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/obsidian/src/utils/conceptConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { DiscourseNodeInVault } from "./getDiscourseNodes";
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase";
import type { Json } from "@repo/database/dbTypes";
import { extractContentFromTitle } from "./extractContentFromTitle";

/**
* Get extra data (author, timestamps) from file metadata
Expand Down Expand Up @@ -160,12 +161,17 @@ export const discourseRelationTripleSchemaToLocalConcept = ({
export const discourseNodeInstanceToLocalConcept = (
context: SupabaseContext,
nodeData: ObsidianDiscourseNodeData,
nodeTypesById: Record<string, DiscourseNode>,
): LocalConceptDataInput => {
const extraData = getNodeExtraData(nodeData.file, context.userId);
const { nodeInstanceId, nodeTypeId, importedFromRid, ...otherData } =
nodeData.frontmatter;
const literal_content: Record<string, Json> = {
label: nodeData.file.basename,
core_title: extractContentFromTitle(
nodeTypesById[nodeData.nodeTypeId]?.format ?? "",
nodeData.file.basename,
),
Comment on lines +171 to +174

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 Extract the {content} capture instead of the first placeholder

When an Obsidian node type uses an allowed format with another placeholder before {content} (for example, {Source} - {content}), getDiscourseNodeFormatExpression creates one capture per placeholder but extractContentFromTitle always returns capture 1. This therefore stores the source value as core_title, corrupting the backfill and all subsequent concept upserts for that format; resolve the capture index for {content} as the Roam implementation does.

Useful? React with 👍 / 👎.

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.

Agreed on the defect; it is in #1318's producer (apps/obsidian/src/utils/extractContentFromTitle.ts), which this PR's diff range includes but does not change. Added to the description's known-gaps list alongside the two other #1317/#1318 findings: all three store a non-null but wrong core_title, and this migration treats any non-null value as already set, so those rows need a value-aware pass once the extractor is fixed.

source_data: otherData as unknown as Json,
};
if (importedFromRid && typeof importedFromRid === "string")
Expand Down
95 changes: 79 additions & 16 deletions apps/obsidian/src/utils/syncDgNodesToSupabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import { isAcceptedSchema } from "./typeUtils";
import { getTemplatePluginInfo } from "./templates";
import { difference } from "@repo/utils/setOperations";
import { getAllPages } from "@repo/database/lib/pagination";
import {
CORE_TITLE_PROBE_SELECT,
partitionByCoreTitle,
} from "@repo/database/lib/coreTitleBackfill";

const DEFAULT_TIME = "1970-01-01";
export type ChangeType = "title" | "content";
Expand Down Expand Up @@ -231,6 +235,17 @@ type BuildChangedNodesOptions = {
fullSync?: boolean;
};

type CoreTitleBackfillCounts = {
backfilled: number;
skipped: number;
orphaned: number;
};

type BuildChangedNodesResult = {
changedNodes: ObsidianDiscourseNodeData[];
coreTitleBackfill: CoreTitleBackfillCounts | null;
};

const mergeChangeTypes = (
base: ChangeType[],
additional: ChangeType[],
Expand Down Expand Up @@ -320,15 +335,33 @@ const detectNodeChanges = (
return changeTypes;
};

const noticeCoreTitleBackfill = ({
backfilled,
skipped,
orphaned,
}: CoreTitleBackfillCounts): void => {
if (backfilled === 0 && orphaned === 0) return;
const messages = [
`Backfilled core title for ${backfilled} node${backfilled === 1 ? "" : "s"}.`,
`${skipped} already had one.`,
];
if (orphaned > 0) {
messages.push(
`${orphaned} no longer match a discourse node in this vault.`,
);
}
new Notice(messages.join(" "), 5000);
};

const buildChangedNodesFromNodes = async ({
nodes,
supabaseClient,
context,
changeTypesByPath,
fullSync = false,
}: BuildChangedNodesOptions): Promise<ObsidianDiscourseNodeData[]> => {
}: BuildChangedNodesOptions): Promise<BuildChangedNodesResult> => {
if (nodes.length === 0) {
return [];
return { changedNodes: [], coreTitleBackfill: null };
}

const nodeInstanceIds = nodes.map((node) => node.nodeInstanceId);
Expand All @@ -344,11 +377,14 @@ const buildChangedNodesFromNodes = async ({
);
const changedNodes: ObsidianDiscourseNodeData[] = [];
let missingConcepts: Set<string> | undefined;
let coreTitleProbe:
| { missingCoreTitleIds: Set<string>; skipped: number; orphaned: number }
| undefined;
if (fullSync) {
const existingConceptIds = await getAllPages(
supabaseClient
.from("my_concepts")
.select("source_local_id")
.select(CORE_TITLE_PROBE_SELECT)

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.

Same trick as the Roam side. This query already pages every own-space instance row for missingConcepts, so adding the projected key costs zero extra requests. It projects the key rather than literal_content, unlike the older template_content probe below, which pulls the whole column and re-filters in JS. That one is worth a follow-up ticket, but it's out of scope here.

.eq("space_id", context.spaceId)
.eq("arity", 0)
.eq("is_schema", false)
Expand All @@ -369,6 +405,13 @@ const buildChangedNodesFromNodes = async ({
.filter((id) => id !== null),
);
missingConcepts = difference(nodeIds, dbConceptIds);
const { missingCoreTitleIds, withCoreTitleCount } =
partitionByCoreTitle(existingConceptIds);
coreTitleProbe = {
missingCoreTitleIds,
skipped: withCoreTitleCount,
orphaned: difference(missingCoreTitleIds, nodeIds).size,
};
}
}

Expand All @@ -389,7 +432,8 @@ const buildChangedNodesFromNodes = async ({

if (
finalChangeTypes.length === 0 &&
!missingConcepts?.has(node.nodeInstanceId)
!missingConcepts?.has(node.nodeInstanceId) &&
!coreTitleProbe?.missingCoreTitleIds.has(node.nodeInstanceId)

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.

Same widening the missingConcepts mechanism already uses. Forced-in nodes carry changeTypes: [], and getVariantsToCreate turns that into no content and no embedding work, so only the concept gets re-upserted. Imported nodes still continue above this, so their rows stay for the source space to backfill.

) {
continue;
}
Expand All @@ -405,7 +449,18 @@ const buildChangedNodesFromNodes = async ({
});
}

return changedNodes;
return {
changedNodes,
coreTitleBackfill: coreTitleProbe
? {
backfilled: changedNodes.filter((node) =>
coreTitleProbe.missingCoreTitleIds.has(node.nodeInstanceId),
).length,
skipped: coreTitleProbe.skipped,
orphaned: coreTitleProbe.orphaned,
}
: null,
};
};

export const syncAllNodesAndRelations = async (
Expand All @@ -426,14 +481,15 @@ export const syncAllNodesAndRelations = async (

const allNodes = await collectDiscourseNodesFromVault(plugin, true);

const changedNodeInstances = relationsOnly
? []
: await buildChangedNodesFromNodes({
nodes: allNodes,
supabaseClient,
context,
fullSync: true,
});
const { changedNodes: changedNodeInstances, coreTitleBackfill } =
relationsOnly
? { changedNodes: [], coreTitleBackfill: null }
: await buildChangedNodesFromNodes({
nodes: allNodes,
supabaseClient,
context,
fullSync: true,
});

const accountLocalId = plugin.settings.accountLocalId;
if (!accountLocalId) {
Expand All @@ -458,8 +514,15 @@ export const syncAllNodesAndRelations = async (
fullSync: true,
});

if (coreTitleBackfill !== null) {

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 Notice fires after convertDgToSupabaseConcepts, which throws on upsert error, so the counts describe rows that were actually written. It stays silent when nothing was backfilled and nothing was orphaned, which is every run after the first.

noticeCoreTitleBackfill(coreTitleBackfill);
}

// When synced nodes are already published, ensure non-text assets are in storage.
await syncPublishedNodesAssets(plugin, changedNodeInstances);
await syncPublishedNodesAssets(
plugin,
changedNodeInstances.filter((node) => node.changeTypes.length > 0),

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.

syncPublishedNodeAssets reads and rewrites FileReference rows per published node unconditionally, so without this filter every published backfill-only node cost two round-trips on the first full sync after this ships. Nodes forced in with changeTypes: [] (core-title backfill, and the pre-existing missing-concept case) have unchanged files, so their asset references are already right.

);
} catch (error) {
console.error("syncAllNodesAndRelations: Process failed:", error);
throw error;
Expand Down Expand Up @@ -594,7 +657,7 @@ const convertDgToSupabaseConcepts = async ({
.filter((n) => !!n);

const nodeInstanceToLocalConcepts = nodesSince.map((node) => {
return discourseNodeInstanceToLocalConcept(context, node);
return discourseNodeInstanceToLocalConcept(context, node, nodeTypesById);
});

const relationInstancesData = await loadRelations(plugin);
Expand Down Expand Up @@ -935,7 +998,7 @@ export const syncDiscourseNodeChanges = async (
return;
}

const changedNodes = await buildChangedNodesFromNodes({
const { changedNodes } = await buildChangedNodesFromNodes({
nodes: dgNodesInVault,
supabaseClient,
context,
Expand Down
92 changes: 92 additions & 0 deletions apps/roam/src/utils/__tests__/coreTitleBackfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
buildCoreTitleBackfill,
mergeNodesBySourceLocalId,
} from "../coreTitleBackfill";
import type { RoamDiscourseNodeData } from "../getAllDiscourseNodesSince";

const node = (sourceLocalId: string): RoamDiscourseNodeData => ({
author_local_id: "author",
author_name: "Author",
source_local_id: sourceLocalId,
created: "1",
last_modified: "2",
text: `CLM - ${sourceLocalId}`,
type: "claim-type",
});

describe("buildCoreTitleBackfill", () => {
it("forces in local nodes whose row has no core_title", () => {
const backfill = buildCoreTitleBackfill({
conceptRows: [
{ source_local_id: "a", core_title: null },
{ source_local_id: "b", core_title: "already set" },
],
localNodes: [node("a"), node("b")],
});

expect(backfill.nodesToBackfill.map((n) => n.source_local_id)).toEqual([
"a",
]);
expect(backfill.withCoreTitleCount).toBe(1);
expect(backfill.orphanedCount).toBe(0);
});

it("reports rows that are no longer in the graph as orphaned", () => {
const backfill = buildCoreTitleBackfill({
conceptRows: [
{ source_local_id: "a", core_title: null },
{ source_local_id: "gone", core_title: null },
],
localNodes: [node("a")],
});

expect(backfill.nodesToBackfill.map((n) => n.source_local_id)).toEqual([
"a",
]);
expect(backfill.orphanedCount).toBe(1);
});

it("skips rows without a source_local_id", () => {
const backfill = buildCoreTitleBackfill({
conceptRows: [{ source_local_id: null, core_title: null }],
localNodes: [node("a")],
});

expect(backfill.nodesToBackfill).toEqual([]);
expect(backfill.withCoreTitleCount).toBe(0);
expect(backfill.orphanedCount).toBe(0);
});

it("is a no-op once every row has a core_title", () => {
const backfill = buildCoreTitleBackfill({
conceptRows: [
{ source_local_id: "a", core_title: "a" },
{ source_local_id: "b", core_title: "b" },
],
localNodes: [node("a"), node("b")],
});

expect(backfill.nodesToBackfill).toEqual([]);
expect(backfill.withCoreTitleCount).toBe(2);
expect(backfill.orphanedCount).toBe(0);
});
});

describe("mergeNodesBySourceLocalId", () => {
it("appends nodes that are not already in the batch", () => {
const merged = mergeNodesBySourceLocalId(
[node("a")],
[node("b"), node("c")],
);

expect(merged.map((n) => n.source_local_id)).toEqual(["a", "b", "c"]);
});

it("keeps the original node when both batches hold the same id", () => {
const original = node("a");
const merged = mergeNodesBySourceLocalId([original], [node("a")]);

expect(merged).toEqual([original]);
});
});
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;
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,

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 key this object is spread into is concept_local_input.author_local_id; author_uid was silently dropped by jsonb_populate_record, so every instance the Roam sync pushed had author_id = NULL and a re-push of a published row blanked the author publish had set. With the backfill re-pushing every pre-core_title row in one pass this stopped being a slow leak, hence the fix here. _local_concept_to_db_concept only looks the account up (no creation), so rows whose author has no PlatformAccount stay NULL as before; the datalog variable keeps its name.

created,
last_modified,
page_uid,
Expand Down Expand Up @@ -202,7 +202,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
44 changes: 44 additions & 0 deletions apps/roam/src/utils/coreTitleBackfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { difference } from "@repo/utils/setOperations";
import {
partitionByCoreTitle,
type CoreTitleProbeRow,
} from "@repo/database/lib/coreTitleBackfill";
import { type RoamDiscourseNodeData } from "./getAllDiscourseNodesSince";

export type CoreTitleBackfill = {
nodesToBackfill: RoamDiscourseNodeData[];
withCoreTitleCount: number;
orphanedCount: number;
};

export const buildCoreTitleBackfill = ({
conceptRows,
localNodes,
}: {
conceptRows: CoreTitleProbeRow[];
localNodes: RoamDiscourseNodeData[];
}): CoreTitleBackfill => {
const { missingCoreTitleIds, withCoreTitleCount } =
partitionByCoreTitle(conceptRows);
const localIds = new Set(localNodes.map((node) => node.source_local_id));
return {
nodesToBackfill: localNodes.filter((node) =>
missingCoreTitleIds.has(node.source_local_id),
),
withCoreTitleCount,
orphanedCount: difference(missingCoreTitleIds, localIds).size,
};
};

export const mergeNodesBySourceLocalId = (
nodes: RoamDiscourseNodeData[],
additionalNodes: RoamDiscourseNodeData[],
): RoamDiscourseNodeData[] => {
const nodesById = new Map(nodes.map((node) => [node.source_local_id, node]));
for (const node of additionalNodes) {
if (!nodesById.has(node.source_local_id)) {
nodesById.set(node.source_local_id, node);
}
}
return [...nodesById.values()];
};
Loading