Skip to content

ENG-2142 Map source to the Roam referenced node on pull - #1382

Open
sid597 wants to merge 21 commits into
mainfrom
eng-2142-map-source-to-the-roam-referenced-node-on-pull
Open

ENG-2142 Map source to the Roam referenced node on pull#1382
sid597 wants to merge 21 commits into
mainfrom
eng-2142-map-source-to-the-roam-referenced-node-on-pull

Conversation

@sid597

@sid597 sid597 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Reviewer brief

  • Result: An imported node whose local format has {Source} gets its title rebuilt from core_title and the local page of the source named in slots.sourceDocument, for example [[EVD]] - REM sleep and recall - [[@Smith 2020]]. The source is found in this order: a RID in this graph's space is the page uid; otherwise the page whose importedFrom.sourceNodeRid matches, which also covers a source in the same import batch because referenced sources are materialized first. A missing source keeps the incoming title and reports a warning in the import dialog and the Refresh toast. No placeholder page.
  • Review focus: findTargetUid moved out of importSharedRelations.ts into its own util so the node importer shares it (parity table below). The {Source} fill is Roam-only in sourceSlot.ts and decorateTitle is unchanged, because {Source} in Roam titles is slated for removal (ENG-2043, DES-368) and ENG-2176 unifies the format grammar. The decision trace with the alternatives considered is the ENG-2142 comment of 2026-09-02.
  • Risk or follow-up: dbToCrossAppConverters.ts:32 builds cross-space relation endpoint RIDs without the platform subtype, the same bug this PR fixes for slot RIDs in sharedNodes.ts; that is ENG-2215. Obsidian's pull side still keeps the published title for {Source} formats (decorateTitle returns null); ENG-2140 owns Obsidian's source mapping. A core title containing the format separator does not survive a republish (lazy capture groups; ENG-2176). The batch ordering is one level deep: a source that itself names a source in the batch is filled on the next Refresh. The up-to-date skip stays timestamp-only (ENG-2156 Decorate imported node titles in Roam from core_title #1331 decision), so an Evidence whose source arrives later keeps its title until Refresh.

Parity table for the moved findTargetUid:

Old (importSharedRelations.ts) New (findTargetUid.ts) Verdict
localSpaceUrl read from roamAlphaAPI.graph.name at module load canonicalRoamUrl() called inside the function changed: removes a module-load read of roamAlphaAPI, which is what makes the util unit-testable
ridType?: string parameter, ridType ?? "note" parameter dropped, "note" passed directly identical: no caller supplied it
bare local id: build RID, then imported-node lookup build RID via sharedReferenceRid, then the own-space check, then the lookup changed: the own-space check also runs on the bare path; importRelations derives spaceUri from the relation's own RID and discovery excludes the local space, so relation import is unaffected
RID construction inline exported sharedReferenceRid changed: orderSourcesFirst needs the same encoding to match a batch member's rid
sync window.roamAlphaAPI.q existence check same identical: moved as is

Stacked on #1331 (ENG-2156); the diff is against that branch.

Verification

  • pnpm ci:validate exit 0 (type checks in 12 packages, unit tests in 3).
  • apps/roam vitest 211 passed, including new cases for each identity path, a missing source, no published source, an unchanged refresh, a refresh after the source arrives, the up-to-date skip, batch ordering, and warning pass-through. packages/database vitest 17 passed.
  • Not verified live: the import dialog and the Refresh toast. The runtime proof plan is in the ENG-2142 decision trace; a non-author drives it.

Loom video

eng-2142-20260907.mp4

Scope check

  • Ran $scope-check against ENG-2142 and the final diff.
  • Scope beyond Done When: None.

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context (pre-pr-review on Opus). Its findings are fixed in 07b61b2; the deferred ones are listed under Risk or follow-up.

sid597 added 14 commits August 24, 2026 20:18
…annot fill

decorateTitle now returns null for formats without a {content} placeholder or
with placeholders such as {Source}: substituting the empty string dropped the
source from a Roam-format Evidence name and produced a title that no longer
matched the format. The Obsidian format-expression helper reuses the shared
placeholder pattern so decorate and match agree.
createDiscourseNodeType only invalidates the new-store cache; with the store
flag off getDiscourseNodes reads discourseConfigRef.nodes, so a created type
stayed invisible and every later import re-entered the create branch. Mirror
the settings panel and call refreshConfigTree after a successful create, and
only count the type as created once the create resolved.

Built-in types now take part in name matching so a remote schema named Page
resolves to Roam's Page instead of creating a user type that shadows it. The
resolver returns the map keyed by schema id, which both callers already hold.
Format precedence follows the Obsidian reader (source_data first, ||), and
the Roam format-expression helper reuses the shared placeholder pattern.
@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

ENG-2142

@graphite-app

graphite-app Bot commented Sep 2, 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

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
discourse-graph Skipped Skipped Sep 6, 2026 7:57pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T10:27:32.122578Z 07b61b2 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@supabase

supabase Bot commented Sep 2, 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 ↗︎.

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

Guideposts for the non-obvious decisions in this diff.

// A shared node refers to another node by its bare local id when both live in the same
// space, and by a RID otherwise. "note" is the subtype node RIDs carry; URL-shaped Roam
// RIDs ignore it.
export const sharedReferenceRid = (

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.

The isRid branch is required by the contract, not defensive. A slot value is a bare local id only when the referring and referred nodes share a space, and per maparent on #1214 a source_local_id may itself be a RID. No producer emits one today: Roam writes 9-char uids and Obsidian writes note paths. "note" is the subtype sharedNodes.ts gives node RIDs; URL-shaped Roam RIDs ignore it, so one literal serves both platforms.


// The local page for a node another space refers to: its own uid when the RID points
// into this graph, else the page imported from it.
export const findTargetUid = 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.

Moved out of importSharedRelations.ts so the node importer can share it. Two deltas from the original, both in the parity table in the description: the local graph URL is read per call instead of at module load, which is what lets this be unit tested, and the unused ridType parameter is gone. The bare-local-id path now also runs the own-space check; it cannot change relation import, because importRelations derives spaceUri from the relation's own RID and discovery excludes the local space.

const rid = sharedReferenceRid(localOrRid, spaceUri);
const { spaceUri: ridSpaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(rid);
if (ridSpaceUri === canonicalRoamUrl()) {
const result = window.roamAlphaAPI.q(

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.

Moved as is, sync q included. The query binds :block/uid, so it matches blocks as well as pages; relations legitimately target blocks. On the pull path a block uid has no page title, so resolveSourceTitle treats it as missing and keeps the incoming title.

// Inverse of sourceUidOfNode, for the pull side: the local title of a node whose format
// names a source, built from its core title and the Source page's title. Null when the
// format has a placeholder neither fills, so the caller keeps the incoming title.
export const titleWithSource = ({

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.

Roam-only on purpose, and not a fields parameter on decorateTitle. {Source} in Roam titles is slated for removal (ENG-2043, DES-368) and ENG-2176 unifies the format grammar, so the fill stays next to the push-side hack it inverts and both retire together. Obsidian's pull path keeps the published title for these formats; ENG-2140 owns Obsidian's source mapping.

Known limitation, same grammar weakness ENG-2176 addresses: a core title containing the format separator does not survive a republish. coreTitle = "a - b" yields [[EVD]] - a - b - [[Src]], and the lazy capture groups in getDiscourseNodeFormatExpression then read the source as b - [[Src]].

// nodes other batch members refer to are materialized first. One level only: a source
// that itself names a source in the batch is not ordered after it, and its title is
// filled on the next refresh instead.
const orderSourcesFirst = (sharedNodes: SharedNode[]): SharedNode[] => {

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.

One pass, not a topological sort. The slot accepts any discourse node (sourceSlot.ts), so a source that itself names a source in the batch is possible; that node keeps its incoming title with a warning and is filled on the next Refresh. resolveSharedNodeTypes still receives the original order; only the materialization loop is reordered.

title: pageTitle,
});
}));
return result.success && warning ? { ...result, warning } : result;

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.

Attached here rather than passed into createImportedPage and updateImportedPage: the warning belongs to the title decision, which both share, and neither helper would do anything with it but echo it.

>;
type Platform = Enums<"Platform">;

const nodeRidSubtype = (platform: Platform): string | undefined =>

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.

Bug fix in its own commit. Node RIDs already carried the platform subtype, but cross-space slot RIDs did not, so a slot pointing at an Obsidian node produced orn:obsidian:vault/x while the same node's stored importedFrom.sourceNodeRid was orn:obsidian.note:vault/x, and the provenance lookup never matched. No migration: stored RIDs already carry the subtype. dbToCrossAppConverters.ts:32 builds relation endpoint RIDs the same way and still omits it; that is ENG-2215.

: "refresh-imported-node-failed",
intent: result.success ? "success" : "danger",
content: result.message,
intent: !result.success

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.

A refresh that kept the title because the source is missing succeeded, so the toast stays a success message, but it carries a warning, so its intent matches the import dialog's callout for the same case.

? [{ sharedNode: item.sharedNode, message: item.warning }]
: [],
);
const importNotices = [...failedImports, ...warnings];

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.

Warnings are listed with the failures in the same per-node form and counted in the title, so a yellow callout never shows bullets its title does not account for. The warning text says the title was kept, which is what distinguishes it from a failure.

}));

