-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-2175 Backfill format on existing node type schema rows #1346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); |
| 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"; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 This mirrors eng-2155's core_title helper but stays in apps/roam: Obsidian schema rows carry |
||
|
|
||
| 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, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: An empty format counts as already set. The producer wrote the key ( |
||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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; | ||
| }) => { | ||
|
|
@@ -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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A second force-include beside |
||
| ) { | ||
| nodeTypesByUid.set(nodeType.type, nodeType); | ||
| } | ||
| }); | ||
|
|
@@ -1069,6 +1079,62 @@ const getSharedRoamNodesWithFullContentUpdatesSince = async ({ | |
| }); | ||
| }; | ||
|
|
||
| const probeSchemaFormatBackfill = async ({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runs only on |
||
| 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 = ({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deliberately independent of |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Known shape, kept deliberately: it mirrors |
||
| renderToast({ | ||
| id: "schema-format-backfill", | ||
| intent: orphaned > 0 ? "warning" : "success", | ||
| content: messages.join(" "), | ||
| timeout: 5000, | ||
| }); | ||
| }; | ||
|
|
||
| export const createOrUpdateDiscourseEmbedding = async ( | ||
| showToast = false, | ||
| ): Promise<void> => { | ||
|
|
@@ -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" | ||
|
|
@@ -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, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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_inputhas noauthor_uidfield, sojsonb_populate_recorddropped it and every sync-produced concept carried a NULLauthor_id.upsert_conceptssetsauthor_idunconditionally on conflict, so re-upserting publish-origin schema rows (which do have an author) would blank them, anddbNodeSchemaToCrossAppthen throws "Missing author". Identical to a493612 on eng-2155; whichever merges second sees a no-op conflict.page_uidbelow 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.