Skip to content

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

Merged
sid597 merged 27 commits into
mainfrom
eng-2142-map-source-to-the-roam-referenced-node-on-pull
Sep 18, 2026
Merged

sid597 merged 27 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 fills {Source} with [[@placeholder]] so the title keeps the node type's format, and reports a warning in the import dialog and the Refresh toast; Refresh replaces the placeholder once the source is imported (95eab0d, per mdroidian's review).
  • 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

Verification

  • pnpm ci:validate exit 0 (type checks in 12 packages, unit tests in 3).
  • apps/roam vitest 221 passed, including new cases for each identity path, a missing source and its placeholder title, 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

Recorded before the placeholder change (95eab0d).

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.
  • Re-ran it on the final diff on 2026-09-16 after 95eab0d. Two findings are open and a fix is in progress: the [[@placeholder]] reference parses as a Source on republish and reaches sourceDocument, and two imports with the same core title and unresolved Sources now collide. Hold review of the placeholder change until the follow-up commit lands.

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.

Project Deployment Actions Updated
discourse-graph Ready Ready Preview Sep 18, 2026 4:25pm 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.

Comment thread apps/roam/src/utils/findTargetUid.ts Outdated
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why not reuse the topological sort that already exists as orderConceptsRec ?

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.

Reused orderConceptsByDependency, which calls orderConceptsRec. A small adapter maps each SharedNode and its source reference to RIDs before sorting, then returns the original SharedNode objects. This avoids collisions between bare IDs from different spaces. The separate ordering algorithm is gone. Tests cover chained sources, cycles, and references absent from the batch.

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 thread apps/roam/src/utils/findTargetUid.ts Outdated
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.

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 starting head 80acda8 already binds the source ID through :in instead of interpolating it. That binding is preserved with data.async.fast.q. The regression test passes an ID containing Datalog syntax as a separate argument.

@mdroidian

Copy link
Copy Markdown
Member

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 58 minutes.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 58 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 2e273276-91ea-4c6a-911e-a7cd4f86237f

📥 Commits

Reviewing files that changed from the base of the PR and between 4dbfbd1 and 80acda8.

📒 Files selected for processing (19)
  • apps/roam/src/components/DiscoverSharedNodesDialog.tsx
  • apps/roam/src/components/RefreshImportedNodeTitleButton.tsx
  • apps/roam/src/utils/__tests__/findTargetUid.test.ts
  • apps/roam/src/utils/__tests__/importSharedNodes.test.ts
  • apps/roam/src/utils/__tests__/importSharedRelations.test.ts
  • apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
  • apps/roam/src/utils/__tests__/refreshAllImportedNodes.test.ts
  • apps/roam/src/utils/__tests__/refreshImportedNode.test.ts
  • apps/roam/src/utils/__tests__/sourceSlot.test.ts
  • apps/roam/src/utils/findTargetUid.ts
  • apps/roam/src/utils/importSharedNodes.ts
  • apps/roam/src/utils/importSharedRelations.ts
  • apps/roam/src/utils/materializeSharedNode.ts
  • apps/roam/src/utils/refreshAllImportedNodes.ts
  • apps/roam/src/utils/refreshImportedNode.ts
  • apps/roam/src/utils/registerCommandPaletteCommands.ts
  • apps/roam/src/utils/sourceSlot.ts
  • packages/database/src/lib/__tests__/sharedNodes.test.ts
  • packages/database/src/lib/sharedNodes.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Collaborator

Curious: You say this is stacked on ENG-2156, but the PR #1382 specifies merging on main.

@maparent maparent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, but I think one function can just go.

// 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

Choose a reason for hiding this comment

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

Why not reuse the topological sort that already exists as orderConceptsRec ?

@mdroidian mdroidian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing {source} requires a placeholder, otherwise it won't be recognized as the original node type.

Decision notes: https://roamresearch.com/#/app/discourse-graphs/page/7H06vXWQc

Comment thread apps/roam/src/utils/findTargetUid.ts Outdated
const rid = sharedReferenceRid(localOrRid, spaceUri);
const { spaceUri: ridSpaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(rid);
if (ridSpaceUri === canonicalRoamUrl()) {
const result = window.roamAlphaAPI.q(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Never use window.roamAlphaAPI.q

Always use window.roamAlphaAPI.data

Prefer data.async.fast.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.

Changed the existence lookup to await window.roamAlphaAPI.data.async.fast.q. The shared UID stays bound through :in, and the tests now return promises so they exercise the async path.

@sid597

sid597 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Comment thread apps/roam/src/utils/sourceSlot.ts Outdated
// either of them.

export const SOURCE_SLOT = "sourceDocument";
export const MISSING_SOURCE_PLACEHOLDER = "(source missing)";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Change to [[@placeholder]] otherwise it won't serve the intended purpose. Currently this placeholder would not let the Evidence node be identified as an evidence node.

EG: it has to match: [[EVD]] - {content} - {Source} and typically {Source} is defined as a page with this format: @title

Ideally it should grab the format from the linked reference node (eg: {Source} would see what the {Source} format is and adhere to that. But we can defer that to v1. If you decide to defer it, please create the ticket and link it here.

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.

I think we should fix this using the format. For now this will work because [[EVD]] - {content} - {Source} turns into ^[[EVD]] - (.*?) - (.*?)$ so any text after the second dash matches I checked the 4 graphs that we have discourse-graphs, megacoglab, akamatsulab and discourse-dev-graph (the one I tested on). Also you can see in the image there is the publish button that only appears because this page is being recognised as a dg node

image.png

…o-the-roam-referenced-node-on-pull

# Conflicts:
#	apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
#	apps/roam/src/utils/importSharedNodes.ts
#	apps/roam/src/utils/importSharedRelations.ts
#	apps/roam/src/utils/materializeSharedNode.ts
#	packages/database/src/lib/__tests__/sharedNodes.test.ts
#	packages/database/src/lib/sharedNodes.ts
@sid597
sid597 merged commit a81be44 into main Sep 18, 2026
11 checks passed
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.

3 participants