Skip to content

ENG-1857 Validate Obsidian importer with Roam-origin nodes - #1225

Open
sid597 wants to merge 3 commits into
mainfrom
eng-1857-validate-obsidian-importer-with-roam-origin-nodes-v2
Open

ENG-1857 Validate Obsidian importer with Roam-origin nodes#1225
sid597 wants to merge 3 commits into
mainfrom
eng-1857-validate-obsidian-importer-with-roam-origin-nodes-v2

Conversation

@sid597

@sid597 sid597 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Validates and fixes the existing Obsidian shared-node importer for Roam-origin nodes. The importer now:

  • imports Roam-origin full markdown payloads without requiring source frontmatter
  • derives the source node type from source my_concepts schema rows before mapping it locally
  • writes stable imported identity frontmatter (nodeInstanceId, mapped nodeTypeId, importedFromRid, lastModified, authorId)
  • prevents duplicate imports by source space + source node identity instead of title alone
  • avoids overwriting same-title imports by choosing an available import path
  • reports why individual nodes failed instead of only counting them

Root Cause

Roam-origin shared node markdown does not include the Obsidian-style frontmatter that the importer was previously parsing from rawContent. That meant the importer could create the file, fail to find nodeTypeId / nodeInstanceId, and then delete the file as invalid.

Decisions worth your attention

Duplicate detection is no longer scoped to import/. QueryEngine.getImportedNodePages() filtered on path("import"); the importedFromRid + nodeInstanceId frontmatter pair is what actually identifies an imported node. With the folder filter, a note moved out of import/ was offered for import again, and because findExistingImportedFile searches the whole vault, confirming that import overwrote the relocated file's body — losing edits made after the move. The filter is gone from both the DataCore query and the vault-iteration fallback. That method has three callers, so this also fixes refreshAllImportedFiles and buildEndpointToFileMap skipping relocated notes. Those two are outside this ticket's stated scope; I judged a shared "which notes came from elsewhere" query having no business caring about the folder to be a fix rather than scope creep, but flagging it since it widens the blast radius of a 2-line change.

.eq("arity", 0) on the my_concepts lookups, per @maparent on #1147 (syncDgNodesToSupabase.ts: "add arity = 0 to avoid relation instances") and #1214 (discoverSharedNodes.ts: "Add .eq(\"arity\", 0) to avoid relations"). Applied to the node-type schema query too, matching the existing is_schema = true + arity = 0 pairing at syncDgNodesToSupabase.ts:186, :519 and templateImport.ts:92 — those sites are also the evidence that node-type schemas carry arity = 0 (compute_arity_local returns COALESCE(jsonb_array_length(literal_content->'roles'), 0), so node types compute 0 and relation types compute 2).

Node type resolution is handed over from the preview rather than re-queried. computeImportPreview already ran the same instance → schema lookup, and precomputedData exists to avoid re-querying, so the preview now builds sourceNodeTypeIdByKey (its schema select gained id) and passes it through. fetchSourceNodeTypeIds survives as the fallback for refreshImportedFile, which has no preview — the same shape keyToRelationEndpointId already uses.

Failure reporting follows refreshAllImportedFiles. It returns { success, failed, errors } and its caller shows an aggregate Notice then console.errors the array (registerCommands.ts:167); the importer now does the same. Per-node reasons are not surfaced in the UI because no path in this app does that — that would be an app-wide change. apps/obsidian has no PostHog integration, so there is no telemetry path here either.

Opening the import modal now costs one DB query. Duplicate keys are space-scoped, and mapping importedFromRidspaceId requires the DB, so the local-only frontmatter scan became getImportedNodesInfo (one batched space lookup).

Parity notes on the frontmatter write. authorId was if (authorId) record.authorId = … and is now written unconditionally — reachable only for authorId: 0, which is not a real id. The if (mappedNodeTypeId !== undefined) guard is gone because mapNodeTypeIdToLocal returns Promise<string> and falls back to sourceNodeTypeId, so it could never be false.

Removed: gray-matter (this PR removed its last usage), getLocalNodeInstanceIds (replaced by getImportedNodesInfo), and processFileContent's error return arm (it has no failing path left, so the union, the caller's error branch, and the result.file! assertion it forced all went with it). The lockfile is edited surgically — a full pnpm install --lockfile-only churned 180 unrelated lines of peer-dependency hashes; pnpm install --frozen-lockfile accepts the 3-line edit.

No unit test harness. An earlier revision of this branch added vitest plus tests for four extracted helpers. apps/obsidian had no test infra, and the extraction had started shaping production code around the tests (a parameter existed only so a test could assert Object.assign preserves keys). Harness, tests, and the file they were extracted into are all removed; validation for this ticket is the runtime flow below. A standalone infra PR is the right way in if we want Obsidian unit tests.

Known Limits

  • The existing Obsidian materializer normalizes source [[CLM]] into [[CLM|CLM]]. Semantic body and nested bullets are preserved; byte-for-byte fidelity is covered by ENG-1882.
  • Roam's full content variant is built as # ${title}\n\n${body} (roamToCrossAppConverters.ts:38), so an imported Roam note opens with an H1 duplicating its own title. Obsidian-origin full is body-only, per the direct/full split. Not changed here; documented for ENG-1882.

