Skip to content

[ENG-1858] Materialize Obsidian-origin markdown into Roam - #1267

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

[ENG-1858] Materialize Obsidian-origin markdown into Roam#1267
sid597 wants to merge 4 commits into
mainfrom
eng-1858-materialize-obsidian-origin-markdown-into-roam-v3

Conversation

@sid597

@sid597 sid597 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Implements the ENG-1858 materializer on top of what is merged on main (ENG-1855 discovery, ENG-1856 identity storage, ENG-2019 shared-node payload).

What this adds

  • materializeSharedNode({ client, sharedNode }) — consumes one Obsidian-origin SharedNode payload:
    • validates the boundary (platform, RID shape via isRid, modified-time validity, non-empty title),
    • fetches the full content variant at import-action time and strips frontmatter,
    • materializes via Roam's native markdown importdata.page.fromMarkdown on create, data.block.fromMarkdown on update — so Roam owns markdown→blocks conversion,
    • when a page with the same sourceNodeRid already exists, updates it in place (replacement blocks land before old ones are deleted; rename included) — re-importing never duplicates,
    • writes importedFrom identity via the ENG-1856 helpers only after content writes succeed, and awaits it,
    • returns the source identity plus { success: true, action } or { success: false, error: { message, stage } } so ENG-1859 can report per-node outcomes; a created page whose identity cannot be stored is removed again (no untracked partial import).
  • stripFrontmatter in @repo/content-model next to normalizeLineEndings — pure text logic both apps care about.
  • setBlockProps now returns its block.update promise so the identity write is observable; the 9 existing call sites are prefixed with void, preserving their fire-and-forget behavior exactly. Whether any of them should await is pre-existing and unchanged here.
  • 25 unit tests (16 materializer, 6 identity, 3 text) covering: create, update-not-duplicate (with content-before-delete ordering), rename, both collision refusals, non-Obsidian/RID/timestamp/content-type rejections, fetch errors, identity-write failure cleanup + orphan reporting, title-only page when full is absent.

Decisions to check in review

  • Native fromMarkdown API. data.page.fromMarkdown / data.block.fromMarkdown are not in roamjs-components' types, so their signatures are declared locally and tests mock that declaration — green tests do not prove the real API behaves as declared. Runtime demo owed before merge (see below).
  • No production call site — deliberate. ENG-1859 (import action) is blocked by this ticket and is the consumer. Failure results are returned, not toasted/PostHog'd here — reporting belongs to the action surface, matching DiscoverSharedNodesDialog's internalError pattern.
  • Missing full variant → title-only page rather than failure (full is optional per ENG-2016), via typed data.page.create.
  • Only text/obsidian+markdown is accepted for full — Roam-produced text/markdown embeds the title as an H1 (buildFullMarkdown), which would duplicate the title into the body.
  • Title collision = refuse with an actionable reason, write nothing — on create, and pre-checked on rename-during-update before any mutation. Final collision UX is out of scope per the ticket.
  • Node-type mapping deferred — open question. MVP0 keeps the source title verbatim; a title matching a local node format is recognized by Roam's existing format matching. Verbatim vs reformat-to-local-format vs auto-create (like Obsidian's mapNodeTypeIdToLocal) needs a call; per ENG-1856's boundary note this decision belongs to ENG-1858.
  • Timestamps: sourceModifiedAt is validated and canonicalized with toISOString() before storage — no naive parsing of DB values (the only PostgREST reads here are text/content_type).
  • Pre-existing eslint warnings in DiscourseNodeConfigPanel.tsx and supabaseContext.ts sit on lines this PR does not touch.

Out of scope (per ticket)

Import discovery UI / multi-select (ENG-1859), relations & assets (ENG-1872), refresh-all (ENG-1860), markdown fidelity documentation (ENG-1882).

Verification

  • eslint --max-warnings 0 on changed files, prettier, tsc --noEmit --skipLibCheck: clean (roam + content-model).
  • vitest: roam 67/67 (12 files), content-model 9/9.
  • Runtime demo against real Roam + Supabase owed before merge — the native fromMarkdown behavior (markdown fidelity, caller-supplied uid handling) can only be proven on a live graph.

Adds materializeSharedNode, which consumes one Obsidian-origin SharedNode
payload: fetches the full-variant markdown, strips YAML frontmatter,
converts the body to a Roam block tree, and creates the page or updates
the existing one found by sourceNodeRid (rename included) so re-imports
never duplicate. Source identity is written via the ENG-1856 helpers
after content writes succeed; failures return actionable reasons without
touching stored identity. markdownToRoamBlocks is a pure structural
parser (headings nest content, lists nest by indentation, fenced code
stays whole); fidelity limits are documented under ENG-1882. No UI or
call site here - the import action lands with ENG-1859.
@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

ENG-1858

@supabase

