Skip to content

Commit 9c5be03

Browse files
authored
Eng 1343 f10a sync node schema definitions (#720)
* ENG-1343 upload obsidian node schema to supabase
1 parent 042fc2a commit 9c5be03

2 files changed

Lines changed: 121 additions & 78 deletions

File tree

apps/obsidian/src/utils/conceptConversion.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2-
import { TFile } from "obsidian";
3-
import { DiscourseNode } from "~/types";
4-
import { SupabaseContext } from "./supabaseContext";
5-
import { LocalConceptDataInput } from "@repo/database/inputTypes";
6-
import { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase";
7-
import { Json } from "@repo/database/dbTypes";
2+
import type { TFile } from "obsidian";
3+
import type { DiscourseNode } from "~/types";
4+
import type { SupabaseContext } from "./supabaseContext";
5+
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
6+
import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase";
7+
import type { Json } from "@repo/database/dbTypes";
88

99
/**
1010
* Get extra data (author, timestamps) from file metadata
@@ -33,16 +33,22 @@ export const discourseNodeSchemaToLocalConcept = ({
3333
node: DiscourseNode;
3434
accountLocalId: string;
3535
}): LocalConceptDataInput => {
36-
const now = new Date().toISOString();
36+
const { description, template, id, name, created, modified, ...otherData } =
37+
node;
3738
return {
3839
space_id: context.spaceId,
39-
name: node.name,
40-
source_local_id: node.id,
40+
name: name,
41+
source_local_id: id,
4142
is_schema: true,
4243
author_local_id: accountLocalId,
43-
created: now,
44-
// TODO: get the template or any other info to put into literal_content jsonb
45-
last_modified: now,
44+
created: new Date(created).toISOString(),
45+
last_modified: new Date(modified).toISOString(),
46+
description: description,
47+
literal_content: {
48+
label: name,
49+
template: template,
50+
source_data: otherData,
51+
},
4652
};
4753
};
4854

@@ -59,22 +65,19 @@ export const discourseNodeInstanceToLocalConcept = ({
5965
accountLocalId: string;
6066
}): LocalConceptDataInput => {
6167
const extraData = getNodeExtraData(nodeData.file, accountLocalId);
62-
console.log(nodeData.frontmatter);
63-
const concept = {
68+
const { nodeInstanceId, nodeTypeId, ...otherData } = nodeData.frontmatter;
69+
return {
6470
space_id: context.spaceId,
65-
name: nodeData.file.basename,
66-
source_local_id: nodeData.nodeInstanceId,
67-
schema_represented_by_local_id: nodeData.nodeTypeId,
71+
name: nodeData.file.path,
72+
source_local_id: nodeInstanceId as string,
73+
schema_represented_by_local_id: nodeTypeId as string,
6874
is_schema: false,
6975
literal_content: {
70-
...nodeData.frontmatter,
71-
} as unknown as Json,
76+
label: nodeData.file.basename,
77+
source_data: otherData as unknown as Json,
78+
},
7279
...extraData,
7380
};
74-
console.log(
75-
`[discourseNodeInstanceToLocalConcept] Converting concept: source_local_id=${nodeData.nodeInstanceId}, name="${nodeData.file.basename}"`,
76-
);
77-
return concept;
7881
};
7982

8083
export const relatedConcepts = (concept: LocalConceptDataInput): string[] => {
@@ -135,6 +138,7 @@ export const orderConceptsByDependency = (
135138
...missing,
136139
...orderConceptsRec(ordered, first, conceptById),
137140
]);
141+
if (missing.size > 0) console.error(`missing: ${[...missing]}`);
138142
}
139143
return { ordered, missing: Array.from(missing) };
140144
};

apps/obsidian/src/utils/syncDgNodesToSupabase.ts

Lines changed: 94 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import { Notice, TFile } from "obsidian";
3-
import { DGSupabaseClient } from "@repo/database/lib/client";
4-
import { Json } from "@repo/database/dbTypes";
3+
import type { DGSupabaseClient } from "@repo/database/lib/client";
4+
import type { Json } from "@repo/database/dbTypes";
55
import {
66
getSupabaseContext,
77
getLoggedInClient,
8-
SupabaseContext,
8+
type SupabaseContext,
99
} from "./supabaseContext";
1010
import { default as DiscourseGraphPlugin } from "~/index";
1111
import { upsertNodesToSupabaseAsContentWithEmbeddings } from "./upsertNodesAsContentWithEmbeddings";
1212
import {
1313
orderConceptsByDependency,
1414
discourseNodeInstanceToLocalConcept,
15+
discourseNodeSchemaToLocalConcept,
1516
} from "./conceptConversion";
16-
import { LocalConceptDataInput } from "@repo/database/inputTypes";
17+
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
1718

18-
const DEFAULT_TIME = new Date("1970-01-01");
19+
const DEFAULT_TIME = "1970-01-01";
1920
export type ChangeType = "title" | "content";
2021

2122
export type ObsidianDiscourseNodeData = {
@@ -101,7 +102,10 @@ const deleteNodesFromSupabase = async (
101102
if (conceptDeleteError) {
102103
result.success = false;
103104
result.errors.concept = conceptDeleteError;
104-
console.error("Failed to delete concepts from Supabase:", conceptDeleteError);
105+
console.error(
106+
"Failed to delete concepts from Supabase:",
107+
conceptDeleteError,
108+
);
105109
}
106110

107111
const { error: contentDeleteError } = await supabaseClient
@@ -159,7 +163,8 @@ const ensureNodeInstanceId = async (
159163

160164
return nodeInstanceId;
161165
};
162-
const getLastSyncTime = async (
166+
167+
const getLastContentSyncTime = async (
163168
supabaseClient: DGSupabaseClient,
164169
spaceId: number,
165170
): Promise<Date> => {
@@ -170,7 +175,22 @@ const getLastSyncTime = async (
170175
.order("last_modified", { ascending: false })
171176
.limit(1)
172177
.maybeSingle();
173-
return new Date(data?.last_modified || DEFAULT_TIME);
178+
return new Date((data?.last_modified || DEFAULT_TIME) + "Z");
179+
};
180+
181+
const getLastSchemaSyncTime = async (
182+
supabaseClient: DGSupabaseClient,
183+
spaceId: number,
184+
): Promise<Date> => {
185+
const { data } = await supabaseClient
186+
.from("Concept")
187+
.select("last_modified")
188+
.eq("space_id", spaceId)
189+
.eq("is_schema", true)
190+
.order("last_modified", { ascending: false })
191+
.limit(1)
192+
.maybeSingle();
193+
return new Date((data?.last_modified || DEFAULT_TIME) + "Z");
174194
};
175195

176196
type DiscourseNodeInVault = {
@@ -195,7 +215,6 @@ const mergeChangeTypes = (
195215
return Array.from(merged);
196216
};
197217

198-
199218
/**
200219
* Step 1: Collect all discourse nodes from the vault
201220
* Filters markdown files that have nodeTypeId in frontmatter
@@ -362,7 +381,10 @@ const buildChangedNodesFromNodes = async ({
362381
nodeInstanceIds,
363382
);
364383

365-
const lastSyncTime = await getLastSyncTime(supabaseClient, context.spaceId);
384+
const lastSyncTime = await getLastContentSyncTime(
385+
supabaseClient,
386+
context.spaceId,
387+
);
366388
const changedNodes: ObsidianDiscourseNodeData[] = [];
367389

368390
for (const node of nodes) {
@@ -466,12 +488,20 @@ export const createOrUpdateDiscourseEmbedding = async (
466488
throw new Error("accountLocalId not found in plugin settings");
467489
}
468490

469-
await syncChangedNodesToSupabase({
470-
changedNodes: allNodeInstances,
491+
await upsertNodesToSupabaseAsContentWithEmbeddings({
492+
obsidianNodes: allNodeInstances,
493+
supabaseClient,
494+
context,
495+
accountLocalId,
471496
plugin,
497+
});
498+
499+
await convertDgToSupabaseConcepts({
500+
nodesSince: allNodeInstances,
472501
supabaseClient,
473502
context,
474503
accountLocalId,
504+
plugin,
475505
});
476506

477507
console.debug("Sync completed successfully");
@@ -486,43 +516,59 @@ const convertDgToSupabaseConcepts = async ({
486516
supabaseClient,
487517
context,
488518
accountLocalId,
519+
plugin,
489520
}: {
490521
nodesSince: ObsidianDiscourseNodeData[];
491522
supabaseClient: DGSupabaseClient;
492523
context: SupabaseContext;
493524
accountLocalId: string;
525+
plugin: DiscourseGraphPlugin;
494526
}): Promise<void> => {
495-
// TODO: handling schema (node types and relations) will be handled in the future by ENG-1181
496-
// Schema upsert will need allNodeTypes parameter when enabled
527+
const lastSchemaSync = (
528+
await getLastSchemaSyncTime(supabaseClient, context.spaceId)
529+
).getTime();
530+
const newNodeTypes = (plugin.settings.nodeTypes ?? []).filter(
531+
(n) => n.modified > lastSchemaSync,
532+
);
497533

498-
const nodeInstanceToLocalConcepts = nodesSince.map((node) => {
499-
return discourseNodeInstanceToLocalConcept({
534+
const nodesTypesToLocalConcepts = newNodeTypes.map((nodeType) =>
535+
discourseNodeSchemaToLocalConcept({
536+
context,
537+
node: nodeType,
538+
accountLocalId,
539+
}),
540+
);
541+
542+
const nodeInstanceToLocalConcepts = nodesSince.map((node) =>
543+
discourseNodeInstanceToLocalConcept({
500544
context,
501545
nodeData: node,
502546
accountLocalId,
503-
});
504-
});
547+
}),
548+
);
505549

506550
const conceptsToUpsert: LocalConceptDataInput[] = [
507-
// ...nodesTypesToLocalConcepts,
551+
...nodesTypesToLocalConcepts,
508552
...nodeInstanceToLocalConcepts,
509553
];
510554

511-
const { ordered } = orderConceptsByDependency(conceptsToUpsert);
555+
if (conceptsToUpsert.length > 0) {
556+
const { ordered } = orderConceptsByDependency(conceptsToUpsert);
512557

513-
const { error } = await supabaseClient.rpc("upsert_concepts", {
514-
data: ordered as Json,
515-
v_space_id: context.spaceId,
516-
});
558+
const { error } = await supabaseClient.rpc("upsert_concepts", {
559+
data: ordered as Json,
560+
v_space_id: context.spaceId,
561+
});
517562

518-
if (error) {
519-
const errorMessage =
520-
error instanceof Error
521-
? error.message
522-
: typeof error === "string"
523-
? error
524-
: JSON.stringify(error, null, 2);
525-
throw new Error(`upsert_concepts failed: ${errorMessage}`);
563+
if (error) {
564+
const errorMessage =
565+
error instanceof Error
566+
? error.message
567+
: typeof error === "string"
568+
? error
569+
: JSON.stringify(error, null, 2);
570+
throw new Error(`upsert_concepts failed: ${errorMessage}`);
571+
}
526572
}
527573
};
528574

@@ -543,33 +589,29 @@ const syncChangedNodesToSupabase = async ({
543589
context: SupabaseContext;
544590
accountLocalId: string;
545591
}): Promise<void> => {
546-
if (changedNodes.length === 0) {
547-
console.debug("No nodes to sync");
548-
return;
592+
if (changedNodes.length > 0) {
593+
await upsertNodesToSupabaseAsContentWithEmbeddings({
594+
obsidianNodes: changedNodes,
595+
supabaseClient,
596+
context,
597+
accountLocalId,
598+
plugin,
599+
});
549600
}
550601

551-
await upsertNodesToSupabaseAsContentWithEmbeddings({
552-
obsidianNodes: changedNodes,
553-
supabaseClient,
554-
context,
555-
accountLocalId,
556-
plugin,
557-
});
558-
559602
// Only upsert concepts for nodes with title changes or new files
560603
// (concepts store the title, so content-only changes don't affect them)
561604
const nodesNeedingConceptUpsert = changedNodes.filter((node) =>
562605
node.changeTypes.includes("title"),
563606
);
564607

565-
if (nodesNeedingConceptUpsert.length > 0) {
566-
await convertDgToSupabaseConcepts({
567-
nodesSince: nodesNeedingConceptUpsert,
568-
supabaseClient,
569-
context,
570-
accountLocalId,
571-
});
572-
}
608+
await convertDgToSupabaseConcepts({
609+
nodesSince: nodesNeedingConceptUpsert,
610+
supabaseClient,
611+
context,
612+
accountLocalId,
613+
plugin,
614+
});
573615
};
574616

575617
/**
@@ -635,10 +677,7 @@ export const syncSpecificFiles = async (
635677
const changeTypesByPath = new Map<string, ChangeType[]>();
636678
for (const filePath of filePaths) {
637679
const existing = changeTypesByPath.get(filePath) ?? [];
638-
changeTypesByPath.set(
639-
filePath,
640-
mergeChangeTypes(existing, ["content"]),
641-
);
680+
changeTypesByPath.set(filePath, mergeChangeTypes(existing, ["content"]));
642681
}
643682

644683
await syncDiscourseNodeChanges(plugin, changeTypesByPath);

0 commit comments

Comments
 (0)