Skip to content

Commit 7e5540d

Browse files
committed
[ENG-1847] Define shared cross-app node content contract
1 parent 78427b8 commit 7e5540d

5 files changed

Lines changed: 306 additions & 37 deletions

File tree

apps/obsidian/src/utils/rid.ts

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,6 @@
1-
// Functions to express a pair of spaceUri, sourceLocalId as a single string, and back.
2-
// We're following https://github.com/BlockScience/rid-lib:
3-
// Either a Web URL, with the last segment as the sourceLocalId;
4-
// OR the format `orn:<platform>.<subtype>:<source identifier>/<sourceLocalId>`
5-
// With the assumption that the sourceUri has the form <platform>:<source identifier>
6-
// The subtype may be omitted.
7-
8-
export const spaceUriAndLocalIdToRid = (
9-
spaceUri: string,
10-
localId: string,
11-
subtype?: string,
12-
): string => {
13-
if (spaceUri.startsWith("http")) return `${spaceUri}/${localId}`;
14-
const parts = spaceUri.split(":");
15-
if (parts.length === 2)
16-
return subtype
17-
? `orn:${parts[0]}.${subtype}:${parts[1]}/${localId}`
18-
: `orn:${parts[0]}:${parts[1]}/${localId}`;
19-
throw new Error("Unrecognized spaceUri");
20-
};
21-
22-
export const ridToSpaceUriAndLocalId = (
23-
rid: string,
24-
): { spaceUri: string; sourceLocalId: string } => {
25-
const m = rid.match(/^orn:(\w+)\.(\w+):(.*)\/([^/]+)$/);
26-
if (m) {
27-
return { spaceUri: `${m[1]}:${m[3]}`, sourceLocalId: m[4]! };
28-
}
29-
const m2 = rid.match(/^orn:(\w+):(.*)\/([^/]+)$/);
30-
if (m2) {
31-
return { spaceUri: `${m2[1]}:${m2[2]}`, sourceLocalId: m2[3]! };
32-
}
33-
const parts = rid.split("/");
34-
const sourceLocalId = parts.pop()!;
35-
return { spaceUri: parts.join("/"), sourceLocalId };
36-
};
1+
// The RID helpers now live in the shared database package so Roam and Obsidian
2+
// share one cross-app identity format. See @repo/database/lib/rid.
3+
export {
4+
spaceUriAndLocalIdToRid,
5+
ridToSpaceUriAndLocalId,
6+
} from "@repo/database/lib/rid";

