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
6 changes: 6 additions & 0 deletions apps/roam/src/utils/__tests__/conceptConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ describe("discourseNodeSchemaToLocalConcept", () => {
template: "* Evidence\n",
});
});

it("carries the type author as author_local_id", () => {
stubRoamQuery();
const concept = discourseNodeSchemaToLocalConcept(context, claimSchema);
expect(concept.author_local_id).toBe("author-1");
});
});
69 changes: 69 additions & 0 deletions apps/roam/src/utils/__tests__/schemaFormatBackfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";
import { buildSchemaFormatBackfill } from "~/utils/schemaFormatBackfill";

const nodeType = (type: string): DiscourseNode => ({
type,
text: "Claim",
shortcut: "C",
specification: [],
backedBy: "user",
canvasSettings: {},
format: "[[CLM]] - {content}",
});

describe("buildSchemaFormatBackfill", () => {
it("targets rows without a format that match a local node type", () => {
const result = buildSchemaFormatBackfill({
conceptRows: [
{ source_local_id: "schema-1", format: null },
{ source_local_id: "schema-2", format: "[[QUE]] - {content}" },
],
nodeTypes: [nodeType("schema-1"), nodeType("schema-2")],
});
expect(result.nodeTypeIdsToBackfill).toEqual(new Set(["schema-1"]));
expect(result.withFormatCount).toBe(1);
expect(result.orphanedCount).toBe(0);
});

it("treats an empty format as already set", () => {
const result = buildSchemaFormatBackfill({
conceptRows: [{ source_local_id: "schema-1", format: "" }],
nodeTypes: [nodeType("schema-1")],
});
expect(result.nodeTypeIdsToBackfill.size).toBe(0);
expect(result.withFormatCount).toBe(1);
});

it("reports rows with no matching local node type as orphaned", () => {
const result = buildSchemaFormatBackfill({
conceptRows: [
{ source_local_id: "gone-schema", format: null },
{ source_local_id: "schema-1", format: null },
],
nodeTypes: [nodeType("schema-1")],
});
expect(result.nodeTypeIdsToBackfill).toEqual(new Set(["schema-1"]));
expect(result.orphanedCount).toBe(1);
});

it("ignores rows without a source_local_id", () => {
const result = buildSchemaFormatBackfill({
conceptRows: [{ source_local_id: null, format: null }],
nodeTypes: [nodeType("schema-1")],
});
expect(result.nodeTypeIdsToBackfill.size).toBe(0);
expect(result.withFormatCount).toBe(0);
expect(result.orphanedCount).toBe(0);
});

it("returns zero counts for an empty probe", () => {
const result = buildSchemaFormatBackfill({
conceptRows: [],
nodeTypes: [nodeType("schema-1")],
});
expect(result.nodeTypeIdsToBackfill.size).toBe(0);
expect(result.withFormatCount).toBe(0);
expect(result.orphanedCount).toBe(0);
});
});
6 changes: 3 additions & 3 deletions apps/roam/src/utils/conceptConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU
const getNodeExtraData = (
node_uid: string,
): {
author_uid: string;
author_local_id: string;

@sid597 sid597 Aug 24, 2026

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.

Required by the backfill, not opportunistic. concept_local_input has no author_uid field, so jsonb_populate_record dropped it and every sync-produced concept carried a NULL author_id. upsert_concepts sets author_id unconditionally on conflict, so re-upserting publish-origin schema rows (which do have an author) would blank them, and dbNodeSchemaToCrossApp then throws "Missing author". Identical to a493612 on eng-2155; whichever merges second sees a no-op conflict.

page_uid below is the same dead-key class but inert: nothing maps to it and nothing breaks because of it. Removing it belongs with the literal_content shape unification.

created: string;
last_modified: string;
page_uid: string;
Expand Down Expand Up @@ -50,7 +50,7 @@ const getNodeExtraData = (
const created = new Date(created_t).toISOString();
const last_modified = new Date(last_modified_t).toISOString();
return {
author_uid,
author_local_id: author_uid,
created,
last_modified,
page_uid,
Expand Down Expand Up @@ -196,7 +196,7 @@ export const discourseRelationDataToLocalConcept = (
const created = new Date(
Math.max(...nodeData.map((nd) => new Date(nd.created).getTime())),
).toISOString();
const author_local_id: string = nodeData[0].author_uid; // take any one; again until I get the relation object
const author_local_id: string = nodeData[0].author_local_id; // take any one; again until I get the relation object
return {
space_id: context.spaceId,
source_local_id: relationUid,
Expand Down
41 changes: 41 additions & 0 deletions apps/roam/src/utils/schemaFormatBackfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { difference, intersection } from "@repo/utils/setOperations";
import { type DiscourseNode } from "./getDiscourseNodes";

export const SCHEMA_FORMAT_PROBE_SELECT =
"source_local_id, format:literal_content->>format";

@sid597 sid597 Aug 24, 2026

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.

PostgREST reads an absent jsonb key and a JSON null the same way: both come back as null through ->>. The Roam producers never write a null format, so both mean "backfill", and we partition client-side instead of filtering with .is(...). supabase-js types the projection string; the declared row type widens it to string | null, which is what actually arrives.

This mirrors eng-2155's core_title helper but stays in apps/roam: Obsidian schema rows carry source_data.format from the start, so only Roam needs it. Obsidian's template_content probe pushes the null test into the query instead; here we need the "already set" count, so we fetch all own-space schema rows (tens) and split.


type SchemaFormatProbeRow = {
source_local_id: string | null;
format: string | null;
};

export type SchemaFormatBackfill = {
nodeTypeIdsToBackfill: Set<string>;
withFormatCount: number;
orphanedCount: number;
};

export const buildSchemaFormatBackfill = ({
conceptRows,
nodeTypes,
}: {
conceptRows: SchemaFormatProbeRow[];
nodeTypes: DiscourseNode[];
}): SchemaFormatBackfill => {
const missingFormatIds = new Set<string>();
let withFormatCount = 0;
for (const row of conceptRows) {
if (row.source_local_id === null) continue;
if (row.format === null) {
missingFormatIds.add(row.source_local_id);
} else {
withFormatCount += 1;
}
}
const localTypeIds = new Set(nodeTypes.map((nodeType) => nodeType.type));
return {
nodeTypeIdsToBackfill: intersection(missingFormatIds, localTypeIds),
withFormatCount,
orphanedCount: difference(missingFormatIds, localTypeIds).size,

@sid597 sid597 Aug 24, 2026

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.

Orphans are probed rows that no longer match a configured node type. We report the count and never guess a format. They can persist: cleanupOrphanedNodes deletes rows whose Roam block is gone, but a type removed from the DG config keeps its page and its row. The core_title backfill accepted the same permanence for renamed pages.

An empty format counts as already set. The producer wrote the key (format defaults to ""), so there is nothing to recompute and the backfill converges.

};
};
89 changes: 88 additions & 1 deletion apps/roam/src/utils/syncDgNodesToSupabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
} from "./convertRoamNodeToFullContent";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import { intersection } from "@repo/utils/setOperations";
import {
buildSchemaFormatBackfill,
SCHEMA_FORMAT_PROBE_SELECT,
type SchemaFormatBackfill,
} from "./schemaFormatBackfill";
import type { Json, Enums } from "@repo/database/dbTypes";
import { render as renderToast } from "roamjs-components/components/Toast";
import internalError from "~/utils/internalError";
Expand Down Expand Up @@ -634,13 +639,15 @@ export const convertDgToSupabaseConcepts = async ({
since,
allNodeTypes,
sharedNodeTypeIds = new Set<string>(),
backfillNodeTypeIds = new Set<string>(),
supabaseClient,
context,
}: {
nodesSince: RoamDiscourseNodeData[];
since: number | undefined;
allNodeTypes: DiscourseNode[];
sharedNodeTypeIds?: ReadonlySet<string>;
backfillNodeTypeIds?: ReadonlySet<string>;
supabaseClient: DGSupabaseClient;
context: SupabaseContext;
}) => {
Expand All @@ -650,7 +657,10 @@ export const convertDgToSupabaseConcepts = async ({
);

allNodeTypes.forEach((nodeType) => {
if (sharedNodeTypeIds.has(nodeType.type)) {
if (
sharedNodeTypeIds.has(nodeType.type) ||
backfillNodeTypeIds.has(nodeType.type)

@sid597 sid597 Aug 24, 2026

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.

A second force-include beside sharedNodeTypeIds instead of smuggling the backfill through it; they mean different things. The set is an intersection with ids already in my_concepts, so the backfill can only rewrite existing rows. It cannot publish a schema the user never synced, including in shared-content-only mode.

) {
nodeTypesByUid.set(nodeType.type, nodeType);
}
});
Expand Down Expand Up @@ -1069,6 +1079,62 @@ const getSharedRoamNodesWithFullContentUpdatesSince = async ({
});
};

const probeSchemaFormatBackfill = async ({

@sid597 sid597 Aug 24, 2026

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.

Runs only on isInitialSync, so the second cycle probes nothing and the report goes silent. Paged through getAllPages like the other my_concepts reads in this file; the expected scale is node types per space, so one page in practice. A probe failure fails the cycle like any other phase; initialSync stays true, so the next attempt probes again.

supabaseClient,
spaceId,
nodeTypes,
}: {
supabaseClient: DGSupabaseClient;
spaceId: number;
nodeTypes: DiscourseNode[];
}): Promise<SchemaFormatBackfill> => {
const probeRows = await getAllPages(
supabaseClient
.from("my_concepts")
.select(SCHEMA_FORMAT_PROBE_SELECT)
.eq("space_id", spaceId)
.eq("is_schema", true)
.eq("is_relation", false)
.order("id"),
1000,
);
if (!Array.isArray(probeRows)) throw probeRows;
return buildSchemaFormatBackfill({
conceptRows: probeRows,
nodeTypes,
});
};

const reportSchemaFormatBackfill = ({

@sid597 sid597 Aug 24, 2026

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.

Deliberately independent of showToast, same as the core_title report on #1332: it announces a one-time data migration, not cycle status. skipped keeps the property name from the core_title posthog event; the toast calls the same number "already had one".

backfilled,
skipped,
orphaned,
}: {
backfilled: number;
skipped: number;
orphaned: number;
}): void => {
posthog.capture("Sync schema format backfill", {
backfilled,
skipped,
orphaned,
});
if (backfilled === 0 && orphaned === 0) return;
const messages = [
`Backfilled format for ${backfilled} node type${backfilled === 1 ? "" : "s"}.`,
`${skipped} already had one.`,
];
if (orphaned > 0) {
messages.push(`${orphaned} no longer match a node type in this graph.`);
}
Comment on lines +1122 to +1129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Toast reports "0 node types" backfilled

When no formatless rows match a local node type but some are orphaned, the guard still shows the toast, and its message is hardcoded to lead with Backfilled format for 0 node type(s). before the orphan warning. Users see a confusing zero-count line.

Prompt for agents
In reportSchemaFormatBackfill in apps/roam/src/utils/syncDgNodesToSupabase.ts, the early-return guard only suppresses the toast when both backfilled and orphaned are 0. When backfilled is 0 but orphaned > 0, the message array still unconditionally includes the line 'Backfilled format for 0 node types.' which is misleading. Consider only including the 'Backfilled format for N node types.' line when backfilled > 0, and only including the 'N already had one.' line when relevant, so the toast reads sensibly in the orphaned-only case.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Known shape, kept deliberately: it mirrors reportCoreTitleBackfill on #1332, which reads the same way in the orphaned-only case. The PR body tracks a joint fix for the two reports (recurring orphan toast, message composition) so they don't fork.

renderToast({
id: "schema-format-backfill",
intent: orphaned > 0 ? "warning" : "success",
content: messages.join(" "),
timeout: 5000,
});
};

export const createOrUpdateDiscourseEmbedding = async (
showToast = false,
): Promise<void> => {
Expand Down Expand Up @@ -1202,6 +1268,19 @@ export const createOrUpdateDiscourseEmbedding = async (
(n) => n.backedBy === "user",
);

const schemaFormatBackfill = isInitialSync
? await measureSyncPhase({
phase: "probeSchemaFormatBackfill",
phases,
operation: () =>
probeSchemaFormatBackfill({
supabaseClient: activeSupabaseClient,
spaceId: activeContext.spaceId,
nodeTypes: allDgNodeTypes,
}),
})
: null;

const changedNodeInstances = await measureSyncPhase({
phase: isInitialSync
? "getAllMissingOrNewDiscourseNodes"
Expand Down Expand Up @@ -1313,10 +1392,18 @@ export const createOrUpdateDiscourseEmbedding = async (
since: sinceTime,
allNodeTypes: allDgNodeTypes,
sharedNodeTypeIds,
backfillNodeTypeIds: schemaFormatBackfill?.nodeTypeIdsToBackfill,
supabaseClient: activeSupabaseClient,
context: activeContext,
}),
});
if (schemaFormatBackfill !== null) {
reportSchemaFormatBackfill({
backfilled: schemaFormatBackfill.nodeTypeIdsToBackfill.size,
skipped: schemaFormatBackfill.withFormatCount,
orphaned: schemaFormatBackfill.orphanedCount,
});
}
await measureSyncPhase({
phase: "cleanupOrphanedNodes",
phases,
Expand Down