// Runs before the imports above: getDiscourseNodes calls generateUID at module load.
vi.hoisted(() => {

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.

Same block in importSharedNodes.test.ts, refreshImportedNode.test.ts and sourceSlot.test.ts. getDiscourseNodes.ts builds DEFAULT_NODES at module load and calls generateUID while doing it; importing sourceSlot pulls that in, so window has to exist before the imports run. Copied from roamToCrossAppConverters.test.ts.

@sid597

sid597 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Size justification, per the guideline: one problem, resolving a pulled node's source into its Roam title. 232 lines outside tests across 9 files; 480 of the 712 lines are unit tests for the identity paths and fallbacks the ticket lists. The files are coupled: the RID subtype fix in sharedNodes.ts is what makes the provenance lookup match, findTargetUid had to move out of importSharedRelations.ts to be shared, and the warning has to be carried from the materializer through the importer to the two surfaces that show it. Splitting would leave each half without a testable behavior.

…titles-in-roam-from-core_title' into codex/workday-20260906-pr-1382
Base automatically changed from eng-2156-decorate-imported-node-titles-in-roam-from-core_title to main September 6, 2026 17:38

@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 found 1 potential issue.

Devin Review

Comment on lines +29 to +31
const result = window.roamAlphaAPI.q(
`[:find (?e) :where [?e :block/uid "${sourceLocalId}"]]`,
);

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.

🟥 Shared source IDs inject Datalog

A shared Roam source ID is interpolated into roamAlphaAPI.q without parameters. Crafted IDs can alter the query and resolve an attacker-chosen page.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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