From 7e5540dae0313cddbe41197ac95832ffd990c368 Mon Sep 17 00:00:00 2001 From: sid597 Date: Thu, 18 Jun 2026 12:57:54 +0530 Subject: [PATCH 1/3] [ENG-1847] Define shared cross-app node content contract --- apps/obsidian/src/utils/rid.ts | 42 +---- packages/database/package.json | 4 +- packages/database/src/crossAppNodeContract.ts | 95 ++++++++++ .../database/src/fixtures/crossAppNodes.ts | 164 ++++++++++++++++++ packages/database/src/lib/rid.ts | 38 ++++ 5 files changed, 306 insertions(+), 37 deletions(-) create mode 100644 packages/database/src/crossAppNodeContract.ts create mode 100644 packages/database/src/fixtures/crossAppNodes.ts create mode 100644 packages/database/src/lib/rid.ts diff --git a/apps/obsidian/src/utils/rid.ts b/apps/obsidian/src/utils/rid.ts index c96db9e3d..08ef6aed2 100644 --- a/apps/obsidian/src/utils/rid.ts +++ b/apps/obsidian/src/utils/rid.ts @@ -1,36 +1,6 @@ -// Functions to express a pair of spaceUri, sourceLocalId as a single string, and back. -// We're following https://github.com/BlockScience/rid-lib: -// Either a Web URL, with the last segment as the sourceLocalId; -// OR the format `orn:.:/` -// With the assumption that the sourceUri has the form : -// The subtype may be omitted. - -export const spaceUriAndLocalIdToRid = ( - spaceUri: string, - localId: string, - subtype?: string, -): string => { - if (spaceUri.startsWith("http")) return `${spaceUri}/${localId}`; - const parts = spaceUri.split(":"); - if (parts.length === 2) - return subtype - ? `orn:${parts[0]}.${subtype}:${parts[1]}/${localId}` - : `orn:${parts[0]}:${parts[1]}/${localId}`; - throw new Error("Unrecognized spaceUri"); -}; - -export const ridToSpaceUriAndLocalId = ( - rid: string, -): { spaceUri: string; sourceLocalId: string } => { - const m = rid.match(/^orn:(\w+)\.(\w+):(.*)\/([^/]+)$/); - if (m) { - return { spaceUri: `${m[1]}:${m[3]}`, sourceLocalId: m[4]! }; - } - const m2 = rid.match(/^orn:(\w+):(.*)\/([^/]+)$/); - if (m2) { - return { spaceUri: `${m2[1]}:${m2[2]}`, sourceLocalId: m2[3]! }; - } - const parts = rid.split("/"); - const sourceLocalId = parts.pop()!; - return { spaceUri: parts.join("/"), sourceLocalId }; -}; +// The RID helpers now live in the shared database package so Roam and Obsidian +// share one cross-app identity format. See @repo/database/lib/rid. +export { + spaceUriAndLocalIdToRid, + ridToSpaceUriAndLocalId, +} from "@repo/database/lib/rid"; diff --git a/packages/database/package.json b/packages/database/package.json index b2c0de431..9a814f52f 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -11,7 +11,9 @@ "default": "./src/dbDotEnv.mjs" }, "./dbTypes": "./src/dbTypes.ts", - "./inputTypes": "./src/inputTypes.ts" + "./inputTypes": "./src/inputTypes.ts", + "./crossAppNodeContract": "./src/crossAppNodeContract.ts", + "./fixtures/*": "./src/fixtures/*.ts" }, "typesVersions": { "*": { diff --git a/packages/database/src/crossAppNodeContract.ts b/packages/database/src/crossAppNodeContract.ts new file mode 100644 index 000000000..7f6582b37 --- /dev/null +++ b/packages/database/src/crossAppNodeContract.ts @@ -0,0 +1,95 @@ +import type { Enums } from "./dbTypes"; + +/** + * Shared cross-app discourse-node content contract (MVP0). + * + * This is the payload that lets Roam and Obsidian discover, import and refresh + * each other's discourse nodes. It is a typed *view* over data that already + * persists through `@repo/database/inputTypes` (`LocalConceptDataInput` / + * `LocalContentDataInput`) and the `upsert_concepts` / `upsert_content` RPCs — + * it does NOT introduce a new persistence path. Build/parse the `rid` with the + * helpers in `@repo/database/lib/rid`. The full spec — field-by-field mapping to + * the Concept/Content tables and markdown fidelity limits — lives on Linear + * issue ENG-1847. + */ + +/** Source app a shared node originates from. Mirrors the DB `Platform` enum. */ +export type Platform = Enums<"Platform">; // "Roam" | "Obsidian" + +/** Persisted content scales. Mirrors the DB `ContentVariant` enum. */ +export type ContentVariant = Enums<"ContentVariant">; + +/** + * The Content variants every shared node must persist: + * - `direct`: the import-list title. + * - `full`: a self-sufficient markdown body the destination can materialize + * without querying the source app. + */ +export const SHARED_NODE_CONTENT_VARIANTS = [ + "direct", + "full", +] as const satisfies readonly ContentVariant[]; + +/** + * MIME type of the `full` variant in MVP0. Markdown is the v0 content model; + * atJSON is the planned v1 successor (F16). Keep this as the single place that + * names the format so v1 does not have to hunt down hardcoded strings. + */ +export const FULL_CONTENT_FORMAT = "text/markdown"; + +/** Identity of the node-type schema the destination maps to / creates from. */ +export type CrossAppNodeType = { + /** + * `source_local_id` of the node-type *schema* Concept in the source space + * (the Concept with `is_schema = true`). Maps to + * `LocalConceptDataInput.schema_represented_by_local_id` on the instance. + */ + sourceLocalId: string; + /** Human-readable node-type label, e.g. "Claim". */ + label: string; +}; + +/** The required content variants of a shared node. */ +export type CrossAppNodeContent = { + /** Import-list title. Persisted as the `direct` Content variant (`text`). */ + direct: { value: string }; + /** + * Self-sufficient markdown body. Persisted as the `full` Content variant + * (`text`); `format` is the contract-level media type for that text in MVP0. + */ + full: { format: typeof FULL_CONTENT_FORMAT; value: string }; +}; + +/** + * Stable cross-app identity (F9). The triple + * (`sourceApp`, `sourceSpace.url`, `sourceLocalId`) is equivalent to `rid`; + * build/parse `rid` with `spaceUriAndLocalIdToRid` / `ridToSpaceUriAndLocalId` + * from `@repo/database/lib/rid`. Duplicate-prevention and refresh must key on + * this identity, never on the display title. + */ +export type CrossAppNodeIdentity = { + sourceApp: Platform; + /** + * Source space: `Space.url` (portable cross-app id) and `Space.name` + * (display). Do not use numeric `Space.id` as the payload identity; it is + * local to the receiving database. + */ + sourceSpace: { url: string; name: string }; + /** The node's `source_local_id` within its source space. */ + sourceLocalId: string; + /** Stable cross-app id derived from (`sourceSpace.url`, `sourceLocalId`). */ + rid: string; +}; + +/** The shared cross-app discourse-node payload (discovery + import facing). */ +export type CrossAppNode = CrossAppNodeIdentity & { + nodeType: CrossAppNodeType; + content: CrossAppNodeContent; + /** + * ISO-8601 source last-modified time. Use the source node modified timestamp, + * or the latest `Content.last_modified` across the required `direct` and + * `full` variants when deriving from persisted rows. Basis for freshness + * (F13), refresh, and duplicate-prevention. + */ + sourceModifiedAt: string; +}; diff --git a/packages/database/src/fixtures/crossAppNodes.ts b/packages/database/src/fixtures/crossAppNodes.ts new file mode 100644 index 000000000..b98ecbc13 --- /dev/null +++ b/packages/database/src/fixtures/crossAppNodes.ts @@ -0,0 +1,164 @@ +import type { + LocalConceptDataInput, + LocalContentDataInput, +} from "../inputTypes"; +import { + FULL_CONTENT_FORMAT, + type CrossAppNode, +} from "../crossAppNodeContract"; +import { spaceUriAndLocalIdToRid } from "../lib/rid"; + +/** + * Reference fixtures for the cross-app node content contract (ENG-1847). + * + * Each fixture pairs the contract-level `CrossAppNode` with the existing + * `LocalConceptDataInput` + `LocalContentDataInput[]` it persists as — showing + * downstream Roam/Obsidian tickets exactly how the contract maps onto + * `upsert_concepts` / `upsert_content` without redefining the payload. The + * fixtures use the `space_url` / `author_local_id` string keys so they stay + * portable; the live source apps pass their resolved numeric `space_id` / + * `author_id` from `SupabaseContext` instead. + */ +export type CrossAppNodeFixture = { + node: CrossAppNode; + concept: LocalConceptDataInput; + contents: LocalContentDataInput[]; +}; + +// --- Roam-origin node: a Claim shared from a Roam graph --------------------- + +const ROAM_SPACE_URL = "https://roamresearch.com/#/app/MAPLab"; +const ROAM_NODE_ID = "tgWb6JozF"; // a Roam block/page uid +const ROAM_CLAIM_SCHEMA_ID = "rCLM0schema"; // source_local_id of the Claim schema Concept +const ROAM_NODE_RID = spaceUriAndLocalIdToRid(ROAM_SPACE_URL, ROAM_NODE_ID); + +const roamFullMarkdown = `# Sleep improves memory consolidation + +Multiple studies show that sleep after learning strengthens memory traces. + +- Supported by [[EVD]] - Rasch & Born 2013 +`; + +export const roamOriginNode: CrossAppNodeFixture = { + node: { + sourceApp: "Roam", + sourceSpace: { url: ROAM_SPACE_URL, name: "MAPLab" }, + sourceLocalId: ROAM_NODE_ID, + rid: ROAM_NODE_RID, + nodeType: { sourceLocalId: ROAM_CLAIM_SCHEMA_ID, label: "Claim" }, + content: { + direct: { value: "Sleep improves memory consolidation" }, + full: { format: FULL_CONTENT_FORMAT, value: roamFullMarkdown }, + }, + sourceModifiedAt: "2026-06-12T14:00:00.000Z", + }, + concept: { + space_url: ROAM_SPACE_URL, + name: "Sleep improves memory consolidation", + source_local_id: ROAM_NODE_ID, + schema_represented_by_local_id: ROAM_CLAIM_SCHEMA_ID, + is_schema: false, + author_local_id: "roam-account-uid", + created: "2026-06-10T09:00:00.000Z", + last_modified: "2026-06-12T14:00:00.000Z", + }, + contents: [ + { + space_url: ROAM_SPACE_URL, + source_local_id: ROAM_NODE_ID, + variant: "direct", + scale: "document", + text: "Sleep improves memory consolidation", + author_local_id: "roam-account-uid", + created: "2026-06-10T09:00:00.000Z", + last_modified: "2026-06-12T14:00:00.000Z", + }, + { + space_url: ROAM_SPACE_URL, + source_local_id: ROAM_NODE_ID, + variant: "full", + scale: "document", + text: roamFullMarkdown, + author_local_id: "roam-account-uid", + created: "2026-06-10T09:00:00.000Z", + last_modified: "2026-06-12T14:00:00.000Z", + }, + ], +}; + +// --- Obsidian-origin node: an Evidence note shared from an Obsidian vault ---- + +const OBSIDIAN_VAULT_ID = "9a8b7c6d5e4f3210"; // app.appId +const OBSIDIAN_SPACE_URL = `obsidian:${OBSIDIAN_VAULT_ID}`; +const OBSIDIAN_NODE_ID = "0192f1a0-7b3c-7e2a-9f10-1a2b3c4d5e6f"; // uuidv7 nodeInstanceId +const OBSIDIAN_EVD_SCHEMA_ID = "evd-7c1f9a2b"; // nodeTypeId +const OBSIDIAN_FILE_PATH = "Discourse Nodes/EVD - REM sleep and recall.md"; +const OBSIDIAN_TITLE = "EVD - REM sleep and recall"; // file basename +const OBSIDIAN_NODE_RID = spaceUriAndLocalIdToRid( + OBSIDIAN_SPACE_URL, + OBSIDIAN_NODE_ID, + "note", +); + +// Obsidian's `full` variant is the entire file as read from the vault, which +// includes the YAML frontmatter — a known markdown-fidelity wrinkle the +// destination materialization (ENG-1858 / ENG-1872) must handle. +const obsidianFullMarkdown = `--- +nodeTypeId: ${OBSIDIAN_EVD_SCHEMA_ID} +nodeInstanceId: ${OBSIDIAN_NODE_ID} +--- + +# REM sleep correlates with recall + +Participants with more REM sleep showed better next-day recall. +`; + +export const obsidianOriginNode: CrossAppNodeFixture = { + node: { + sourceApp: "Obsidian", + sourceSpace: { url: OBSIDIAN_SPACE_URL, name: "Research Vault" }, + sourceLocalId: OBSIDIAN_NODE_ID, + rid: OBSIDIAN_NODE_RID, + nodeType: { sourceLocalId: OBSIDIAN_EVD_SCHEMA_ID, label: "Evidence" }, + content: { + direct: { value: OBSIDIAN_TITLE }, + full: { format: FULL_CONTENT_FORMAT, value: obsidianFullMarkdown }, + }, + sourceModifiedAt: "2026-06-14T10:30:00.000Z", + }, + concept: { + space_url: OBSIDIAN_SPACE_URL, + name: OBSIDIAN_FILE_PATH, // Obsidian uses the file path as the Concept name + source_local_id: OBSIDIAN_NODE_ID, + schema_represented_by_local_id: OBSIDIAN_EVD_SCHEMA_ID, + is_schema: false, + author_local_id: "obsidian-account-uid", + created: "2026-06-13T08:00:00.000Z", + last_modified: "2026-06-14T10:30:00.000Z", + literal_content: { label: OBSIDIAN_TITLE }, + }, + contents: [ + { + space_url: OBSIDIAN_SPACE_URL, + source_local_id: OBSIDIAN_NODE_ID, + variant: "direct", + scale: "document", + text: OBSIDIAN_TITLE, + author_local_id: "obsidian-account-uid", + created: "2026-06-13T08:00:00.000Z", + last_modified: "2026-06-14T10:30:00.000Z", + metadata: { filePath: OBSIDIAN_FILE_PATH }, + }, + { + space_url: OBSIDIAN_SPACE_URL, + source_local_id: OBSIDIAN_NODE_ID, + variant: "full", + scale: "document", + text: obsidianFullMarkdown, + author_local_id: "obsidian-account-uid", + created: "2026-06-13T08:00:00.000Z", + last_modified: "2026-06-14T10:30:00.000Z", + metadata: { filePath: OBSIDIAN_FILE_PATH }, + }, + ], +}; diff --git a/packages/database/src/lib/rid.ts b/packages/database/src/lib/rid.ts new file mode 100644 index 000000000..768ff6d8a --- /dev/null +++ b/packages/database/src/lib/rid.ts @@ -0,0 +1,38 @@ +// Express a pair of (spaceUri, sourceLocalId) as a single stable cross-app id +// (RID), and parse it back. Shared by Roam and Obsidian so both apps use one +// identity format for cross-app share / discovery / import / refresh. +// We follow https://github.com/BlockScience/rid-lib: +// Either a Web URL, with the last segment as the sourceLocalId; +// OR the format `orn:.:/` +// With the assumption that the sourceUri has the form : +// The subtype may be omitted. + +export const spaceUriAndLocalIdToRid = ( + spaceUri: string, + localId: string, + subtype?: string, +): string => { + if (spaceUri.startsWith("http")) return `${spaceUri}/${localId}`; + const parts = spaceUri.split(":"); + if (parts.length === 2) + return subtype + ? `orn:${parts[0]}.${subtype}:${parts[1]}/${localId}` + : `orn:${parts[0]}:${parts[1]}/${localId}`; + throw new Error("Unrecognized spaceUri"); +}; + +export const ridToSpaceUriAndLocalId = ( + rid: string, +): { spaceUri: string; sourceLocalId: string } => { + const m = rid.match(/^orn:(\w+)\.(\w+):(.*)\/([^/]+)$/); + if (m) { + return { spaceUri: `${m[1]}:${m[3]}`, sourceLocalId: m[4]! }; + } + const m2 = rid.match(/^orn:(\w+):(.*)\/([^/]+)$/); + if (m2) { + return { spaceUri: `${m2[1]}:${m2[2]}`, sourceLocalId: m2[3]! }; + } + const parts = rid.split("/"); + const sourceLocalId = parts.pop()!; + return { spaceUri: parts.join("/"), sourceLocalId }; +}; From 186cbec375a28e870fa682a47b85b5ecee66d76f Mon Sep 17 00:00:00 2001 From: sid597 Date: Fri, 19 Jun 2026 10:42:45 +0530 Subject: [PATCH 2/3] [ENG-1849] Add node-type schema dependency to cross-app node fixtures The cross-app node contract (ENG-1847) carries nodeType = { sourceLocalId, label } and the instance Concept references it via schema_represented_by_local_id, but the fixtures persisted no is_schema:true schema Concept for that id. Add the required schemaConcept (Roam Claim, Obsidian Evidence) carrying stable source identity + label only -- no source_data/format/color/tag, per the contract's "without redefining schema shape". Roam's existing 5-min sync already persists the schema Concept per contract (convertDgToSupabaseConcepts -> discourseNodeSchemaToLocalConcept; all types on initial sync, edited types incrementally via nodeTypeSince), so no Roam sync code change is needed for F4/F9/F13. Stacked on eng-1847 (PR #1129, unmerged). --- .../database/src/fixtures/crossAppNodes.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/database/src/fixtures/crossAppNodes.ts b/packages/database/src/fixtures/crossAppNodes.ts index b98ecbc13..a08f4faa6 100644 --- a/packages/database/src/fixtures/crossAppNodes.ts +++ b/packages/database/src/fixtures/crossAppNodes.ts @@ -12,15 +12,17 @@ import { spaceUriAndLocalIdToRid } from "../lib/rid"; * Reference fixtures for the cross-app node content contract (ENG-1847). * * Each fixture pairs the contract-level `CrossAppNode` with the existing - * `LocalConceptDataInput` + `LocalContentDataInput[]` it persists as — showing - * downstream Roam/Obsidian tickets exactly how the contract maps onto - * `upsert_concepts` / `upsert_content` without redefining the payload. The - * fixtures use the `space_url` / `author_local_id` string keys so they stay - * portable; the live source apps pass their resolved numeric `space_id` / - * `author_id` from `SupabaseContext` instead. + * persistence rows it maps onto — the node-type `schemaConcept` it depends on, + * the instance `concept`, and its `LocalContentDataInput[]` — showing downstream + * Roam/Obsidian tickets exactly how the contract maps onto `upsert_concepts` / + * `upsert_content` without redefining the payload. The fixtures use the + * `space_url` / `author_local_id` string keys so they stay portable; the live + * source apps pass their resolved numeric `space_id` / `author_id` from + * `SupabaseContext` instead. */ export type CrossAppNodeFixture = { node: CrossAppNode; + schemaConcept: LocalConceptDataInput; concept: LocalConceptDataInput; contents: LocalContentDataInput[]; }; @@ -52,6 +54,16 @@ export const roamOriginNode: CrossAppNodeFixture = { }, sourceModifiedAt: "2026-06-12T14:00:00.000Z", }, + schemaConcept: { + space_url: ROAM_SPACE_URL, + name: "Claim", + source_local_id: ROAM_CLAIM_SCHEMA_ID, + is_schema: true, + author_local_id: "roam-account-uid", + created: "2026-06-01T09:00:00.000Z", + last_modified: "2026-06-01T09:00:00.000Z", + literal_content: { label: "Claim" }, + }, concept: { space_url: ROAM_SPACE_URL, name: "Sleep improves memory consolidation", @@ -126,6 +138,16 @@ export const obsidianOriginNode: CrossAppNodeFixture = { }, sourceModifiedAt: "2026-06-14T10:30:00.000Z", }, + schemaConcept: { + space_url: OBSIDIAN_SPACE_URL, + name: "Evidence", + source_local_id: OBSIDIAN_EVD_SCHEMA_ID, + is_schema: true, + author_local_id: "obsidian-account-uid", + created: "2026-06-01T08:00:00.000Z", + last_modified: "2026-06-01T08:00:00.000Z", + literal_content: { label: "Evidence" }, + }, concept: { space_url: OBSIDIAN_SPACE_URL, name: OBSIDIAN_FILE_PATH, // Obsidian uses the file path as the Concept name From 87a0551e9ce14e17f883f9e9e0a42b20c0d3ca9e Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 22 Jun 2026 02:36:23 +0530 Subject: [PATCH 3/3] [ENG-1849] Persist Roam schema labels --- apps/roam/src/utils/conceptConversion.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/utils/conceptConversion.ts b/apps/roam/src/utils/conceptConversion.ts index 0851df84a..83abd1a14 100644 --- a/apps/roam/src/utils/conceptConversion.ts +++ b/apps/roam/src/utils/conceptConversion.ts @@ -78,17 +78,21 @@ export const discourseNodeSchemaToLocalConcept = ( node: DiscourseNode, ): LocalConceptDataInput => { const titleParts = node.text.split("/"); + const label = titleParts[titleParts.length - 1] ?? node.text; const result: LocalConceptDataInput = { space_id: context.spaceId, name: node.text, source_local_id: node.type, is_schema: true, + literal_content: { + label, + }, /* eslint-enable @typescript-eslint/naming-convention */ ...getNodeExtraData(node.type), }; if (node.template !== undefined) result.literal_content = { - label: titleParts[titleParts.length - 1], + label, template: templateToText(node.template), }; return result;