Skip to content

Commit cb70908

Browse files
authored
ENG-1791 Obsidian sync does not recover from Concept upsert failure (#1070)
1 parent b1b328a commit cb70908

4 files changed

Lines changed: 98 additions & 26 deletions

File tree

apps/obsidian/src/utils/publishNode.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
syncPublishedNodeAssets,
1818
} from "./syncDgNodesToSupabase";
1919
import { isProvisionalSchema } from "./typeUtils";
20+
import { intersection, difference } from "@repo/utils/setOperations";
2021

2122
import type { DiscourseNodeInVault } from "./getDiscourseNodes";
2223
import type { SupabaseContext } from "./supabaseContext";
@@ -68,31 +69,6 @@ const publishSchema = async ({
6869
}
6970
};
7071

71-
const intersection = <T>(set1: Set<T>, set2: Set<T>): Set<T> => {
72-
// @ts-expect-error - Set.intersection is ES2025 feature
73-
if (set1.intersection) return set1.intersection(set2); // eslint-disable-line
74-
const r: Set<T> = new Set();
75-
for (const x of set1) {
76-
if (set2.has(x)) r.add(x);
77-
}
78-
return r;
79-
};
80-
81-
const difference = <T>(set1: Set<T>, set2: Set<T>): Set<T> => {
82-
// @ts-expect-error - Set.difference is ES2025 feature
83-
if (set1.difference) return set1.difference(set2); // eslint-disable-line
84-
const result = new Set(set1);
85-
if (set1.size <= set2.size)
86-
for (const e of set1) {
87-
if (set2.has(e)) result.delete(e);
88-
}
89-
else
90-
for (const e of set2) {
91-
if (result.has(e)) result.delete(e);
92-
}
93-
return result;
94-
};
95-
9672
export const publishNewRelation = async (
9773
plugin: DiscourseGraphPlugin,
9874
relation: RelationInstance,

apps/obsidian/src/utils/syncDgNodesToSupabase.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
} from "./getDiscourseNodes";
2929
import { isAcceptedSchema } from "./typeUtils";
3030
import { getTemplatePluginInfo } from "./templates";
31+
import { difference } from "@repo/utils/setOperations";
32+
import { getAllPages } from "@repo/database/lib/pagination";
3133

3234
const DEFAULT_TIME = "1970-01-01";
3335
export type ChangeType = "title" | "content";
@@ -226,6 +228,7 @@ type BuildChangedNodesOptions = {
226228
supabaseClient: DGSupabaseClient;
227229
context: SupabaseContext;
228230
changeTypesByPath?: Map<string, ChangeType[]>;
231+
fullSync?: boolean;
229232
};
230233

231234
const mergeChangeTypes = (
@@ -322,6 +325,7 @@ const buildChangedNodesFromNodes = async ({
322325
supabaseClient,
323326
context,
324327
changeTypesByPath,
328+
fullSync = false,
325329
}: BuildChangedNodesOptions): Promise<ObsidianDiscourseNodeData[]> => {
326330
if (nodes.length === 0) {
327331
return [];
@@ -339,6 +343,34 @@ const buildChangedNodesFromNodes = async ({
339343
context.spaceId,
340344
);
341345
const changedNodes: ObsidianDiscourseNodeData[] = [];
346+
let missingConcepts: Set<string> | undefined;
347+
if (fullSync) {
348+
const existingConceptIds = await getAllPages(
349+
supabaseClient
350+
.from("my_concepts")
351+
.select("source_local_id")
352+
.eq("space_id", context.spaceId)
353+
.eq("arity", 0)
354+
.eq("is_schema", false)
355+
.order("id"),
356+
1000,
357+
);
358+
if (Array.isArray(existingConceptIds)) {
359+
// Here, compensating for concepts that never got upserted
360+
// Probably due to non-atomicity of upsert of concept and content
361+
// TODO try to see if there are other cases
362+
// In particular, using timing when concepts get reordered by dependency
363+
// may be error-prone
364+
// fail silently otherwise, there will be other opportunities
365+
const nodeIds = new Set(nodes.map((n) => n.nodeInstanceId));
366+
const dbConceptIds = new Set(
367+
existingConceptIds
368+
.map((d) => d.source_local_id)
369+
.filter((id) => id !== null),
370+
);
371+
missingConcepts = difference(nodeIds, dbConceptIds);
372+
}
373+
}
342374

343375
for (const node of nodes) {
344376
if (node.frontmatter.importedFromRid) continue;
@@ -355,7 +387,10 @@ const buildChangedNodesFromNodes = async ({
355387
: detectedChangeTypes;
356388
const finalChangeTypes = mergedChangeTypes;
357389

358-
if (finalChangeTypes.length === 0) {
390+
if (
391+
finalChangeTypes.length === 0 &&
392+
!missingConcepts?.has(node.nodeInstanceId)
393+
) {
359394
continue;
360395
}
361396

@@ -397,6 +432,7 @@ export const syncAllNodesAndRelations = async (
397432
nodes: allNodes,
398433
supabaseClient,
399434
context,
435+
fullSync: true,
400436
});
401437

402438
const accountLocalId = plugin.settings.accountLocalId;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { PostgrestError, PostgrestFilterBuilder } from "@supabase/supabase-js";
2+
import type { Database, Tables } from "@repo/database/dbTypes";
3+
4+
type TableName =
5+
| keyof Database["public"]["Tables"]
6+
| keyof Database["public"]["Views"];
7+
8+
type PGQuery<Name extends TableName, Result> = PostgrestFilterBuilder<
9+
{ PostgrestVersion: "12" },
10+
Database["public"],
11+
Tables<Name>,
12+
Result[],
13+
Name
14+
>;
15+
16+
export const getAllPages = async <Name extends TableName, Result>(
17+
query: PGQuery<Name, Result>,
18+
limit: number = 200,
19+
): Promise<Result[] | PostgrestError> => {
20+
// note: Bypassing protections
21+
// eslint-disable-next-line
22+
if ((query as any).url.search.indexOf("order") < 0)
23+
throw new Error("Missing order clause on paginated query");
24+
let offset = 0;
25+
const rows: Result[] = [];
26+
// eslint-disable-next-line no-constant-condition
27+
while (true) {
28+
const result = await query.range(offset, offset + limit - 1);
29+
const { data, error } = result;
30+
if (error) return error;
31+
rows.push(...data);
32+
if (data.length < limit) break;
33+
offset += data.length;
34+
}
35+
return rows;
36+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export const intersection = <T>(set1: Set<T>, set2: Set<T>): Set<T> => {
2+
// @ts-expect-error - Set.intersection is ES2025 feature
3+
if (set1.intersection) return set1.intersection(set2); // eslint-disable-line
4+
const r: Set<T> = new Set();
5+
for (const x of set1) {
6+
if (set2.has(x)) r.add(x);
7+
}
8+
return r;
9+
};
10+
11+
export const difference = <T>(set1: Set<T>, set2: Set<T>): Set<T> => {
12+
// @ts-expect-error - Set.difference is ES2025 feature
13+
if (set1.difference) return set1.difference(set2); // eslint-disable-line
14+
const result = new Set(set1);
15+
if (set1.size <= set2.size)
16+
for (const e of set1) {
17+
if (set2.has(e)) result.delete(e);
18+
}
19+
else
20+
for (const e of set2) {
21+
if (result.has(e)) result.delete(e);
22+
}
23+
return result;
24+
};

0 commit comments

Comments
 (0)