supabase Bot commented Jul 29, 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 29, 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 29, 2026 7:49am

Request Review

@graphite-app

graphite-app Bot commented Jul 29, 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

@sid597 sid597 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inline notes to orient the review — one per non-obvious decision.

.eq("variant", "full")
.maybeSingle();
if (error) return { error: error.message };
return { markdown: data?.text ?? "" };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fetch happens at import-action time, deliberately not during discovery (per the listing-vs-import rework on #1214). Same lookup shape as Obsidian's fetchNodeContent in importNodes.ts, including the one-row-per-variant assumption its maybeSingle makes. A missing row materializes a title-only page rather than failing — full is optional per ENG-2016. text itself is NOT NULL in content.sql; the nullable type (and the ??) comes from my_contents view typegen, so the live path through ?? is row absence.

tree: InputTextNode[];
}): Promise<MaterializeSharedNodeResult> => {
const collidingPageUid = getPageUidByPageTitle(sharedNode.title);
if (collidingPageUid) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

MVP0 title-collision behavior: fail with an actionable reason and write nothing (same on the rename path in updateImportedPage). Final collision UX is out of scope per the ticket. Related: per ENG-1856's boundary note, node-type mapping is ENG-1858's decision — MVP0 keeps the source title verbatim, so a title matching a local node format is picked up by Roam's existing format recognition. Verbatim vs reformat vs auto-create is an open question in the PR description.

for (const [order, node] of tree.entries()) {
await createBlock({ parentUid: pageUid, order, node });
}
writeImportedSourceIdentity({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Identity is written only after content writes succeed — on this update path nothing touches the existing importedFrom props until then, so a mid-write failure can't drop source identity (ticket Done-When #3). Same ordering on the create path above.

return { status: "updated", pageUid };
};

export const materializeSharedNode = async ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No production caller yet by design: ENG-1859 (import action) is blocked by this ticket and consumes this. That's also why failures are returned as reason strings instead of toast/PostHog here — reporting belongs to the action surface, matching how DiscoverSharedNodesDialog handles it with internalError.

const indentWidth = (whitespace: string): number =>
[...whitespace].reduce((width, char) => width + (char === "\t" ? 4 : 1), 0);

export const markdownToRoamBlocks = (markdown: string): InputTextNode[] => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Searched for an existing markdown→blocks path before writing this: roamjs-components/marked is the opposite direction (Roam text → HTML), importDiscourseGraph consumes already-structured JSON, and the export utils all go Roam→markdown. marked's lexer would be a new direct dependency and would still need this tree-building on top of its flat tokens (nesting content under headings is a Roam-import convention, not a token property). Known fidelity limits (tables, setext headings, indented fences, ordered-list numbering, continuation lines) are ENG-1882's documentation scope.

@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 bugs or issues to report.

Open in Devin Review

@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: 6dfcde87f5

ℹ️ 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/roam/src/utils/materializeSharedNode.ts Outdated
Comment thread apps/roam/src/utils/materializeSharedNode.ts
Replaces the hand-rolled markdown parser with Roam's native
data.page.fromMarkdown / data.block.fromMarkdown, so Roam owns markdown
conversion. stripFrontmatter moves to @repo/content-model next to
normalizeLineEndings. Results now carry the source identity and a
failure stage so ENG-1859 can report per-node outcomes. Input is
validated at the boundary (platform, RID shape, modified time, full
content type). setBlockProps returns its update promise so the identity
write can be awaited; existing call sites keep fire-and-forget behavior
via void. On create, a page whose identity cannot be stored is removed
again; on update, replacement blocks land before old ones are deleted.

@sid597 sid597 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Refreshed inline notes for the native-API design (the earlier review's anchors are outdated).

};
};

const getRoamMarkdownApi = (): RoamMarkdownApi =>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

data.page.fromMarkdown / data.block.fromMarkdown exist on the runtime roamAlphaAPI but not in roamjs-components' types, hence this local declaration. Preference order applied here: native Roam API > roamjs-components > hand-rolling — Roam owns markdown→blocks conversion, so fidelity follows Roam's importer rather than code we maintain (remaining fidelity questions are ENG-1882's scope).

},
});

const validateSharedNode = (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Boundary validation before any I/O: platform from the Platform enum (not a RID prefix test, which fails for https-shaped space URIs), RID shape via isRid, and the modified time canonicalized with toISOString() so what lands in block props is always UTC-canonical.

@sid597 sid597 Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Context from maparent on #1224 (comment): he proposes augmenting ridToSpaceUriAndLocalId to also return the Platform (the info is present in the RID forms we design). No change here — for discovery-driven imports the platform arrives server-sourced on the SharedNode (my_spaces.platform), which stays the source of truth. Where the augmented helper pays off is ENG-1860's refresh flow, which starts from a stored importedFrom.sourceNodeRid with no Space row in hand (today that costs a Space-table lookup via getSpaceNameIdFromRid). Under discussion — adopt there if/when he lands it.

return { sourceModifiedAt: modifiedAt.toISOString(), title };
};

