Skip to content

Commit 4e27c72

Browse files
committed
Implement ATJSON canonical content storage
1 parent 74fb513 commit 4e27c72

46 files changed

Lines changed: 3935 additions & 214 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/obsidian/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"build": "tsx scripts/build.ts",
1010
"lint": "eslint .",
1111
"lint:fix": "eslint . --fix",
12+
"test": "node --import tsx --test tests/atjsonContentWrite.test.ts",
1213
"publish": "tsx scripts/publish.ts --version 0.1.0",
1314
"check-types": "tsc --noEmit --skipLibCheck"
1415
},
@@ -39,6 +40,7 @@
3940
},
4041
"dependencies": {
4142
"@codemirror/view": "^6.38.8",
43+
"@repo/content-model": "workspace:*",
4244
"@repo/database": "workspace:*",
4345
"@repo/utils": "workspace:*",
4446
"@supabase/supabase-js": "catalog:",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
TEXT_MARKDOWN_CONTENT_TYPE,
3+
TEXT_PLAIN_CONTENT_TYPE,
4+
} from "@repo/content-model";
5+
6+
export type ObsidianImportContentVariant = "direct" | "full";
7+
8+
type ObsidianImportContentRow = {
9+
variant: string | null;
10+
// eslint-disable-next-line @typescript-eslint/naming-convention
11+
content_type: string | null;
12+
};
13+
14+
export const OBSIDIAN_IMPORT_CONTENT_TYPES = [
15+
TEXT_PLAIN_CONTENT_TYPE,
16+
TEXT_MARKDOWN_CONTENT_TYPE,
17+
] as const;
18+
19+
export const getContentTypeForObsidianImportVariant = (
20+
variant: ObsidianImportContentVariant,
21+
): (typeof OBSIDIAN_IMPORT_CONTENT_TYPES)[number] =>
22+
variant === "full" ? TEXT_MARKDOWN_CONTENT_TYPE : TEXT_PLAIN_CONTENT_TYPE;
23+
24+
export const isObsidianImportDirectRow = (
25+
row: ObsidianImportContentRow,
26+
): boolean =>
27+
row.variant === "direct" &&
28+
(row.content_type ?? TEXT_PLAIN_CONTENT_TYPE) === TEXT_PLAIN_CONTENT_TYPE;
29+
30+
export const isObsidianImportFullRow = (
31+
row: ObsidianImportContentRow,
32+
): boolean =>
33+
row.variant === "full" &&
34+
(row.content_type ?? TEXT_MARKDOWN_CONTENT_TYPE) ===
35+
TEXT_MARKDOWN_CONTENT_TYPE;
36+
37+
export const selectObsidianImportContentRows = <
38+
T extends ObsidianImportContentRow,
39+
>(
40+
rows: T[],
41+
): {
42+
direct: T | undefined;
43+
full: T | undefined;
44+
} => ({
45+
direct: rows.find(isObsidianImportDirectRow),
46+
full: rows.find(isObsidianImportFullRow),
47+
});
48+
49+
export { TEXT_MARKDOWN_CONTENT_TYPE, TEXT_PLAIN_CONTENT_TYPE };

