From 5579a4ef92034898720d9928c38a170105f086f3 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Sun, 23 Aug 2026 10:44:10 -0400 Subject: [PATCH 1/4] eng-2167 NodeSchemaToCrossApp is missing modifiedAt --- .../roamToCrossAppConverters.test.ts | 66 ++++++++++++++++++- .../src/utils/roamToCrossAppConverters.ts | 12 +++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts b/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts index 5596eb602..54dd59dbb 100644 --- a/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts +++ b/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts @@ -9,7 +9,11 @@ vi.mock("roamjs-components/queries/getPageViewType", () => ({ })); vi.mock("~/utils/pageToMarkdown", () => ({ toMarkdown: () => "" })); -import { nodeUidsWithTypeToCrossApp } from "~/utils/roamToCrossAppConverters"; +import { + nodeSchemaToCrossApp, + nodeUidsWithTypeToCrossApp, +} from "~/utils/roamToCrossAppConverters"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; const USER_ROW = { ":db/id": 5, ":user/uid": "user-1" }; @@ -60,3 +64,63 @@ describe("nodeUidsWithTypeToCrossApp timestamps", () => { expect(node.modifiedAt).toEqual(new Date(1000)); }); }); + +const nodeSchema = (): DiscourseNode => ({ + text: "Evidence", + type: "_EVD-node", + shortcut: "e", + format: "[[EVD]] - {content}", + specification: [], + backedBy: "user", + canvasSettings: {}, +}); + +// For the timestamp tests: what Roam holds about one node type page. +const convertSchemaPull = (pullResult: Record | null) => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + pull: () => pullResult, + }, + }; + return nodeSchemaToCrossApp(nodeSchema()); +}; + +const schemaPull = { + ":create/time": 1000, + ":create/user": { ":user/uid": "user-1" }, +}; + +describe("nodeSchemaToCrossApp timestamps", () => { + it("takes the block edit time, as written when the page props change", () => { + const schema = convertSchemaPull({ ...schemaPull, ":edit/time": 3000 }); + expect(schema?.createdAt).toEqual(new Date(1000)); + expect(schema?.modifiedAt).toEqual(new Date(3000)); + }); + + it("takes the page edit time, as written when a block below it changes", () => { + const schema = convertSchemaPull({ + ...schemaPull, + ":edit/time": 2000, + ":page/edit-time": 4000, + }); + expect(schema?.modifiedAt).toEqual(new Date(4000)); + }); + + it("keeps the later of the two", () => { + const schema = convertSchemaPull({ + ...schemaPull, + ":edit/time": 5000, + ":page/edit-time": 4000, + }); + expect(schema?.modifiedAt).toEqual(new Date(5000)); + }); + + it("falls back to the create time when neither exists", () => { + const schema = convertSchemaPull(schemaPull); + expect(schema?.modifiedAt).toEqual(new Date(1000)); + }); + + it("is null without an author, rather than a concept that cannot be inserted", () => { + expect(convertSchemaPull({ ":create/time": 1000 })).toBeNull(); + }); +}); diff --git a/apps/roam/src/utils/roamToCrossAppConverters.ts b/apps/roam/src/utils/roamToCrossAppConverters.ts index a3c33f399..cbce108cd 100644 --- a/apps/roam/src/utils/roamToCrossAppConverters.ts +++ b/apps/roam/src/utils/roamToCrossAppConverters.ts @@ -185,20 +185,28 @@ export const nodeSchemaToCrossApp = ( s: DiscourseNode, ): CrossAppNodeSchema | null => { const relData = window.roamAlphaAPI.pull( - "[:create/time :edit/time {:create/user [:user/uid]}]", + "[:create/time :edit/time :page/edit-time {:create/user [:user/uid]}]", `[:block/uid "${s.type}"]`, ) as unknown as { ":create/time": number; ":edit/time": number; + ":page/edit-time"?: number; ":create/user": { ":user/uid": string }; }; if (!relData) return null; const userUid = (relData[":create/user"] ?? {})[":user/uid"]; if (!userUid) return null; + const createdTime = relData[":create/time"] || Date.now(); + // A node type's settings live either in the page's props or in blocks below it, + // depending on the settings store in use, so neither time alone sees every edit: + // :edit/time moves when the props are written, :page/edit-time when a block is. + const editTime = relData[":edit/time"] ?? createdTime; + const pageEditTime = relData[":page/edit-time"] ?? editTime; return { localId: s.type, label: s.text, authorId: userUid, - createdAt: new Date(relData[":create/time"] || Date.now()), + createdAt: new Date(createdTime), + modifiedAt: new Date(Math.max(editTime, pageEditTime, createdTime)), }; }; From 3003cf531a3df6f82854f0d35116ec3a432cbaf4 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 21 Aug 2026 08:32:10 -0400 Subject: [PATCH 2/4] eng-2129-slots-and-slotDefinitions-to-ref_content --- packages/database/src/crossAppContracts.ts | 2 + .../__tests__/dbToCrossAppConverters.test.ts | 17 +++++---- .../database/src/lib/crossAppConverters.ts | 5 +++ .../src/lib/dbToCrossAppConverters.ts | 38 +++++++++++++++---- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/database/src/crossAppContracts.ts b/packages/database/src/crossAppContracts.ts index e60090690..fe97fd6c2 100644 --- a/packages/database/src/crossAppContracts.ts +++ b/packages/database/src/crossAppContracts.ts @@ -25,6 +25,7 @@ export type CrossAppNodeSchema = CrossAppSchemaBase & { label: string; template?: string; templateTitle?: string; + slotDefinitions?: Record; }; // A relation type schema @@ -75,6 +76,7 @@ type InlineCrossAppTypedContent = InlineCrossAppContent & { // A node instance export type CrossAppNode = CrossAppBase & { nodeType: LocalId; + slots?: Record; content: { direct: InlineCrossAppContent; full?: InlineCrossAppTypedContent; diff --git a/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts b/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts index 86ee73ba7..f52141a7c 100644 --- a/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts +++ b/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts @@ -51,13 +51,16 @@ describe("dbNodeSchemaToCrossApp", () => { extra: "kept", }, }); - expect(dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toEqual({ + expect( + dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap: {} }), + ).toEqual({ rid: "orn:obsidian.schema:vault-a/concept-1", localId: "concept-1", createdAt: new Date("2026-06-14T11:00:00Z"), modifiedAt: new Date("2026-06-14T13:00:00Z"), label: "Some concept", metadata: { extra: "kept" }, + slotDefinitions: {}, template: "template body", templateTitle: "Template Title", authorId: "account-local-1", @@ -66,16 +69,16 @@ describe("dbNodeSchemaToCrossApp", () => { it("throws when the author is unknown", () => { const schema = baseConcept({ author_id: 999 }); - expect(() => dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toThrow( - "Missing author", - ); + expect(() => + dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap: {} }), + ).toThrow("Missing author"); }); it("throws when the space is unknown", () => { const schema = baseConcept({ space_id: 999 }); - expect(() => dbNodeSchemaToCrossApp(schema, spaceMap, accountMap)).toThrow( - "Missing space", - ); + expect(() => + dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap: {} }), + ).toThrow("Missing space"); }); }); diff --git a/packages/database/src/lib/crossAppConverters.ts b/packages/database/src/lib/crossAppConverters.ts index 3199f299c..1ff856f7f 100644 --- a/packages/database/src/lib/crossAppConverters.ts +++ b/packages/database/src/lib/crossAppConverters.ts @@ -86,16 +86,20 @@ export const crossAppNodeToDbConcept = ( ]), created: node.createdAt?.toISOString(), last_modified: node.modifiedAt?.toISOString(), + local_reference_content: node.slots, }); }; export const crossAppNodeSchemaToDbConcept = ( node: CrossAppNodeSchema, ): LocalConceptDataInput => { + const slots = Object.keys(node.slotDefinitions ?? {}); const literalInfo = filterUndefined({ template: node.templateTitle, template_content: node.template, + roles: slots.length > 0 ? slots : undefined, }); + const referenceContent = slots.length ? node.slotDefinitions! : undefined; const spaceUri = node.rid ? ridToSpaceUriAndLocalId(node.rid).spaceUri : undefined; @@ -107,6 +111,7 @@ export const crossAppNodeSchemaToDbConcept = ( is_schema: true, literal_content: Object.keys(literalInfo).length > 0 ? literalInfo : undefined, + local_reference_content: referenceContent, created: node.createdAt?.toISOString(), last_modified: node.modifiedAt?.toISOString(), }); diff --git a/packages/database/src/lib/dbToCrossAppConverters.ts b/packages/database/src/lib/dbToCrossAppConverters.ts index 4fbfbede8..07839536f 100644 --- a/packages/database/src/lib/dbToCrossAppConverters.ts +++ b/packages/database/src/lib/dbToCrossAppConverters.ts @@ -15,6 +15,7 @@ const getConceptMap = async ( conceptIds: number[], spaceMap: Record, ): Promise> => { + if (conceptIds.length === 0) return {}; const request = await client .from("my_concepts") .select("id, space_id, source_local_id") @@ -79,12 +80,22 @@ const asSimpleLocalId = ( return rid; }; -export const dbNodeSchemaToCrossApp = ( - schema: Concept, - spaceMap: Record, - accountMap: Record, -): CrossAppNodeSchema => { - const { template, template_content, ...other } = +export const dbNodeSchemaToCrossApp = ({ + schema, + spaceMap, + accountMap, + schemaMap, +}: { + schema: Concept; + spaceMap: Record; + accountMap: Record; + schemaMap: Record; +}): CrossAppNodeSchema => { + const referenceContent = (schema.reference_content ?? {}) as Record< + string, + number + >; + const { template, template_content, roles, ...other } = schema.literal_content as Record; const authorId = accountMap[schema.author_id || 0]; if (authorId === undefined) throw new Error("Missing author"); @@ -95,6 +106,14 @@ export const dbNodeSchemaToCrossApp = ( schema.source_local_id!, "schema", ); + const slotDefinitions: Record = Object.fromEntries( + ((roles as string[] | undefined) ?? []) + .map((r) => [ + r, + asSimpleLocalId(schemaMap[referenceContent[r] ?? 0], spaceUrl), + ]) + .filter(([, s]) => s !== undefined) as [string, string][], + ); return { rid, localId: schema.source_local_id!, @@ -105,6 +124,7 @@ export const dbNodeSchemaToCrossApp = ( template: template_content as string | undefined, templateTitle: template as string | undefined, authorId, + slotDefinitions, }; }; @@ -126,7 +146,11 @@ export const dbNodeSchemasToCrossApp = async ({ ); accountMap = await getAccountMap(client, [...authorIds]); } - return schemas.map((r) => dbNodeSchemaToCrossApp(r, spaceMap, accountMap)); + const referredSchemaIds = schemas.flatMap((schema) => schema.refs); + const schemaMap = await getConceptMap(client, referredSchemaIds, spaceMap); + return schemas.map((schema) => + dbNodeSchemaToCrossApp({ schema, spaceMap, accountMap, schemaMap }), + ); }; export const dbRelationTypeSchemaToCrossApp = ( From cc484c422f30e760d9dc8cb70afa455cb3e28dff Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 21 Aug 2026 16:30:07 -0400 Subject: [PATCH 3/4] add slots to SharedNode --- .../src/lib/__tests__/sharedNodes.test.ts | 2 + packages/database/src/lib/sharedNodes.ts | 43 ++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts index 37b422299..af73fb196 100644 --- a/packages/database/src/lib/__tests__/sharedNodes.test.ts +++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts @@ -19,6 +19,8 @@ const nodes: BuildArgs["nodes"] = [ schema_id: 200, source_local_id: "node-1", space_id: 20, + reference_content: {}, + concepts_of_relation: [], }, ]; const directContents: BuildArgs["directContents"] = [ diff --git a/packages/database/src/lib/sharedNodes.ts b/packages/database/src/lib/sharedNodes.ts index 36dd023e7..c5c7c549a 100644 --- a/packages/database/src/lib/sharedNodes.ts +++ b/packages/database/src/lib/sharedNodes.ts @@ -4,8 +4,19 @@ import type { Enums, Json, Tables } from "../dbTypes"; type SharedConcept = Pick< Tables<"my_concepts">, - "is_schema" | "last_modified" | "schema_id" | "source_local_id" | "space_id" ->; + | "is_schema" + | "last_modified" + | "schema_id" + | "source_local_id" + | "space_id" + | "reference_content" +> & { + concepts_of_relation: { + id: number | null; + space_id: number | null; + source_local_id: string | null; + }[]; +}; type SharedContent = Pick< Tables<"my_contents">, | "author_id" @@ -45,6 +56,7 @@ export type SharedNode = { lastModified: string; authorId?: number; directMetadata: Json; + slots?: Record; }; export type SharedNodeRows = { @@ -54,8 +66,8 @@ export type SharedNodeRows = { spaces: SharedSpace[]; }; -const CONCEPT_COLUMNS = - "is_schema, last_modified, schema_id, source_local_id, space_id"; +const CONCEPT_COLUMNS_WITH_SLOTS = + "is_schema, last_modified, schema_id, source_local_id, space_id, reference_content, concepts_of_relation(id, space_id, source_local_id)"; const DIRECT_CONTENT_COLUMNS = "author_id, created, last_modified, metadata, source_local_id, space_id, text, variant"; const FULL_CONTENT_SUMMARY_COLUMNS = "last_modified, source_local_id, space_id"; @@ -184,6 +196,24 @@ export const buildSharedNodes = ({ return []; } + const nodeRidById = Object.fromEntries( + node.concepts_of_relation.map((c) => { + if (c.space_id === node.space_id) return [c.id, c.source_local_id]; + const space = spacesById.get(c.space_id || 0); + if (!space || !c.source_local_id || !c.id) return [c.id, undefined]; + return [c.id, spaceUriAndLocalIdToRid(space.url, c.source_local_id)]; + }) as [number, string | undefined][], + ); + const referenceContent = (node.reference_content ?? {}) as Record< + string, + number + >; + const slots = Object.fromEntries( + Object.entries(referenceContent ?? {}) + .map(([k, v]) => [k, nodeRidById[v]]) + .filter(([, v]) => v !== undefined) as [string, string][], + ); + return [ { rid, @@ -197,6 +227,7 @@ export const buildSharedNodes = ({ lastModified, authorId: direct.author_id ?? undefined, directMetadata: direct.metadata, + slots: Object.keys(slots).length > 0 ? slots : undefined, }, ]; }) @@ -218,7 +249,7 @@ const getSharedNodeRows = async ({ await Promise.all([ client .from("my_concepts") - .select(CONCEPT_COLUMNS) + .select(CONCEPT_COLUMNS_WITH_SLOTS) .neq("space_id", currentSpaceId) .eq("is_schema", false) .eq("is_relation", false), @@ -278,7 +309,7 @@ export const getSharedNodeByRid = async ({ const [conceptsResponse, directResponse, fullResponse] = await Promise.all([ client .from("my_concepts") - .select(CONCEPT_COLUMNS) + .select(CONCEPT_COLUMNS_WITH_SLOTS) .eq("space_id", space.id) .eq("source_local_id", sourceLocalId) .eq("is_schema", false) From 1f3c80ca3c1b0f27c3d1795783ccab76a3f8b38c Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Sat, 22 Aug 2026 12:39:39 -0400 Subject: [PATCH 4/4] tests --- .../__tests__/dbToCrossAppConverters.test.ts | 53 +++++++++++++++++++ .../src/lib/__tests__/sharedNodes.test.ts | 30 +++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts b/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts index f52141a7c..1251cb5e2 100644 --- a/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts +++ b/packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts @@ -67,6 +67,59 @@ describe("dbNodeSchemaToCrossApp", () => { }); }); + it("resolves slot definitions from roles and reference content", () => { + const schema = baseConcept({ + literal_content: { roles: ["evidence", "claim"], extra: "kept" }, + reference_content: { evidence: 10, claim: 20 }, + }); + const result = dbNodeSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + schemaMap: { + 10: "orn:obsidian.schema:vault-a/evidence-type", + 20: "orn:obsidian.schema:vault-a/claim-type", + }, + }); + // schemas are always local, so slots hold plain source local ids + expect(result.slotDefinitions).toEqual({ + evidence: "evidence-type", + claim: "claim-type", + }); + // roles drive the slot definitions, they are not kept as plain metadata + expect(result.metadata).toEqual({ extra: "kept" }); + }); + + it("throws when a slot points at a schema in another space", () => { + const schema = baseConcept({ + literal_content: { roles: ["evidence"] }, + reference_content: { evidence: 10 }, + }); + expect(() => + dbNodeSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + schemaMap: { 10: "orn:obsidian.schema:vault-b/evidence-type" }, + }), + ).toThrow("Unexpected spaceUri"); + }); + + it("omits slots whose referenced schema cannot be resolved", () => { + const schema = baseConcept({ + literal_content: { roles: ["evidence", "claim"] }, + reference_content: { evidence: 10 }, + }); + expect( + dbNodeSchemaToCrossApp({ + schema, + spaceMap, + accountMap, + schemaMap: { 10: "orn:obsidian.schema:vault-a/evidence-type" }, + }).slotDefinitions, + ).toEqual({ evidence: "evidence-type" }); + }); + it("throws when the author is unknown", () => { const schema = baseConcept({ author_id: 999 }); expect(() => diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts index af73fb196..2ef057cd8 100644 --- a/packages/database/src/lib/__tests__/sharedNodes.test.ts +++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts @@ -169,6 +169,36 @@ describe("buildSharedNodes", () => { expect(build({ nodesOverride, directOverride })).toEqual([]); }); + it("resolves slots to local ids in the same space and rids elsewhere", () => { + const otherSpace: BuildArgs["spaces"][number] = { + id: 21, + name: "Other vault", + platform: "Obsidian", + url: "obsidian:vault-b", + }; + const nodeWithSlots: BuildArgs["nodes"][number] = { + ...nodes[0]!, + reference_content: { evidence: 5, claim: 6, dangling: 7 }, + concepts_of_relation: [ + { id: 5, space_id: 20, source_local_id: "node-5" }, + { id: 6, space_id: 21, source_local_id: "node-6" }, + ], + }; + expect( + build({ + nodesOverride: [nodeWithSlots], + spacesOverride: [...spaces, otherSpace], + })[0]?.slots, + ).toEqual({ + evidence: "node-5", + claim: "orn:obsidian:vault-b/node-6", + }); + }); + + it("leaves slots undefined when the node references nothing", () => { + expect(build()[0]?.slots).toBeUndefined(); + }); + it("sorts newest nodes first", () => { const olderNode = { ...nodes[0]!,