packages/database/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"default": "./src/dbDotEnv.mjs"
1212
},
1313
"./dbTypes": "./src/dbTypes.ts",
14-
"./inputTypes": "./src/inputTypes.ts"
14+
"./inputTypes": "./src/inputTypes.ts",
15+
"./crossAppNodeContract": "./src/crossAppNodeContract.ts",
16+
"./fixtures/*": "./src/fixtures/*.ts"
1517
},
1618
"typesVersions": {
1719
"*": {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { Enums } from "./dbTypes";
2+
3+
/**
4+
* Shared cross-app discourse-node content contract (MVP0).
5+
*
6+
* This is the payload that lets Roam and Obsidian discover, import and refresh
7+
* each other's discourse nodes. It is a typed *view* over data that already
8+
* persists through `@repo/database/inputTypes` (`LocalConceptDataInput` /
9+
* `LocalContentDataInput`) and the `upsert_concepts` / `upsert_content` RPCs —
10+
* it does NOT introduce a new persistence path. Build/parse the `rid` with the
11+
* helpers in `@repo/database/lib/rid`. The full spec — field-by-field mapping to
12+
* the Concept/Content tables and markdown fidelity limits — lives on Linear
13+
* issue ENG-1847.
14+
*/
15+
16+
/** Source app a shared node originates from. Mirrors the DB `Platform` enum. */
17+
export type Platform = Enums<"Platform">; // "Roam" | "Obsidian"
18+
19+
/** Persisted content scales. Mirrors the DB `ContentVariant` enum. */
20+
export type ContentVariant = Enums<"ContentVariant">;
21+
22+
/**
23+
* The Content variants every shared node must persist:
24+
* - `direct`: the import-list title.
25+
* - `full`: a self-sufficient markdown body the destination can materialize
26+
* without querying the source app.
27+
*/
28+
export const SHARED_NODE_CONTENT_VARIANTS = [
29+
"direct",
30+
"full",
31+
] as const satisfies readonly ContentVariant[];
32+
33+
/**
34+
* MIME type of the `full` variant in MVP0. Markdown is the v0 content model;
35+
* atJSON is the planned v1 successor (F16). Keep this as the single place that
36+
* names the format so v1 does not have to hunt down hardcoded strings.
37+
*/
38+
export const FULL_CONTENT_FORMAT = "text/markdown";
39+
40+
/** Identity of the node-type schema the destination maps to / creates from. */
41+
export type CrossAppNodeType = {
42+
/**
43+
* `source_local_id` of the node-type *schema* Concept in the source space
44+
* (the Concept with `is_schema = true`). Maps to
45+
* `LocalConceptDataInput.schema_represented_by_local_id` on the instance.
46+
*/
47+
sourceLocalId: string;
48+
/** Human-readable node-type label, e.g. "Claim". */
49+
label: string;
50+
};
51+
52+
/** The required content variants of a shared node. */
53+
export type CrossAppNodeContent = {
54+
/** Import-list title. Persisted as the `direct` Content variant (`text`). */
55+
direct: { value: string };
56+
/**
57+
* Self-sufficient markdown body. Persisted as the `full` Content variant
58+
* (`text`); `format` is the contract-level media type for that text in MVP0.
59+
*/
60+
full: { format: typeof FULL_CONTENT_FORMAT; value: string };
61+
};
62+
63+
/**
64+
* Stable cross-app identity (F9). The triple
65+
* (`sourceApp`, `sourceSpace.url`, `sourceLocalId`) is equivalent to `rid`;
66+
* build/parse `rid` with `spaceUriAndLocalIdToRid` / `ridToSpaceUriAndLocalId`
67+
* from `@repo/database/lib/rid`. Duplicate-prevention and refresh must key on
68+
* this identity, never on the display title.
69+
*/
70+
export type CrossAppNodeIdentity = {
71+
sourceApp: Platform;
72+
/**
73+
* Source space: `Space.url` (portable cross-app id) and `Space.name`
74+
* (display). Do not use numeric `Space.id` as the payload identity; it is
75+
* local to the receiving database.
76+
*/
77+
sourceSpace: { url: string; name: string };
78+
/** The node's `source_local_id` within its source space. */
79+
sourceLocalId: string;
80+
/** Stable cross-app id derived from (`sourceSpace.url`, `sourceLocalId`). */
81+
rid: string;
82+
};
83+
84+
/** The shared cross-app discourse-node payload (discovery + import facing). */
85+
export type CrossAppNode = CrossAppNodeIdentity & {
86+
nodeType: CrossAppNodeType;
87+
content: CrossAppNodeContent;
88+
/**
89+
* ISO-8601 source last-modified time. Use the source node modified timestamp,
90+
* or the latest `Content.last_modified` across the required `direct` and
91+
* `full` variants when deriving from persisted rows. Basis for freshness
92+
* (F13), refresh, and duplicate-prevention.
93+
*/
94+
sourceModifiedAt: string;
95+
};
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type {
2+
LocalConceptDataInput,
3+
LocalContentDataInput,
4+
} from "../inputTypes";
5+
import {
6+
FULL_CONTENT_FORMAT,
7+
type CrossAppNode,
8+
} from "../crossAppNodeContract";
9+
import { spaceUriAndLocalIdToRid } from "../lib/rid";
10+
11+
/**
12+
* Reference fixtures for the cross-app node content contract (ENG-1847).
13+
*
14+
* Each fixture pairs the contract-level `CrossAppNode` with the existing
15+
* `LocalConceptDataInput` + `LocalContentDataInput[]` it persists as — showing
16+
* downstream Roam/Obsidian tickets exactly how the contract maps onto
17+
* `upsert_concepts` / `upsert_content` without redefining the payload. The
18+
* fixtures use the `space_url` / `author_local_id` string keys so they stay
19+
* portable; the live source apps pass their resolved numeric `space_id` /
20+
* `author_id` from `SupabaseContext` instead.
21+
*/
22+
export type CrossAppNodeFixture = {
23+
node: CrossAppNode;
24+
concept: LocalConceptDataInput;
25+
contents: LocalContentDataInput[];
26+
};
27+
28+
// --- Roam-origin node: a Claim shared from a Roam graph ---------------------
29+
30+
const ROAM_SPACE_URL = "https://roamresearch.com/#/app/MAPLab";
31+
const ROAM_NODE_ID = "tgWb6JozF"; // a Roam block/page uid
32+
const ROAM_CLAIM_SCHEMA_ID = "rCLM0schema"; // source_local_id of the Claim schema Concept
33+
const ROAM_NODE_RID = spaceUriAndLocalIdToRid(ROAM_SPACE_URL, ROAM_NODE_ID);
34+
35+
const roamFullMarkdown = `# Sleep improves memory consolidation
36+
37+
Multiple studies show that sleep after learning strengthens memory traces.
38+
39+
- Supported by [[EVD]] - Rasch & Born 2013
40+
`;
41+
42+
export const roamOriginNode: CrossAppNodeFixture = {
43+
node: {
44+
sourceApp: "Roam",
45+
sourceSpace: { url: ROAM_SPACE_URL, name: "MAPLab" },
46+
sourceLocalId: ROAM_NODE_ID,
47+
rid: ROAM_NODE_RID,
48+
nodeType: { sourceLocalId: ROAM_CLAIM_SCHEMA_ID, label: "Claim" },
49+
content: {
50+
direct: { value: "Sleep improves memory consolidation" },
51+
full: { format: FULL_CONTENT_FORMAT, value: roamFullMarkdown },
52+
},
53+
sourceModifiedAt: "2026-06-12T14:00:00.000Z",
54+
},
55+
concept: {
56+
space_url: ROAM_SPACE_URL,
57+
name: "Sleep improves memory consolidation",
58+
source_local_id: ROAM_NODE_ID,
59+
schema_represented_by_local_id: ROAM_CLAIM_SCHEMA_ID,
60+
is_schema: false,
61+
author_local_id: "roam-account-uid",
62+
created: "2026-06-10T09:00:00.000Z",
63+
last_modified: "2026-06-12T14:00:00.000Z",
64+
},
65+
contents: [
66+
{
67+
space_url: ROAM_SPACE_URL,
68+
source_local_id: ROAM_NODE_ID,
69+
variant: "direct",
70+
scale: "document",
71+
text: "Sleep improves memory consolidation",
72+
author_local_id: "roam-account-uid",
73+
created: "2026-06-10T09:00:00.000Z",
74+
last_modified: "2026-06-12T14:00:00.000Z",
75+
},
76+
{
77+
space_url: ROAM_SPACE_URL,
78+
source_local_id: ROAM_NODE_ID,
79+
variant: "full",
80+
scale: "document",
81+
text: roamFullMarkdown,
82+
author_local_id: "roam-account-uid",
83+
created: "2026-06-10T09:00:00.000Z",
84+
last_modified: "2026-06-12T14:00:00.000Z",
85+
},
86+
],
87+
};
88+
89+
// --- Obsidian-origin node: an Evidence note shared from an Obsidian vault ----
90+
91+
const OBSIDIAN_VAULT_ID = "9a8b7c6d5e4f3210"; // app.appId
92+
const OBSIDIAN_SPACE_URL = `obsidian:${OBSIDIAN_VAULT_ID}`;
93+
const OBSIDIAN_NODE_ID = "0192f1a0-7b3c-7e2a-9f10-1a2b3c4d5e6f"; // uuidv7 nodeInstanceId
94+
const OBSIDIAN_EVD_SCHEMA_ID = "evd-7c1f9a2b"; // nodeTypeId
95+
const OBSIDIAN_FILE_PATH = "Discourse Nodes/EVD - REM sleep and recall.md";
96+
const OBSIDIAN_TITLE = "EVD - REM sleep and recall"; // file basename
97+
const OBSIDIAN_NODE_RID = spaceUriAndLocalIdToRid(
98+
OBSIDIAN_SPACE_URL,
99+
OBSIDIAN_NODE_ID,
100+
"note",
101+
);
102+
103+
// Obsidian's `full` variant is the entire file as read from the vault, which
104+
// includes the YAML frontmatter — a known markdown-fidelity wrinkle the
105+
// destination materialization (ENG-1858 / ENG-1872) must handle.
106+
const obsidianFullMarkdown = `---
107+
nodeTypeId: ${OBSIDIAN_EVD_SCHEMA_ID}
108+
nodeInstanceId: ${OBSIDIAN_NODE_ID}
109+
---
110+
111+
# REM sleep correlates with recall
112+
113+
Participants with more REM sleep showed better next-day recall.
114+
`;
115+
116+
export const obsidianOriginNode: CrossAppNodeFixture = {
117+
node: {
118+
sourceApp: "Obsidian",
119+
sourceSpace: { url: OBSIDIAN_SPACE_URL, name: "Research Vault" },
120+
sourceLocalId: OBSIDIAN_NODE_ID,
121+
rid: OBSIDIAN_NODE_RID,
122+
nodeType: { sourceLocalId: OBSIDIAN_EVD_SCHEMA_ID, label: "Evidence" },
123+
content: {
124+
direct: { value: OBSIDIAN_TITLE },
125+
full: { format: FULL_CONTENT_FORMAT, value: obsidianFullMarkdown },
126+
},
127+
sourceModifiedAt: "2026-06-14T10:30:00.000Z",
128+
},
129+
concept: {
130+
space_url: OBSIDIAN_SPACE_URL,
131+
name: OBSIDIAN_FILE_PATH, // Obsidian uses the file path as the Concept name
132+
source_local_id: OBSIDIAN_NODE_ID,
133+
schema_represented_by_local_id: OBSIDIAN_EVD_SCHEMA_ID,
134+
is_schema: false,
135+
author_local_id: "obsidian-account-uid",
136+
created: "2026-06-13T08:00:00.000Z",
137+
last_modified: "2026-06-14T10:30:00.000Z",
138+
literal_content: { label: OBSIDIAN_TITLE },
139+
},
140+
contents: [
141+
{
142+
space_url: OBSIDIAN_SPACE_URL,
143+
source_local_id: OBSIDIAN_NODE_ID,
144+
variant: "direct",
145+
scale: "document",
146+
text: OBSIDIAN_TITLE,
147+
author_local_id: "obsidian-account-uid",
148+
created: "2026-06-13T08:00:00.000Z",
149+
last_modified: "2026-06-14T10:30:00.000Z",
150+
metadata: { filePath: OBSIDIAN_FILE_PATH },
151+
},
152+
{
153+
space_url: OBSIDIAN_SPACE_URL,
154+
source_local_id: OBSIDIAN_NODE_ID,
155+
variant: "full",
156+
scale: "document",
157+
text: obsidianFullMarkdown,
158+
author_local_id: "obsidian-account-uid",
159+
created: "2026-06-13T08:00:00.000Z",
160+
last_modified: "2026-06-14T10:30:00.000Z",
161+
metadata: { filePath: OBSIDIAN_FILE_PATH },
162+
},
163+
],
164+
};

packages/database/src/lib/rid.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Express a pair of (spaceUri, sourceLocalId) as a single stable cross-app id
2+
// (RID), and parse it back. Shared by Roam and Obsidian so both apps use one
3+
// identity format for cross-app share / discovery / import / refresh.
4+
// We follow https://github.com/BlockScience/rid-lib:
5+
// Either a Web URL, with the last segment as the sourceLocalId;
6+
// OR the format `orn:<platform>.<subtype>:<source identifier>/<sourceLocalId>`
7+
// With the assumption that the sourceUri has the form <platform>:<source identifier>
8+
// The subtype may be omitted.
9+
10+
export const spaceUriAndLocalIdToRid = (
11+
spaceUri: string,
12+
localId: string,
13+
subtype?: string,
14+
): string => {
15+
if (spaceUri.startsWith("http")) return `${spaceUri}/${localId}`;
16+
const parts = spaceUri.split(":");
17+
if (parts.length === 2)
18+
return subtype
19+
? `orn:${parts[0]}.${subtype}:${parts[1]}/${localId}`
20+
: `orn:${parts[0]}:${parts[1]}/${localId}`;
21+
throw new Error("Unrecognized spaceUri");
22+
};
23+
24+
export const ridToSpaceUriAndLocalId = (
25+
rid: string,
26+
): { spaceUri: string; sourceLocalId: string } => {
27+
const m = rid.match(/^orn:(\w+)\.(\w+):(.*)\/([^/]+)$/);
28+
if (m) {
29+
return { spaceUri: `${m[1]}:${m[3]}`, sourceLocalId: m[4]! };
30+
}
31+
const m2 = rid.match(/^orn:(\w+):(.*)\/([^/]+)$/);
32+
if (m2) {
33+
return { spaceUri: `${m2[1]}:${m2[2]}`, sourceLocalId: m2[3]! };
34+
}
35+
const parts = rid.split("/");
36+
const sourceLocalId = parts.pop()!;
37+
return { spaceUri: parts.join("/"), sourceLocalId };
38+
};

0 commit comments

Comments
 (0)