apps/obsidian/src/utils/importNodes.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import type { Json } from "@repo/database/dbTypes";
3+
import {
4+
OBSIDIAN_IMPORT_CONTENT_TYPES,
5+
TEXT_PLAIN_CONTENT_TYPE,
6+
getContentTypeForObsidianImportVariant,
7+
selectObsidianImportContentRows,
8+
} from "./importContentTypes";
39
import matter from "gray-matter";
410
import { App, Notice, TFile } from "obsidian";
511
import type { DGSupabaseClient } from "@repo/database/lib/client";
@@ -66,9 +72,10 @@ export const getPublishedNodesForGroups = async ({
6672
const { data, error } = await client
6773
.from("my_contents")
6874
.select(
69-
"source_local_id, space_id, text, created, last_modified, variant, metadata, author_id",
75+
"source_local_id, space_id, text, created, last_modified, variant, content_type, metadata, author_id",
7076
)
71-
.neq("space_id", currentSpaceId);
77+
.neq("space_id", currentSpaceId)
78+
.in("content_type", OBSIDIAN_IMPORT_CONTENT_TYPES);
7279

7380
if (error) {
7481
console.error("Error fetching published nodes:", error);
@@ -86,6 +93,7 @@ export const getPublishedNodesForGroups = async ({
8693
created: string | null;
8794
last_modified: string | null;
8895
variant: string | null;
96+
content_type: string | null;
8997
author_id: number | null;
9098
metadata: Json;
9199
};
@@ -109,7 +117,7 @@ export const getPublishedNodesForGroups = async ({
109117
const latest = withDate.reduce((a, b) =>
110118
(a.last_modified ?? "") >= (b.last_modified ?? "") ? a : b,
111119
);
112-
const direct = rows.find((r) => r.variant === "direct");
120+
const { direct } = selectObsidianImportContentRows(rows);
113121
const text = direct?.text ?? latest.text ?? "";
114122
const createdAt = latest.created
115123
? new Date(latest.created + "Z").valueOf()
@@ -267,6 +275,7 @@ export const fetchNodeContent = async ({
267275
.eq("source_local_id", nodeInstanceId)
268276
.eq("space_id", spaceId)
269277
.eq("variant", variant)
278+
.eq("content_type", getContentTypeForObsidianImportVariant(variant))
270279
.maybeSingle();
271280

272281
if (error || !data || data.text == null) {
@@ -301,6 +310,7 @@ export const fetchNodeContentWithMetadata = async ({
301310
.eq("source_local_id", nodeInstanceId)
302311
.eq("space_id", spaceId)
303312
.eq("variant", variant)
313+
.eq("content_type", getContentTypeForObsidianImportVariant(variant))
304314
.maybeSingle();
305315

306316
if (error || !data || data.text == null) {
@@ -342,10 +352,13 @@ const fetchNodeContentForImport = async ({
342352
} | null> => {
343353
const { data, error } = await client
344354
.from("my_contents")
345-
.select("text, created, last_modified, variant, metadata, author_id")
355+
.select(
356+
"text, created, last_modified, variant, content_type, metadata, author_id",
357+
)
346358
.eq("source_local_id", nodeInstanceId)
347359
.eq("space_id", spaceId)
348-
.in("variant", ["direct", "full"]);
360+
.in("variant", ["direct", "full"])
361+
.in("content_type", OBSIDIAN_IMPORT_CONTENT_TYPES);
349362

350363
if (error) {
351364
console.error("Error fetching node content for import:", error);
@@ -358,10 +371,10 @@ const fetchNodeContentForImport = async ({
358371
last_modified: string | null;
359372
author_id: number | null;
360373
variant: string | null;
374+
content_type: string | null;
361375
metadata: Json;
362376
}>;
363-
const direct = rows.find((r) => r.variant === "direct");
364-
const full = rows.find((r) => r.variant === "full");
377+
const { direct, full } = selectObsidianImportContentRows(rows);
365378
const authorId = full?.author_id ?? direct?.author_id ?? null;
366379

367380
if (
@@ -412,6 +425,7 @@ export const getSourceContentDates = async ({
412425
.eq("source_local_id", nodeInstanceId)
413426
.eq("space_id", spaceId)
414427
.eq("variant", "direct")
428+
.eq("content_type", TEXT_PLAIN_CONTENT_TYPE)
415429
.maybeSingle();
416430
if (error || !data) return null;
417431
return {
@@ -1542,6 +1556,7 @@ export const refreshImportedFile = async ({
15421556
.eq("space_id", spaceId)
15431557
.eq("source_local_id", frontmatter.nodeInstanceId)
15441558
.eq("variant", "direct")
1559+
.eq("content_type", TEXT_PLAIN_CONTENT_TYPE)
15451560
.maybeSingle();
15461561
const metadata = metadataResp.data?.metadata;
15471562
const filePath: string | undefined =

apps/obsidian/src/utils/publishNode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { isProvisionalSchema } from "./typeUtils";
2121
import type { DiscourseNodeInVault } from "./getDiscourseNodes";
2222
import type { SupabaseContext } from "./supabaseContext";
2323
import type { TablesInsert } from "@repo/database/dbTypes";
24+
import { TEXT_MARKDOWN_CONTENT_TYPE } from "@repo/content-model";
2425

2526
const publishSchema = async ({
2627
client,
@@ -445,6 +446,7 @@ export const publishNodeToGroup = async ({
445446
.eq("source_local_id", nodeId)
446447
.eq("space_id", spaceId)
447448
.eq("variant", "full")
449+
.eq("content_type", TEXT_MARKDOWN_CONTENT_TYPE)
448450
.maybeSingle();
449451
if (idResponse.error || !idResponse.data) {
450452
throw idResponse.error || new Error("no data while fetching node");

apps/obsidian/src/utils/syncDgNodesToSupabase.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
} from "./conceptConversion";
2424
import { loadRelations } from "~/utils/relationsStore";
2525
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
26+
import {
27+
TEXT_MARKDOWN_CONTENT_TYPE,
28+
TEXT_PLAIN_CONTENT_TYPE,
29+
} from "@repo/content-model";
2630
import {
2731
type DiscourseNodeInVault,
2832
collectDiscourseNodesFromVault,
@@ -59,6 +63,7 @@ const getAllNodeInstanceIdsFromSupabase = async (
5963
.select("source_local_id")
6064
.eq("space_id", spaceId)
6165
.eq("scale", "document")
66+
.eq("content_type", TEXT_PLAIN_CONTENT_TYPE)
6267
.not("source_local_id", "is", null);
6368

6469
if (error) {
@@ -168,6 +173,7 @@ const getLastContentSyncTime = async (
168173
.from("my_contents")
169174
.select("last_modified")
170175
.eq("space_id", spaceId)
176+
.in("content_type", [TEXT_PLAIN_CONTENT_TYPE, TEXT_MARKDOWN_CONTENT_TYPE])
171177
.order("last_modified", { ascending: false })
172178
.limit(1)
173179
.maybeSingle();
@@ -273,6 +279,7 @@ const getExistingTitlesFromDatabase = async (
273279
.select("source_local_id, text")
274280
.eq("space_id", spaceId)
275281
.eq("variant", "direct")
282+
.eq("content_type", TEXT_PLAIN_CONTENT_TYPE)
276283
.in("source_local_id", nodeInstanceIds);
277284

278285
if (directError) {

apps/obsidian/src/utils/upsertNodesAsContentWithEmbeddings.ts

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import { nextApiRoot } from "@repo/utils/execContext";
3-
import { DGSupabaseClient } from "@repo/database/lib/client";
4-
import { Json, CompositeTypes } from "@repo/database/dbTypes";
5-
import { SupabaseContext } from "./supabaseContext";
6-
import { ObsidianDiscourseNodeData, ChangeType } from "./syncDgNodesToSupabase";
7-
import { default as DiscourseGraphPlugin } from "~/index";
3+
import type { DGSupabaseClient } from "@repo/database/lib/client";
4+
import type { Json, CompositeTypes } from "@repo/database/dbTypes";
5+
import {
6+
DG_ATJSON_CONTENT_TYPE,
7+
TEXT_MARKDOWN_CONTENT_TYPE,
8+
TEXT_PLAIN_CONTENT_TYPE,
9+
createDgAtJsonMetadata,
10+
derivePlainTextFromDgDocument,
11+
obsidianMarkdownToDgDocument,
12+
} from "@repo/content-model";
13+
import type { SupabaseContext } from "./supabaseContext";
14+
import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase";
15+
import type DiscourseGraphPlugin from "~/index";
816

917
type LocalContentDataInput = Partial<CompositeTypes<"content_local_input">>;
1018

11-
type ContentVariant = "direct" | "full";
12-
1319
const EMBEDDING_BATCH_SIZE = 200;
1420
const EMBEDDING_MODEL = "openai_text_embedding_3_small_1536";
1521

@@ -19,31 +25,16 @@ type EmbeddingApiResponse = {
1925
}[];
2026
};
2127

22-
/**
23-
* Determine which content variants to create based on change types
24-
*/
25-
const getVariantsToCreate = (changeTypes: ChangeType[]): ContentVariant[] => {
26-
const variants: ContentVariant[] = [];
27-
28-
if (changeTypes.includes("title")) {
29-
variants.push("direct");
30-
}
31-
32-
if (changeTypes.includes("content")) {
33-
variants.push("full");
34-
}
35-
36-
return variants;
37-
};
38-
3928
const createNodeContentEntries = async (
4029
node: ObsidianDiscourseNodeData,
4130
accountLocalId: string,
4231
plugin: DiscourseGraphPlugin,
4332
): Promise<LocalContentDataInput[]> => {
44-
const variantsToCreate = getVariantsToCreate(node.changeTypes);
33+
const shouldWriteDirect = node.changeTypes.includes("title");
34+
const shouldWriteMarkdown = node.changeTypes.includes("content");
35+
const shouldWriteAtJson = shouldWriteDirect || shouldWriteMarkdown;
4536

46-
if (variantsToCreate.length === 0) {
37+
if (!shouldWriteDirect && !shouldWriteMarkdown && !shouldWriteAtJson) {
4738
return [];
4839
}
4940

@@ -59,25 +50,45 @@ const createNodeContentEntries = async (
5950
const entries: LocalContentDataInput[] = [];
6051

6152
// Create direct entry (title) if needed - will get embeddings
62-
if (variantsToCreate.includes("direct")) {
53+
if (shouldWriteDirect) {
6354
entries.push({
6455
...baseEntry,
6556
text: node.file.basename,
6657
variant: "direct",
58+
content_type: TEXT_PLAIN_CONTENT_TYPE,
6759
metadata: { filePath: node.file.path },
6860
});
6961
}
7062

71-
// Create full entry (content) if needed - no embeddings
72-
if (variantsToCreate.includes("full")) {
63+
if (shouldWriteMarkdown || shouldWriteAtJson) {
7364
try {
7465
const fullContent = await plugin.app.vault.read(node.file);
75-
entries.push({
76-
...baseEntry,
77-
text: fullContent,
78-
variant: "full",
79-
metadata: node.frontmatter as Json,
66+
if (shouldWriteMarkdown) {
67+
entries.push({
68+
...baseEntry,
69+
text: fullContent,
70+
variant: "full",
71+
content_type: TEXT_MARKDOWN_CONTENT_TYPE,
72+
metadata: node.frontmatter as Json,
73+
});
74+
}
75+
const document = obsidianMarkdownToDgDocument({
76+
title: node.file.basename,
77+
markdown: fullContent,
78+
metadata: {
79+
filePath: node.file.path,
80+
frontmatter: node.frontmatter as Json,
81+
},
8082
});
83+
if (shouldWriteAtJson) {
84+
entries.push({
85+
...baseEntry,
86+
text: derivePlainTextFromDgDocument(document),
87+
variant: "full",
88+
content_type: DG_ATJSON_CONTENT_TYPE,
89+
metadata: createDgAtJsonMetadata({ document }) as unknown as Json,
90+
});
91+
}
8192
} catch (error) {
8293
console.error(`Error reading file content for ${node.file.path}:`, error);
8394
}
@@ -202,18 +213,24 @@ export const upsertNodesToSupabaseAsContentWithEmbeddings = async ({
202213
return;
203214
}
204215

205-
// Create two entries per node: one "direct" (title) and one "full" (content)
216+
// Create representation rows based on the changed slice: title, Markdown body, and canonical ATJSON.
206217
const allContentEntries = await convertObsidianNodeToLocalContent({
207218
nodes: obsidianNodes,
208219
accountLocalId,
209220
plugin,
210221
});
211222

212223
const directVariantEntries = allContentEntries.filter(
213-
(entry) => entry.variant === "direct",
224+
(entry) =>
225+
entry.variant === "direct" &&
226+
(entry.content_type ?? TEXT_PLAIN_CONTENT_TYPE) ===
227+
TEXT_PLAIN_CONTENT_TYPE,
214228
);
215229
const fullVariantEntries = allContentEntries.filter(
216-
(entry) => entry.variant === "full",
230+
(entry) =>
231+
entry.variant === "full" ||
232+
(entry.content_type ?? TEXT_PLAIN_CONTENT_TYPE) !==
233+
TEXT_PLAIN_CONTENT_TYPE,
217234
);
218235

219236
let directEntriesWithEmbeddings: LocalContentDataInput[];
@@ -223,7 +240,7 @@ export const upsertNodesToSupabaseAsContentWithEmbeddings = async ({
223240
} catch (error: unknown) {
224241
const errorMessage = error instanceof Error ? error.message : String(error);
225242
console.error(
226-
`upsertNodesToSupabaseAsContentWithEmbeddings: Embedding service failed ${errorMessage}`,
243+
`upsertNodesToSupabaseAsContentWithEmbeddings: Embedding service failed - ${errorMessage}`,
227244
);
228245
throw new Error(errorMessage);
229246
}

0 commit comments

Comments
 (0)