From b1cff46b141fa76d4daae60edc562996716d38cf Mon Sep 17 00:00:00 2001 From: sid597 Date: Fri, 26 Jun 2026 20:56:57 +0530 Subject: [PATCH 1/2] [ENG-1856] Store source identity metadata for Roam imported nodes Persist an imported cross-app node's stable source identity in the Roam page's block-props under `discourse-graph.importedFrom`, with helpers to derive it from a CrossAppNode, read/write it, and find an imported page by sourceNodeRid for duplicate prevention. Stored in block-props so it survives content overwrite and is datalog-queryable; mirrors the reified-block props pattern and reuses the shared rid.ts helpers. Includes a typed example fixture and unit tests for the pure functions. --- .../__tests__/importedSourceIdentity.test.ts | 118 +++++++++++++++ .../utils/importedSourceIdentity.example.ts | 43 ++++++ apps/roam/src/utils/importedSourceIdentity.ts | 142 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts create mode 100644 apps/roam/src/utils/importedSourceIdentity.example.ts create mode 100644 apps/roam/src/utils/importedSourceIdentity.ts diff --git a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts new file mode 100644 index 000000000..c872cceda --- /dev/null +++ b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { CrossAppNode } from "@repo/database/crossAppNodeContract"; +import type { json } from "~/utils/getBlockProps"; +import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock"; +import { + IMPORTED_FROM_PROP_KEY, + parseImportedSourceIdentity, + toImportedSourceIdentity, + type ImportedSourceIdentity, +} from "~/utils/importedSourceIdentity"; + +const identity: ImportedSourceIdentity = { + sourceApp: "obsidian", + sourceSpaceId: "obsidian:9a8b7c6d5e4f3210", + sourceNodeId: "0192f1a0-7b3c-7e2a-9f10-1a2b3c4d5e6f", + sourceNodeRid: + "orn:obsidian.note:9a8b7c6d5e4f3210/0192f1a0-7b3c-7e2a-9f10-1a2b3c4d5e6f", + sourceTitle: "EVD - REM sleep and recall", + sourceModifiedAt: "2026-06-14T10:30:00.000Z", +}; + +const propsWithImportedFrom = ( + importedFrom: json, + siblingProps: Record = {}, +): Record => ({ + [DISCOURSE_GRAPH_PROP_NAME]: { + ...siblingProps, + [IMPORTED_FROM_PROP_KEY]: importedFrom, + }, +}); + +describe("toImportedSourceIdentity", () => { + it("maps the six identity fields from a cross-app node, using direct content as title", () => { + const node: CrossAppNode = { + sourceApp: "obsidian", + sourceSpaceId: identity.sourceSpaceId, + sourceSpaceName: "Research Vault", + sourceNodeId: identity.sourceNodeId, + sourceNodeRid: identity.sourceNodeRid, + nodeType: { sourceNodeTypeId: "evd-7c1f9a2b", label: "Evidence" }, + content: { + direct: { value: identity.sourceTitle }, + full: { format: "text/markdown", value: "# body\n" }, + }, + sourceModifiedAt: identity.sourceModifiedAt, + }; + + const result = toImportedSourceIdentity(node); + + expect(result).toEqual(identity); + expect(Object.keys(result).sort()).toEqual( + [ + "sourceApp", + "sourceModifiedAt", + "sourceNodeId", + "sourceNodeRid", + "sourceSpaceId", + "sourceTitle", + ].sort(), + ); + }); +}); + +describe("parseImportedSourceIdentity", () => { + it("round-trips a stored identity", () => { + expect( + parseImportedSourceIdentity(propsWithImportedFrom(identity)), + ).toEqual(identity); + }); + + it("ignores sibling discourse-graph props", () => { + const props = propsWithImportedFrom(identity, { + "relation-migration": { abc123: 1718000000000 }, + }); + expect(parseImportedSourceIdentity(props)).toEqual(identity); + }); + + it("returns undefined when there are no discourse-graph props", () => { + expect(parseImportedSourceIdentity({})).toBeUndefined(); + }); + + it("returns undefined when discourse-graph has no importedFrom", () => { + expect( + parseImportedSourceIdentity({ + [DISCOURSE_GRAPH_PROP_NAME]: { "relation-migration": {} }, + }), + ).toBeUndefined(); + }); + + it("returns undefined when a required field is missing", () => { + const withoutRid = { + sourceApp: identity.sourceApp, + sourceSpaceId: identity.sourceSpaceId, + sourceNodeId: identity.sourceNodeId, + sourceTitle: identity.sourceTitle, + sourceModifiedAt: identity.sourceModifiedAt, + }; + expect( + parseImportedSourceIdentity(propsWithImportedFrom(withoutRid)), + ).toBeUndefined(); + }); + + it("returns undefined when a field has the wrong type", () => { + expect( + parseImportedSourceIdentity( + propsWithImportedFrom({ ...identity, sourceModifiedAt: 1718000000000 }), + ), + ).toBeUndefined(); + }); + + it("returns undefined for an unknown sourceApp", () => { + expect( + parseImportedSourceIdentity( + propsWithImportedFrom({ ...identity, sourceApp: "notion" }), + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/roam/src/utils/importedSourceIdentity.example.ts b/apps/roam/src/utils/importedSourceIdentity.example.ts new file mode 100644 index 000000000..e55456c57 --- /dev/null +++ b/apps/roam/src/utils/importedSourceIdentity.example.ts @@ -0,0 +1,43 @@ +import type { CrossAppNode } from "@repo/database/crossAppNodeContract"; +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import { + toImportedSourceIdentity, + type ImportedSourceIdentity, +} from "./importedSourceIdentity"; + +/** + * Example of the source identity persisted on a Roam page when an Obsidian-origin + * shared node is imported. Derived from the cross-app node contract so the stored + * shape stays in lockstep with `CrossAppNode`. Not imported by runtime code; this + * file exists to type-check and document the stored metadata. + * + * The source node type is intentionally absent from the stored identity: node-type + * mapping is the materializer's concern (ENG-1858), not ENG-1856's. + */ + +const OBSIDIAN_SOURCE_SPACE_ID = "obsidian:9a8b7c6d5e4f3210"; +const OBSIDIAN_SOURCE_NODE_ID = "0192f1a0-7b3c-7e2a-9f10-1a2b3c4d5e6f"; + +const obsidianOriginNode: CrossAppNode = { + sourceApp: "obsidian", + sourceSpaceId: OBSIDIAN_SOURCE_SPACE_ID, + sourceSpaceName: "Research Vault", + sourceNodeId: OBSIDIAN_SOURCE_NODE_ID, + sourceNodeRid: spaceUriAndLocalIdToRid( + OBSIDIAN_SOURCE_SPACE_ID, + OBSIDIAN_SOURCE_NODE_ID, + "note", + ), + nodeType: { sourceNodeTypeId: "evd-7c1f9a2b", label: "Evidence" }, + content: { + direct: { value: "EVD - REM sleep and recall" }, + full: { + format: "text/markdown", + value: "# REM sleep correlates with recall\n", + }, + }, + sourceModifiedAt: "2026-06-14T10:30:00.000Z", +}; + +export const obsidianImportedIdentityExample: ImportedSourceIdentity = + toImportedSourceIdentity(obsidianOriginNode); diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts new file mode 100644 index 000000000..adc2521ba --- /dev/null +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -0,0 +1,142 @@ +import type { CrossAppNode } from "@repo/database/crossAppNodeContract"; +import getBlockProps, { type json } from "./getBlockProps"; +import setBlockProps from "./setBlockProps"; +import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; + +/** + * Stable identity of a node imported into Roam from another app, persisted on the + * imported page so it can be re-found (duplicate prevention) and refreshed later. + * Field names mirror the app-neutral `CrossAppNode` contract; the page's display + * title is kept separate from this stored identity. + */ +export type ImportedSourceIdentity = { + sourceApp: CrossAppNode["sourceApp"]; + sourceSpaceId: string; + sourceNodeId: string; + sourceNodeRid: string; + sourceTitle: string; + sourceModifiedAt: string; +}; + +/** + * Sub-key under the shared `discourse-graph` block-props namespace holding the + * imported source identity, alongside any other discourse-graph props. + */ +export const IMPORTED_FROM_PROP_KEY = "importedFrom"; + +const isJsonObject = (value: json): value is { [key: string]: json } => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isSourceApp = (value: json): value is CrossAppNode["sourceApp"] => + value === "roam" || value === "obsidian"; + +/** + * Derive the stored identity from a cross-app node payload. The display title + * comes from the `direct` content variant, per the contract. + */ +export const toImportedSourceIdentity = ( + node: CrossAppNode, +): ImportedSourceIdentity => ({ + sourceApp: node.sourceApp, + sourceSpaceId: node.sourceSpaceId, + sourceNodeId: node.sourceNodeId, + sourceNodeRid: node.sourceNodeRid, + sourceTitle: node.content.direct.value, + sourceModifiedAt: node.sourceModifiedAt, +}); + +/** + * Extract the imported source identity from a page's normalized block props, or + * `undefined` when the page was not imported (or the metadata is malformed). + * Pure: pass the result of `getBlockProps(uid)`. + */ +export const parseImportedSourceIdentity = ( + props: Record, +): ImportedSourceIdentity | undefined => { + const dgData = props[DISCOURSE_GRAPH_PROP_NAME]; + if (!isJsonObject(dgData)) return undefined; + const imported = dgData[IMPORTED_FROM_PROP_KEY]; + if (!isJsonObject(imported)) return undefined; + const { + sourceApp, + sourceSpaceId, + sourceNodeId, + sourceNodeRid, + sourceTitle, + sourceModifiedAt, + } = imported; + if (!isSourceApp(sourceApp)) return undefined; + if ( + typeof sourceSpaceId !== "string" || + typeof sourceNodeId !== "string" || + typeof sourceNodeRid !== "string" || + typeof sourceTitle !== "string" || + typeof sourceModifiedAt !== "string" + ) + return undefined; + return { + sourceApp, + sourceSpaceId, + sourceNodeId, + sourceNodeRid, + sourceTitle, + sourceModifiedAt, + }; +}; + +/** Read the imported source identity stored on a Roam page, if any. */ +export const readImportedSourceIdentity = ( + pageUid: string, +): ImportedSourceIdentity | undefined => + parseImportedSourceIdentity(getBlockProps(pageUid)); + +/** + * Write (or overwrite) the imported source identity on a Roam page. Merges into + * the `discourse-graph` props namespace so sibling metadata is preserved, and + * lives in block props so it survives overwrites of the page's content. Used on + * first import and on refresh. + */ +export const writeImportedSourceIdentity = ( + pageUid: string, + identity: ImportedSourceIdentity, +): void => { + const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME]; + const dgData: Record = isJsonObject(existing) ? existing : {}; + dgData[IMPORTED_FROM_PROP_KEY] = { + sourceApp: identity.sourceApp, + sourceSpaceId: identity.sourceSpaceId, + sourceNodeId: identity.sourceNodeId, + sourceNodeRid: identity.sourceNodeRid, + sourceTitle: identity.sourceTitle, + sourceModifiedAt: identity.sourceModifiedAt, + }; + setBlockProps(pageUid, { [DISCOURSE_GRAPH_PROP_NAME]: dgData }); +}; + +/** + * Find the uid of an already-imported Roam page by its source RID, or `null`. + * `sourceNodeRid` is the canonical cross-app identity key, so it alone + * identifies an imported node. Warns if more than one page shares a RID. + */ +export const findImportedNodeUidByRid = async ( + sourceNodeRid: string, +): Promise => { + const query = `[:find ?uid + :in $ ?rid + :where + [?page :block/uid ?uid] + [?page :block/props ?props] + [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] + [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?imported] + [(get ?imported :sourceNodeRid) ?rid]]`; + const result = (await window.roamAlphaAPI.data.async.q( + query, + sourceNodeRid, + )) as [string][]; + if (result.length > 1) { + console.warn( + `findImportedNodeUidByRid: ${result.length} pages share sourceNodeRid '${sourceNodeRid}'`, + ); + } + return result.length > 0 ? result[0][0] : null; +}; From 5aaaf1f5d9f5e750f9167ad02bbcf79059e19c8f Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 29 Jun 2026 00:23:48 +0530 Subject: [PATCH 2/2] [ENG-1856] Remove doc comments from imported identity helpers Sibling block-props utils carry no JSDoc; reviewer orientation moves to PR review comments rather than living in the code. --- .../utils/importedSourceIdentity.example.ts | 9 ++---- apps/roam/src/utils/importedSourceIdentity.ts | 31 ------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/apps/roam/src/utils/importedSourceIdentity.example.ts b/apps/roam/src/utils/importedSourceIdentity.example.ts index e55456c57..eef246f1e 100644 --- a/apps/roam/src/utils/importedSourceIdentity.example.ts +++ b/apps/roam/src/utils/importedSourceIdentity.example.ts @@ -6,13 +6,8 @@ import { } from "./importedSourceIdentity"; /** - * Example of the source identity persisted on a Roam page when an Obsidian-origin - * shared node is imported. Derived from the cross-app node contract so the stored - * shape stays in lockstep with `CrossAppNode`. Not imported by runtime code; this - * file exists to type-check and document the stored metadata. - * - * The source node type is intentionally absent from the stored identity: node-type - * mapping is the materializer's concern (ENG-1858), not ENG-1856's. + * Example source identity persisted on a Roam page when an Obsidian-origin node is + * imported, type-checked against the cross-app node contract. Not used at runtime. */ const OBSIDIAN_SOURCE_SPACE_ID = "obsidian:9a8b7c6d5e4f3210"; diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index adc2521ba..109da2249 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -3,12 +3,6 @@ import getBlockProps, { type json } from "./getBlockProps"; import setBlockProps from "./setBlockProps"; import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; -/** - * Stable identity of a node imported into Roam from another app, persisted on the - * imported page so it can be re-found (duplicate prevention) and refreshed later. - * Field names mirror the app-neutral `CrossAppNode` contract; the page's display - * title is kept separate from this stored identity. - */ export type ImportedSourceIdentity = { sourceApp: CrossAppNode["sourceApp"]; sourceSpaceId: string; @@ -18,10 +12,6 @@ export type ImportedSourceIdentity = { sourceModifiedAt: string; }; -/** - * Sub-key under the shared `discourse-graph` block-props namespace holding the - * imported source identity, alongside any other discourse-graph props. - */ export const IMPORTED_FROM_PROP_KEY = "importedFrom"; const isJsonObject = (value: json): value is { [key: string]: json } => @@ -30,10 +20,6 @@ const isJsonObject = (value: json): value is { [key: string]: json } => const isSourceApp = (value: json): value is CrossAppNode["sourceApp"] => value === "roam" || value === "obsidian"; -/** - * Derive the stored identity from a cross-app node payload. The display title - * comes from the `direct` content variant, per the contract. - */ export const toImportedSourceIdentity = ( node: CrossAppNode, ): ImportedSourceIdentity => ({ @@ -45,11 +31,6 @@ export const toImportedSourceIdentity = ( sourceModifiedAt: node.sourceModifiedAt, }); -/** - * Extract the imported source identity from a page's normalized block props, or - * `undefined` when the page was not imported (or the metadata is malformed). - * Pure: pass the result of `getBlockProps(uid)`. - */ export const parseImportedSourceIdentity = ( props: Record, ): ImportedSourceIdentity | undefined => { @@ -84,18 +65,11 @@ export const parseImportedSourceIdentity = ( }; }; -/** Read the imported source identity stored on a Roam page, if any. */ export const readImportedSourceIdentity = ( pageUid: string, ): ImportedSourceIdentity | undefined => parseImportedSourceIdentity(getBlockProps(pageUid)); -/** - * Write (or overwrite) the imported source identity on a Roam page. Merges into - * the `discourse-graph` props namespace so sibling metadata is preserved, and - * lives in block props so it survives overwrites of the page's content. Used on - * first import and on refresh. - */ export const writeImportedSourceIdentity = ( pageUid: string, identity: ImportedSourceIdentity, @@ -113,11 +87,6 @@ export const writeImportedSourceIdentity = ( setBlockProps(pageUid, { [DISCOURSE_GRAPH_PROP_NAME]: dgData }); }; -/** - * Find the uid of an already-imported Roam page by its source RID, or `null`. - * `sourceNodeRid` is the canonical cross-app identity key, so it alone - * identifies an imported node. Warns if more than one page shares a RID. - */ export const findImportedNodeUidByRid = async ( sourceNodeRid: string, ): Promise => {