Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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<string, unknown> | 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();
});
});
12 changes: 10 additions & 2 deletions apps/roam/src/utils/roamToCrossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
};
2 changes: 2 additions & 0 deletions packages/database/src/crossAppContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type CrossAppNodeSchema = CrossAppSchemaBase & {
label: string;
template?: string;
templateTitle?: string;
slotDefinitions?: Record<string, LocalId | undefined>;
};

// A relation type schema
Expand Down Expand Up @@ -75,6 +76,7 @@ type InlineCrossAppTypedContent = InlineCrossAppContent & {
// A node instance
export type CrossAppNode = CrossAppBase & {
nodeType: LocalId;
slots?: Record<string, LocalId>;
content: {
direct: InlineCrossAppContent;
full?: InlineCrossAppTypedContent;
Expand Down
70 changes: 63 additions & 7 deletions packages/database/src/lib/__tests__/dbToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,31 +51,87 @@ 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",
});
});

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(() => 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");
});
});

Expand Down
32 changes: 32 additions & 0 deletions packages/database/src/lib/__tests__/sharedNodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = [
Expand Down Expand Up @@ -167,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]!,
Expand Down
5 changes: 5 additions & 0 deletions packages/database/src/lib/crossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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.

Avoid ! whenever possible. If not possible, document why it is absolutely required.

Looks like it isn't required here so let's not leave ourselves a footgun.

const spaceUri = node.rid
? ridToSpaceUriAndLocalId(node.rid).spaceUri
: undefined;
Expand All @@ -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(),
});
Expand Down
38 changes: 31 additions & 7 deletions packages/database/src/lib/dbToCrossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const getConceptMap = async (
conceptIds: number[],
spaceMap: Record<number, string>,
): Promise<Record<number, string>> => {
if (conceptIds.length === 0) return {};
const request = await client
.from("my_concepts")
.select("id, space_id, source_local_id")
Expand Down Expand Up @@ -79,12 +80,22 @@ const asSimpleLocalId = (
return rid;
};

export const dbNodeSchemaToCrossApp = (
schema: Concept,
spaceMap: Record<number, string>,
accountMap: Record<number, string>,
): CrossAppNodeSchema => {
const { template, template_content, ...other } =
export const dbNodeSchemaToCrossApp = ({
schema,
spaceMap,
accountMap,
schemaMap,
}: {
schema: Concept;
spaceMap: Record<number, string>;
accountMap: Record<number, string>;
schemaMap: Record<number, string>;
}): CrossAppNodeSchema => {
const referenceContent = (schema.reference_content ?? {}) as Record<
string,
number
>;
const { template, template_content, roles, ...other } =
schema.literal_content as Record<string, Json>;
const authorId = accountMap[schema.author_id || 0];
if (authorId === undefined) throw new Error("Missing author");
Expand All @@ -95,6 +106,14 @@ export const dbNodeSchemaToCrossApp = (
schema.source_local_id!,
"schema",
);
const slotDefinitions: Record<string, string> = Object.fromEntries(
((roles as string[] | undefined) ?? [])
.map((r) => [
r,
asSimpleLocalId(schemaMap[referenceContent[r] ?? 0], spaceUrl),
])
.filter(([, s]) => s !== undefined) as [string, string][],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unconstrained roles in slot definitions

When a schema declares a role without a reference_content target, this filter removes the role entirely. That is a valid representation because CrossAppNodeSchema.slotDefinitions explicitly permits undefined values, and crossAppNodeSchemaToDbConcept reconstructs literal_content.roles from Object.keys(slotDefinitions); consequently, a database-to-CrossApp-to-database round trip silently erases unconstrained roles and changes the schema's arity. Retain each declared role with an undefined value when no target can be resolved.

Useful? React with 👍 / 👎.

);
return {
rid,
localId: schema.source_local_id!,
Expand All @@ -105,6 +124,7 @@ export const dbNodeSchemaToCrossApp = (
template: template_content as string | undefined,
templateTitle: template as string | undefined,
authorId,
slotDefinitions,
};
};

Expand All @@ -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 = (
Expand Down
Loading