Runtime Proof

⚠️ The run below was recorded against 42b7d970, before the review fixes in d5ec65bc and a0da822a. It does not cover the current code and needs re-driving. Changed since: the my_concepts query shape (arity filters), where node types come from (preview handoff), the duplicate-detection scan scope, and the frontmatter write path.

Recorded against 42b7d970, through the real Obsidian command:

  1. Opened Discourse Graph: Import nodes from another space in the Obsidian plugin.
  2. Registered the Obsidian vault user into the same local Supabase group as the Roam source data: eng1890testgroups.
  3. Discovery showed two Roam-origin nodes from test-1-graph:
    • [[EVD]] - This is a Evidence page. - {Source}
    • [[CLM]] - cat -
  4. Selected the Claim node.
  5. Preview showed 1 node, 0 relations, and no new schemas/relations.
  6. Confirmed import.
  7. Obsidian created import/test-1-graph/[[CLM]] - cat -.md with mapped frontmatter and the Roam markdown body.
  8. Reopened the import modal; the imported Claim was hidden while the unimported Evidence node remained visible.

Cells the re-drive must cover, none of which the run above exercised:

  • a Roam-origin node imports end to end (proves the arity = 0 filters return rows — if that reading is wrong, node type resolution returns nothing and every import fails)
  • two selected nodes sharing a title land in separate files, the second as (1).md
  • an imported note moved out of import/ is still recognised as already imported when the modal is reopened
  • a node whose import fails produces a reason in the console alongside the summary Notice
  • multi-space selection, so the space-scoped keys are exercised across more than one source space

Validation

  • pnpm --filter @discourse-graphs/obsidian check-types
  • pnpm --filter @discourse-graphs/obsidian build — 0 errors
  • eslint --max-warnings 0 / prettier --check on changed files; no eslint warning falls on a line this PR touches
  • No unit tests — see "No unit test harness" above

@linear-code

linear-code Bot commented Jul 12, 2026

Copy link
Copy Markdown

ENG-1857

@supabase

supabase Bot commented Jul 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Jul 27, 2026 4:25pm

Request Review

@sid597
sid597 changed the base branch from eng-2019-extract-shared-cross-space-node-discovery-into to main July 27, 2026 15:26
@sid597
sid597 force-pushed the eng-1857-validate-obsidian-importer-with-roam-origin-nodes-v2 branch from f06e484 to 42b7d97 Compare July 27, 2026 15:29
@sid597
sid597 marked this pull request as ready for review July 27, 2026 15:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42b7d97044

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/obsidian/src/utils/importNodes.ts Outdated
Comment on lines +87 to +91
const { nodeKeys } = await getImportedNodesInfo({
queryEngine,
plugin,
client,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scan relocated imports when building duplicate keys

When a user moves an imported note outside import/, getImportedNodesInfo no longer detects it because QueryEngine.getImportedNodePages() explicitly restricts its scan to that folder. The node is therefore offered for import again; if selected, findExistingImportedFile() still finds the relocated file across the whole vault and processFileContent() replaces its contents with the remote payload, potentially destroying edits made after relocation. Duplicate discovery should include imported notes regardless of their current folder.

Useful? React with 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

Removes the Obsidian unit-test harness this branch introduced (vitest,
test:unit, sharedNodeImport.test.ts). The app had no test infra, and the
extracted-for-testability helpers had started shaping production code
around the tests rather than the feature. sharedNodeImport.ts goes with
it, its pieces moving to where they belong.

- dedupe and refresh scans find imported notes wherever they now live,
  not only under import/: a relocated note was re-offered for import and
  then overwritten in place
- node type resolution is handed over from computeImportPreview through
  precomputedData instead of re-querying the same concept/schema rows at
  import time; the direct fetch remains for refreshImportedFile
- my_concepts lookups filter arity = 0 and use generated row types with
  explicit null narrowing instead of hand-written types behind casts
- failed imports log why: query error, no node type schema, no content
- processFileContent returns TFile; its unreachable error arm, the
  assertion that arm forced, and the unused originalFilePath param are
  gone, along with the now-always-defined optional params
- getImportedNodeKey is the single definition of the spaceId:localId key
- getAvailableImportPath covers both the new-file and rename collisions
- drops gray-matter, unused since frontmatter parsing was removed
importSelectedNodes now returns the per node reasons it collected, matching
what refreshAllImportedFiles already returns, and the modal logs them next
to its summary Notice the way the refresh command does. Before this, a node
that could not be imported was counted and dropped in silence.

refreshImportedFile passes the collected reason through instead of the fixed
"Failed to refresh imported file" string, so refreshAllImportedFiles reports
something usable.

failed is derived from the collected reasons rather than tracked in its own
counter: every path out of a node either succeeds or records exactly one
reason, so a separate count was a second source of truth. Reasons carry the
instance id because titles are not unique across source spaces, which is the
case this branch exists to handle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant