Skip to content

ENG-1858 Materialize Obsidian-origin markdown into Roam - #1224

Open
sid597 wants to merge 2 commits into
mainfrom
eng-1858-materialize-obsidian-origin-markdown-into-roam-v2
Open

ENG-1858 Materialize Obsidian-origin markdown into Roam#1224
sid597 wants to merge 2 commits into
mainfrom
eng-1858-materialize-obsidian-origin-markdown-into-roam-v2

Conversation

@sid597

@sid597 sid597 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What

A Roam materializer for one Obsidian-origin CrossAppNode: create a local page from the shared markdown, or update the page already imported from the same source RID instead of duplicating it, preserving imported source identity for later refresh.

Base is main (ENG-1856 landed in #1218).

Behaviour

First import — validate the input, refuse if the title is already held by a page we did not import, create the page from the markdown body via Roam's native data.page.fromMarkdown, then store the source RID and source-modified time. If the identity write fails the new page is removed, so no untracked partial import is left behind.

Re-import — find the page by source RID, materialize replacement blocks and delete the previously captured children only after the replacement succeeds, then rename the title, then refresh source identity.

Failures return a stage (validate-input, find-imported-node, title-collision, create-page, replace-page-content, update-page-title, write-source-identity) and never drop the source RID or timestamp, so ENG-1859 can report per-node outcomes.

Decisions a reviewer will want

Obsidian frontmatter is stripped before Roam sees the markdown. The full content variant is the raw note file — apps/obsidian/src/utils/upsertNodesAsContentWithEmbeddings.ts:74-81 sets it to vault.read(node.file), frontmatter included (node.frontmatter is stored additionally in metadata, not instead). Roam has no frontmatter concept, so passing it through materialized ---/nodeTypeId:/… as literal blocks. The mirror-direction importer does the same thing with gray-matter (apps/obsidian/src/utils/importNodes.ts:948). stripFrontmatter lives in @repo/content-model next to normalizeLineEndings rather than as a Roam-local copy, since it is pure text logic both apps care about. Open question for @maparent: the contract says direct = title, full = body — should the Obsidian producer be stripping frontmatter at publish time instead? This strip is needed either way for rows already published.

Origin comes from the Platform enum, not the RID string. discoverSharedNodes already surfaces sourceApp: Enums<"Platform"> from my_spaces.platform, so materializeObsidianNode takes it as input rather than testing the RID for an obsidian: prefix — that prefix test also fails for the web-URL RID form that spaceUriAndLocalIdToRid emits for http-shaped space URIs. RID shape is checked with isRid.

Title collision is refused, not resolved. Roam page titles are unique and findImportedNodeUidBySourceRid cannot see a page the user authored locally. Creating over one is the only genuinely destructive outcome available here, so the create path returns title-collision and writes nothing. Richer collision UX is out of scope per the issue; this is the documented MVP0 behaviour.

setBlockProps stays the only block-props writer. Making the identity write observable needs an awaitable write, and the reason it was not awaitable is a floating promise inside setBlockProps (which already carried a no-floating-promises warning). Rather than keep a second copy of the merge logic in writeImportedSourceIdentity, setBlockProps now returns the update promise. The 10 existing call sites are prefixed with void, which preserves their current fire-and-forget behaviour exactly — whether any of them should await is pre-existing and unchanged by this PR.

node.nodeType is intentionally unused. Obsidian types a note through frontmatter; Roam types a page through its title-format regex. Reconciling those is a cross-grammar decision, so MVP0 keeps the source title verbatim — an imported node is recognised as its type only when the title already matches a local format. Active type reconciliation/creation is follow-up work.

Only text/obsidian+markdown is accepted for full. No producer emits text/markdown for an Obsidian node, and text/markdown payloads embed the title as an H1 (buildFullMarkdown in roamToCrossAppConverters.ts), which would duplicate the title into the page body.

Scope

Materializer API only — nothing calls it yet. ENG-1859 wires the discovery selection UI and multi-node reporting; it is blocked by this issue. Relations, assets and refresh-all are ENG-1872 / ENG-1860.

Over the size guideline at ~15 files. The coupling: 341 lines are the two test files, setBlockProps + its 10 void call sites are one mechanical change needed to make the identity write awaitable, and stripFrontmatter is 20 lines in @repo/content-model. Splitting the setBlockProps change into its own base PR is fine if preferred.

Validation

  • pnpm --filter roam test — 12 files / 63 tests pass
  • pnpm --filter @repo/content-model test — 2 files / 9 tests pass
  • pnpm --filter roam check-types, pnpm --filter @repo/content-model check-types — pass
  • pnpm --filter roam build — 0 errors
  • ESLint / Prettier clean on every changed file (the remaining warnings in DiscourseNodeConfigPanel.tsx and supabaseContext.ts are pre-existing, on lines this PR does not touch)

Runtime proof still owed

data.page.fromMarkdown and data.block.fromMarkdown are not in roamjs-components' roamAlphaAPI type, so their signatures are declared locally and every test mocks that declaration. Green tests here do not prove the real API behaves as declared. Not yet driven against a live graph: the created/updated page shapes, whether page.fromMarkdown honours a caller-supplied uid, what page.update with "merge-pages": false does when the target title is taken, and how Obsidian wikilinks/embeds land in Roam blocks (markdown fidelity limits are ENG-1882). Do not merge on the unit tests alone.

Known boundary

Roam mutations are not transactional. If replacement markdown is imported but deleting a previous child fails, the result reports replace-page-content and the page can briefly contain both new and leftover old blocks.

Also worth a follow-up: findImportedNodeUidBySourceRid (from #1218) takes the first row of an unordered datalog result, so if two pages ever carry the same source RID, which one a re-import overwrites is non-deterministic.

Linear

ENG-1858

@linear-code

linear-code Bot commented Jul 12, 2026

Copy link
Copy Markdown

ENG-1858

@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.

Project Deployment Actions Updated (UTC)
discourse-graph Ready Ready Preview, Comment Jul 27, 2026 7:16pm

Request Review

@sid597
sid597 force-pushed the eng-1856-store-source-identity-metadata-for-roam-imported-nodes-v2 branch 2 times, most recently from 9b7eaf7 to 9eb8f9f Compare July 24, 2026 09:06
@sid597
sid597 changed the base branch from eng-1856-store-source-identity-metadata-for-roam-imported-nodes-v2 to main July 27, 2026 15:28
@sid597
sid597 force-pushed the eng-1858-materialize-obsidian-origin-markdown-into-roam-v2 branch from b977291 to dbb154b Compare July 27, 2026 15:29
@sid597
sid597 marked this pull request as ready for review July 27, 2026 15:30
@graphite-app

graphite-app Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR size/scope check

This PR is over our review-size guideline.

  • Recommended: ~200 lines changed
  • Acceptable limit: up to 400 lines when well-scoped/self-contained
  • Preferred file count: fewer than 5 files

Please split this into smaller PRs unless there is a clear reason the changes need to land together.

If keeping it as one PR, please add a brief justification covering:

  • What single problem this PR solves
  • Why the files/changes are coupled

@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 2 additional findings.

Open in Devin Review

Strip Obsidian frontmatter before handing markdown to Roam. The `full`
content variant is the raw note file, so the YAML block was materializing
as literal Roam blocks. Adds `stripFrontmatter` to @repo/content-model.

Determine Obsidian origin from the `Platform` enum that discovery already
carries, instead of sniffing an `obsidian:` prefix off the RID, and check
RID shape with `isRid`.

Refuse to create a page whose title is already held by a page that was not
imported from this source, so the MVP0 collision behaviour is defined.

Replace page content before renaming the title, so a markdown failure
leaves the existing page fully untouched.

Store `sourceModifiedAt` as canonical UTC, and accept only
`text/obsidian+markdown` for `full` — no producer emits `text/markdown`.

Restore `setBlockProps` as the single block-props writer by returning its
update promise, replacing the copy of its body in
`writeImportedSourceIdentity`. Existing call sites keep their current
fire-and-forget behaviour via `void`.

Narrow the untyped-API cast to the `fromMarkdown` members that are actually
missing from roamjs-components, and stop declaring a return value the code
ignores.

Copy link
Copy Markdown
Collaborator

Ok. First Rid because it's an easy question: I think we could augment ridToSpaceUriAndLocalId so it also delivers the Platform in the structure. The information is present for the platforms we know, and I think it's a safe assumption that the Rids we design will always include that information. Would that help?

Copy link
Copy Markdown
Collaborator

Stripping Frontmatter is more complex. Right now we put the frontmatter in two places: the Concept's literal_content (under source_data), and we also keep it in the concept's body. In theory this is redundant; in practice, the literal_content is jsonb, does not preserve key order, and hence the transfer would be lossy if we did not keep it there. Possibly acceptably so? OTH, putting it in literal_content allows it to be indexed. So I would tend to keep this design, but also open to discussion. (Which does not mean it could not be improved. I think that a lot of the internal keys should be stripped from source_data.)
We said we woudl translate the frontmatter to the Roam properties block; though I guess that is another scope. There is a question whether we'd take the properties from the source_data or from re-parsing the full text. I prefer the former, as Roam should not have to care about Obsidian specifics. By that rule, it should not have to strip the frontmatter either; this is something that I hope we revisit with the transfer to ATJson. We should be able to mark that region as something that should be stripped in cross-platform transfer, in a way that each platform does not have to know about other platforms. But again I see that as future scope. I hope that's helpful.

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.

2 participants