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
48 changes: 48 additions & 0 deletions apps/roam/src/utils/__tests__/conceptConversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import type { SupabaseContext } from "~/utils/supabaseContext";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";
import { discourseNodeSchemaToLocalConcept } from "~/utils/conceptConversion";

const context = { spaceId: 42 } as SupabaseContext;

const claimSchema: DiscourseNode = {
type: "schema-1",
text: "Claim",
shortcut: "C",
specification: [],
backedBy: "user",
canvasSettings: {},
format: "[[CLM]] - {content}",
};

const stubRoamQuery = () => {
(globalThis as { window: unknown }).window = {
roamAlphaAPI: {
q: vi.fn().mockReturnValue([["author-1", "page-1", 1000, 2000]]),
},
};
};

describe("discourseNodeSchemaToLocalConcept", () => {
it("writes label and format into literal_content", () => {
stubRoamQuery();
const concept = discourseNodeSchemaToLocalConcept(context, claimSchema);
expect(concept.literal_content).toEqual({
label: "Claim",
format: "[[CLM]] - {content}",
});
});

it("keeps label and format when the type has a template", () => {
stubRoamQuery();
const concept = discourseNodeSchemaToLocalConcept(context, {
...claimSchema,
template: [{ text: "Evidence" }],
});
expect(concept.literal_content).toEqual({
label: "Claim",
format: "[[CLM]] - {content}",
template: "* Evidence\n",
});
});
});
2 changes: 2 additions & 0 deletions apps/roam/src/utils/__tests__/publishNodesToGroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ vi.mock("~/utils/roamToCrossAppConverters", () => ({
label: s.text,
authorId: "author-1",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
format: s.format,
}),
reifiedRelationToCrossApp: vi.fn(),
relationTripleSchemaToCrossApp: vi.fn(),
Expand Down Expand Up @@ -172,6 +173,7 @@ describe("publishNodesToGroups", () => {
source_local_id: SCHEMA_UID,
is_schema: true,
name: "Claim",
literal_content: { format: "[[CLM]] - {content}" },
});
expect(data[1]).toMatchObject({
source_local_id: "node-1",
Expand Down
38 changes: 37 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,35 @@ describe("nodeUidsWithTypeToCrossApp timestamps", () => {
expect(node.modifiedAt).toEqual(new Date(1000));
});
});

describe("nodeSchemaToCrossApp", () => {
const claimSchema: DiscourseNode = {
type: "schema-1",
text: "Claim",
shortcut: "C",
specification: [],
backedBy: "user",
canvasSettings: {},
format: "[[CLM]] - {content}",
};

it("carries the node type format", () => {
(globalThis as { window: unknown }).window = {
roamAlphaAPI: {
pull: vi.fn().mockReturnValue({
":create/time": 1000,
":edit/time": 2000,
":create/user": { ":user/uid": "user-1" },
}),
},
};
const schema = nodeSchemaToCrossApp(claimSchema);
expect(schema).toEqual({
localId: "schema-1",
label: "Claim",
authorId: "user-1",
createdAt: new Date(1000),
format: "[[CLM]] - {content}",
});
});
});
15 changes: 7 additions & 8 deletions apps/roam/src/utils/conceptConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,21 @@ export const discourseNodeSchemaToLocalConcept = (
): LocalConceptDataInput => {
const titleParts = node.text.split("/");
const label = titleParts[titleParts.length - 1] ?? node.text;
const literalContent: { [key: string]: Json } = {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

literal_content is now built once, then the template branch adds its key. The old shape replaced the whole object in that branch, so every new key had to be written in two places. No behavior change for label and template.

label,
format: node.format,
};
if (node.template !== undefined)
literalContent.template = templateToText(node.template);
const result: LocalConceptDataInput = {
space_id: context.spaceId,
name: node.text,
source_local_id: node.type,
is_schema: true,
literal_content: {
label,
},
literal_content: literalContent,
/* eslint-enable @typescript-eslint/naming-convention */
...getNodeExtraData(node.type),
};
if (node.template !== undefined)
result.literal_content = {
label,
template: templateToText(node.template),
};
return result;
};

Expand Down
1 change: 1 addition & 0 deletions apps/roam/src/utils/roamToCrossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,5 +200,6 @@ export const nodeSchemaToCrossApp = (
label: s.text,
authorId: userUid,
createdAt: new Date(relData[":create/time"] || Date.now()),
format: s.format,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

DiscourseNode.format is marked deprecated in favor of specification, but that deprecation is about node identification. format is still the title pattern, and the title pattern is what ENG-2156 and ENG-2157 read from the schema row.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-upsert already-synced schemas when publishing format

When a node type was synchronized before this deployment, publishNodesToGroups finds its UID in my_concepts and only maps missingNodeSchemas into the upsert request, so this newly added field never reaches the existing schema row. The periodic path also selects only node types changed since the last sync (or associated with a refreshed shared node), meaning an unchanged schema can remain formatless indefinitely even after its nodes are published; recipients then import it using Obsidian's generated fallback format rather than the Roam format. Publishing should update the referenced schema while preserving its other literal_content keys, not skip it solely because it already exists.

Useful? React with 👍 / 👎.

};
};
1 change: 1 addition & 0 deletions packages/database/doc/concept_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Residual (not otherwise accounted for) keys in Obsidian frontmatter are mapped t
| `label` | `name` | {} |
| `template` | `literal_content->template_content` | |
| `templateTitle` | `literal_content->template` | |
| `format` | `literal_content->format` | |
| - | `is_schema` | true |
| - | `schema_id` | null |
| - | `arity` | 0 |
Expand Down
1 change: 1 addition & 0 deletions packages/database/src/crossAppContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type CrossAppNodeSchema = CrossAppSchemaBase & {
label: string;
template?: string;
templateTitle?: string;
format?: string;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Flat literal_content.format, per the decision on the ticket (MG, team chat 2026-08-19). Obsidian's schema parser already reads flat format as a fallback, so the Obsidian producer stays unchanged. A separate key also avoids template, which the sync producer already uses for the block template.

};