const fetchFullMarkdown = async ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Content fetched at import-action time, deliberately not during discovery (per the listing-vs-import rework on #1214). A missing full row materializes a title-only page — full is optional per ENG-2016. Only text/obsidian+markdown is accepted: Roam-produced text/markdown embeds the title as an H1 (buildFullMarkdown), which would duplicate the title into the body. text is NOT NULL in content.sql; the nullable type comes from the my_contents view typegen.

markdown: string;
title: string;
}): Promise<MaterializeSharedNodeResult> => {
if (getPageUidByPageTitle(title))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

MVP0 collision behavior: a page we did not import holds this title, so creating over it is the only destructive outcome available — refuse with an actionable reason and write nothing. Final collision UX is out of scope per the ticket. Node-type mapping is deliberately deferred (title kept verbatim; open question in the PR description).

} catch (error) {
let cleanupError: unknown;
try {
await window.roamAlphaAPI.data.page.delete({ page: { uid: pageUid } });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

If the identity write fails after page creation, the page is removed again so no untracked partial import is left behind — re-importing stays clean. If the cleanup itself fails, the result carries the orphaned pageUid so the caller can surface it.

try {
const previousChildren = getShallowTreeByParentUid(pageUid);
if (markdown) {
await getRoamMarkdownApi().block.fromMarkdown({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Replacement blocks are appended before the previous children are deleted, so the page is never empty mid-flight; the rename was already collision-checked before any mutation. Identity is refreshed last — a failure at any stage returns that stage plus the identity, and never erases the stored importedFrom props.

} as Record<string, json>;
window.roamAlphaAPI.data.block.update({ block: { uid, props } });
return props;
return window.roamAlphaAPI.data.block

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This was a floating promise — callers could not observe whether the props write landed, which the materializer needs before reporting success. Returning the update promise makes it awaitable; the 9 existing call sites are prefixed with void to preserve their fire-and-forget behavior exactly. Whether any of them should await is pre-existing and unchanged by this PR.


const FRONTMATTER_DELIMITER = "---";

export const stripFrontmatter = (markdown: string): string => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Obsidian's full variant is the raw note file including YAML frontmatter (upsertNodesAsContentWithEmbeddings.ts stores vault.read(file)); Roam has no frontmatter concept, so it must be stripped before fromMarkdown. Placed here next to normalizeLineEndings as pure text logic both apps care about. Open question: should the Obsidian producer strip at publish time instead? This is needed either way for rows already published.

@sid597 sid597 Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolving the open question above with maparent's ruling from #1224 (comment): the dual-home design stays — frontmatter lives in literal_content.source_data (jsonb, indexed, key order lost) and in the full body (lossless), so the producer will not strip it and consumer-side stripping is the accepted MVP0 shape (what this helper does). He flags Roam-side stripping itself as acknowledged debt: "Roam should not have to care about Obsidian specifics… it should not have to strip the frontmatter either" — to be revisited with the atJSON transfer, where strippable regions get marked platform-agnostically (ENG-1882's v1 handoff).

Related future scope from the same comment: translating frontmatter into the Roam properties block is a separate piece of work and should read from source_data, not from re-parsing the full text. Deliberately not ticketed yet — under discussion first.

The Content schema allows derived rows per (space_id, source_local_id,
variant) — non-original rows carry original = NULL, so only
(…, variant, original) is unique. Without the original filter a derived
full representation makes maybeSingle() fail with a multiple-rows error
and a valid node cannot be materialized. From Codex review on #1267.
@sid597

sid597 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 9e55349910

ℹ️ 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".

return {
error: `Unsupported full content type "${data.content_type}" — expected "${contentTypes.obsidianMarkdown}"`,
};
return { markdown: stripFrontmatter(data.text).trim() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve leading indentation in imported markdown

When an Obsidian note's first body line is an indented Markdown code block (for example, four leading spaces after the frontmatter), calling .trim() removes that indentation before fromMarkdown receives it, converting the code block into ordinary text. Strip only the blank lines surrounding the body rather than all leading whitespace so valid Markdown structure is preserved.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f6dc8f8: trim() replaced with trimBlankLines (in @repo/content-model next to the other text helpers), which strips only surrounding blank lines so a leading indented code block keeps its indentation; a whitespace-only body still collapses to the title-only path. Both behaviors are pinned by tests ("preserves the indentation of a leading indented code block", "creates a title-only page when the body is only whitespace").

trim() on the stripped body also removed the first line's indentation,
so a body starting with an indented code block degraded to plain text
in Roam. trimBlankLines (content-model) strips only surrounding blank
lines; a whitespace-only body still collapses to the title-only path.
From Codex review on #1267.
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