Skip to content

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

Open
sid597 wants to merge 5 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 5 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
eng-1858.mp4

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.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Jul 30, 2026 5:36am

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

This comment was marked as outdated.

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

Comment thread apps/roam/src/utils/materializeSharedNode.ts Outdated
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.
Only writeImportedSourceIdentity needs to observe the props-write result
(materializeSharedNode deletes the created page when the identity write
fails), so expose that as setBlockPropsAsync and keep the default export
fire-and-forget. This reverts the signature change that forced void at
eight call sites unrelated to this feature.
@sid597

sid597 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 2d3e6906f3

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

@sid597
sid597 requested review from maparent and mdroidian July 30, 2026 07:31
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