// A relation type schema
Expand Down
41 changes: 41 additions & 0 deletions packages/database/src/lib/__tests__/crossAppConverters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { crossAppNodeSchemaToDbConcept } from "../crossAppConverters";
import type { CrossAppNodeSchema } from "../../crossAppContracts";

const baseSchema: CrossAppNodeSchema = {
localId: "schema-1",
label: "Claim",
authorId: "author-1",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};

describe("crossAppNodeSchemaToDbConcept", () => {
it("maps format to literal_content.format", () => {
const concept = crossAppNodeSchemaToDbConcept({
...baseSchema,
format: "[[CLM]] - {content}",
});
expect(concept.literal_content).toEqual({
format: "[[CLM]] - {content}",
});
});

it("keeps the template keys alongside format", () => {
const concept = crossAppNodeSchemaToDbConcept({
...baseSchema,
format: "[[CLM]] - {content}",
template: "* Evidence\n",
templateTitle: "Claim template",
});
expect(concept.literal_content).toEqual({
format: "[[CLM]] - {content}",
template: "Claim template",
template_content: "* Evidence\n",
});
});

it("omits literal_content when no keys are set", () => {
const concept = crossAppNodeSchemaToDbConcept(baseSchema);
expect(concept.literal_content).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions packages/database/src/lib/crossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const crossAppNodeSchemaToDbConcept = (
const literalInfo = filterUndefined({
template: node.templateTitle,
template_content: node.template,
format: node.format,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The publish path writes only format here, no label or template. That asymmetry with the sync producer is pre-existing: publishNodesToGroups only upserts schemas that are not yet in my_concepts, and the next periodic sync rewrites the row with the full key set. Unifying the two shapes is out of scope for this ticket.

});
return filterUndefined<LocalConceptDataInput>({
source_local_id: node.localId,
Expand Down