From 216f017fb634d6b031612c52768c0cfea446f809 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 25 Feb 2026 10:39:10 -0500 Subject: [PATCH 01/10] eng-1300 publish relations --- apps/obsidian/src/utils/publishNode.ts | 125 ++++++++++++++++++++-- apps/obsidian/src/utils/relationsStore.ts | 17 +++ 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index ae2f08cdc..bc7005aa0 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -3,7 +3,13 @@ import type { default as DiscourseGraphPlugin } from "~/index"; import { getLoggedInClient, getSupabaseContext } from "./supabaseContext"; import { addFile } from "@repo/database/lib/files"; import mime from "mime-types"; -import { DGSupabaseClient } from "@repo/database/lib/client"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import { + getRelationsForNodeInstanceId, + getFileForNodeInstanceIds, + loadRelations, + saveRelations, +} from "./relationsStore"; const publishSchema = async ({ client, @@ -56,6 +62,88 @@ const publishSchema = async ({ } }; +export const publishNodeRelations = async ({ + plugin, + client, + nodeId, + myGroup, + spaceId, +}: { + plugin: DiscourseGraphPlugin; + client: DGSupabaseClient; + nodeId: string; + myGroup: string; + spaceId: number; +}): Promise => { + const relations = await getRelationsForNodeInstanceId(plugin, nodeId); + const resourceIds: Set = new Set(); + const relationTriples = plugin.settings.discourseRelations ?? []; + const relevantNodeIds: Set = new Set(); + relations.map((relation) => { + relevantNodeIds.add(relation.source); + relevantNodeIds.add(relation.destination); + }); + const relevantNodeFiles = getFileForNodeInstanceIds(plugin, relevantNodeIds); + const relevantNodeTypeById: Record = {}; + Object.entries(relevantNodeFiles).map(([id, file]: [string, TFile]) => { + const fm = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + if (fm === undefined) return; + if (fm.nodeInstanceId !== nodeId) { + // check if published to same group. + // Note: current node's pub status not in cache yet! + if (!Array.isArray(fm.publishedToGroups)) return; + const publishedToGroups: string[] = + (fm.publishedToGroups as string[]) || []; + if (!publishedToGroups.includes(myGroup)) return; + } + if (fm.importedFromRid) return; // temporary, should be removed after eng-1475 + relevantNodeTypeById[id] = fm.nodeTypeId as string; + }); + relations.map((relation) => { + if ((relation.publishedToGroupId ?? []).includes(myGroup)) return; + const triple = relationTriples.find( + (triple) => + triple.relationshipTypeId === relation.type && + triple.sourceId === relevantNodeTypeById[relation.source] && + triple.destinationId === relevantNodeTypeById[relation.destination], + ); + if (triple) { + resourceIds.add(relation.id); + resourceIds.add(relation.type); + resourceIds.add(triple.id); + } + }); + const publishResponse = await client.from("ResourceAccess").upsert( + [...resourceIds.values()].map((sourceLocalId: string) => ({ + /* eslint-disable @typescript-eslint/naming-convention */ + account_uid: myGroup, + source_local_id: sourceLocalId, + space_id: spaceId, + /* eslint-enable @typescript-eslint/naming-convention */ + })), + { ignoreDuplicates: true }, + ); + if (publishResponse.error && publishResponse.error.code !== "23505") + throw publishResponse.error; + const relData = await loadRelations(plugin); + let dataChanged = false; + relations + .filter((rel) => resourceIds.has(rel.id)) + .map((rel) => { + const savedRel = relData.relations[rel.id]; + if (!savedRel) return; + const publishedTo = savedRel.publishedToGroupId; + if (!publishedTo) { + savedRel.publishedToGroupId = [myGroup]; + dataChanged = true; + } else if (!publishedTo.includes(myGroup)) { + publishedTo.push(myGroup); + dataChanged = true; + } + }); + if (dataChanged) await saveRelations(plugin, relData); +}; + export const publishNode = async ({ plugin, file, @@ -65,13 +153,8 @@ export const publishNode = async ({ file: TFile; frontmatter: FrontMatterCache; }): Promise => { - const nodeId = frontmatter.nodeInstanceId as string | undefined; - if (!nodeId) throw new Error("Please sync the node first"); const client = await getLoggedInClient(plugin); if (!client) throw new Error("Cannot get client"); - const context = await getSupabaseContext(plugin); - if (!context) throw new Error("Cannot get context"); - const spaceId = context.spaceId; const myGroupsResponse = await client .from("group_membership") .select("group_id"); @@ -83,7 +166,32 @@ export const publishNode = async ({ const existingPublish = (frontmatter.publishedToGroups as undefined | string[]) || []; const commonGroups = existingPublish.filter((g) => myGroups.has(g)); + // temporary single-group assumption const myGroup = (commonGroups.length > 0 ? commonGroups : [...myGroups])[0]!; + return await publishNodeToGroup({ plugin, file, frontmatter, myGroup }); +}; + +export const publishNodeToGroup = async ({ + plugin, + file, + frontmatter, + myGroup, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + frontmatter: FrontMatterCache; + myGroup: string; +}): Promise => { + const nodeId = frontmatter.nodeInstanceId as string | undefined; + if (!nodeId) throw new Error("Please sync the node first"); + const context = await getSupabaseContext(plugin); + if (!context) throw new Error("Cannot get context"); + const spaceId = context.spaceId; + const client = await getLoggedInClient(plugin); + if (!client) throw new Error("Cannot get client"); + const existingPublish = + (frontmatter.publishedToGroups as undefined | string[]) || []; + const idResponse = await client .from("Content") .select("last_modified") @@ -97,6 +205,7 @@ export const publishNode = async ({ const lastModifiedDb = new Date( idResponse.data.last_modified + "Z", ).getTime(); + await publishNodeRelations({ plugin, client, nodeId, myGroup, spaceId }); const embeds = plugin.app.metadataCache.getFileCache(file)?.embeds ?? []; const attachments = embeds .map(({ link }) => { @@ -116,7 +225,7 @@ export const publishNode = async ({ ); const skipPublishAccess = - commonGroups.length > 0 && lastModified <= lastModifiedDb; + existingPublish.includes(myGroup) && lastModified <= lastModifiedDb; if (!skipPublishAccess) { const publishSpaceResponse = await client.from("SpaceAccess").upsert( @@ -210,7 +319,7 @@ export const publishNode = async ({ // do not fail on cleanup if (cleanupResult.error) console.error(cleanupResult.error); - if (commonGroups.length === 0) + if (!existingPublish.includes(myGroup)) await plugin.app.fileManager.processFrontMatter( file, (fm: Record) => { diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 6bb079adf..6f5f06972 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -243,6 +243,23 @@ export const getFileForNodeInstanceId = async ( return null; }; +export const getFileForNodeInstanceIds = ( + plugin: DiscourseGraphPlugin, + nodeInstanceIds: Set, +): Record => { + const files = plugin.app.vault.getMarkdownFiles(); + const result: Record = {}; + for (const file of files) { + const cache = plugin.app.metadataCache.getFileCache(file); + const id = (cache?.frontmatter as Record | undefined) + ?.nodeInstanceId as string | undefined; + if (id && nodeInstanceIds.has(id)) { + result[id] = file; + } + } + return result; +}; + /** * Find a relation instance by source, destination, and type. Returns the first match. */ From 18be22b2cc0c9eab72f8475792f6d8f9939d8f9e Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 25 Feb 2026 22:45:16 -0500 Subject: [PATCH 02/10] use QueryEngine for discourseNodeById --- apps/obsidian/src/services/QueryEngine.ts | 25 +++++++++++++++++++++++ apps/obsidian/src/utils/relationsStore.ts | 25 ++++++++++++++++------- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 6c524a70f..7e5922d72 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -37,6 +37,8 @@ export class QueryEngine { this.app = app; } + functional = () => !!this.dc; + /** * Search across all discourse nodes (files that have frontmatter nodeTypeId) */ @@ -81,6 +83,29 @@ export class QueryEngine { } }; + /** + * Search across all discourse nodes that have nodeInstanceId + */ + getDiscourseNodeById = (nodeInstanceId: string): TFile | null => { + if (!this.dc) { + console.warn( + "Datacore API not available. Search functionality is not available.", + ); + return null; + } + + try { + const dcQuery = `@page and exists(nodeInstanceId) and nodeInstanceId = "${nodeInstanceId}"`; + const potentialNodes = this.dc.query(dcQuery); + const path = potentialNodes.at(0)?.$path; + if (!path) return null; + return this.app.vault.getFileByPath(path); + } catch (error) { + console.error("Error in searchDiscourseNodeById:", error); + return null; + } + }; + searchCompatibleNodeByTitle = async ({ query, compatibleNodeTypeIds, diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 6f5f06972..b6e5631e8 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -5,6 +5,7 @@ import { ensureNodeInstanceId } from "~/utils/nodeInstanceId"; import { checkAndCreateFolder } from "~/utils/file"; import { getVaultId } from "./supabaseContext"; import type { RelationInstance } from "~/types"; +import { QueryEngine } from "~/services/QueryEngine"; const RELATIONS_FILE_NAME = "relations.json"; const RELATIONS_FILE_VERSION = 1; @@ -247,14 +248,24 @@ export const getFileForNodeInstanceIds = ( plugin: DiscourseGraphPlugin, nodeInstanceIds: Set, ): Record => { - const files = plugin.app.vault.getMarkdownFiles(); const result: Record = {}; - for (const file of files) { - const cache = plugin.app.metadataCache.getFileCache(file); - const id = (cache?.frontmatter as Record | undefined) - ?.nodeInstanceId as string | undefined; - if (id && nodeInstanceIds.has(id)) { - result[id] = file; + if (nodeInstanceIds.size == 0) return result; + const queryEngine = new QueryEngine(plugin.app); + if (queryEngine.functional()) { + [...nodeInstanceIds.values()].map((nodeId) => { + const f = queryEngine.getDiscourseNodeById(nodeId); + if (f) result[nodeId] = f; + }); + } else { + // query engine not available, fallback + const files = plugin.app.vault.getMarkdownFiles(); + for (const file of files) { + const cache = plugin.app.metadataCache.getFileCache(file); + const id = (cache?.frontmatter as Record | undefined) + ?.nodeInstanceId as string | undefined; + if (id && nodeInstanceIds.has(id)) { + result[id] = file; + } } } return result; From 2e6577f29aa73e401e2f2a0126e9a4af39154d1c Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 25 Feb 2026 22:53:08 -0500 Subject: [PATCH 03/10] opportunistic: remove spurious async --- apps/obsidian/src/components/RelationshipSection.tsx | 2 +- .../src/components/canvas/overlays/RelationPanel.tsx | 7 ++----- apps/obsidian/src/utils/relationsStore.ts | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx index 47da12c2f..62c00f288 100644 --- a/apps/obsidian/src/components/RelationshipSection.tsx +++ b/apps/obsidian/src/components/RelationshipSection.tsx @@ -402,7 +402,7 @@ const CurrentRelationships = ({ const group = tempRelationships.get(relationKey)!; const otherId = isSource ? r.destination : r.source; - const linkedFile = await getFileForNodeInstanceId(plugin, otherId); + const linkedFile = getFileForNodeInstanceId(plugin, otherId); if ( linkedFile && !group.linkedFiles.some((f) => f.path === linkedFile.path) diff --git a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx index 8bba05dd2..9cbcfc292 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx @@ -540,10 +540,7 @@ const computeRelations = async ( const nodeInstanceId = await getNodeInstanceIdForFile(plugin, file); if (!nodeInstanceId) return []; - const relations = await getRelationsForNodeInstanceId( - plugin, - nodeInstanceId, - ); + const relations = await getRelationsForNodeInstanceId(plugin, nodeInstanceId); const result = new Map(); for (const relationType of plugin.settings.relationTypes) { @@ -573,7 +570,7 @@ const computeRelations = async ( const group = result.get(key)!; for (const r of instanceRels) { const otherId = r.source === nodeInstanceId ? r.destination : r.source; - const linked = await getFileForNodeInstanceId(plugin, otherId); + const linked = getFileForNodeInstanceId(plugin, otherId); if (linked && !group.linkedFiles.some((f) => f.path === linked.path)) { group.linkedFiles.push(linked); } diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index b6e5631e8..76e31e4d1 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -228,10 +228,10 @@ export const getNodeTypeIdForFile = async ( return typeof nodeTypeId === "string" ? nodeTypeId : null; }; -export const getFileForNodeInstanceId = async ( +export const getFileForNodeInstanceId = ( plugin: DiscourseGraphPlugin, nodeInstanceId: string, -): Promise => { +): TFile | null => { const files = plugin.app.vault.getMarkdownFiles(); for (const file of files) { const cache = plugin.app.metadataCache.getFileCache(file); From d5616b030ea5f6c37f7a524071b4d2cc1f876a0c Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 25 Feb 2026 22:54:57 -0500 Subject: [PATCH 04/10] opportunistic: also use QE in single case --- apps/obsidian/src/utils/relationsStore.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 76e31e4d1..f3db77f8c 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -232,6 +232,9 @@ export const getFileForNodeInstanceId = ( plugin: DiscourseGraphPlugin, nodeInstanceId: string, ): TFile | null => { + const queryEngine = new QueryEngine(plugin.app); + if (queryEngine.functional()) + return queryEngine.getDiscourseNodeById(nodeInstanceId); const files = plugin.app.vault.getMarkdownFiles(); for (const file of files) { const cache = plugin.app.metadataCache.getFileCache(file); From 024a9eea52b38761bf37b6ccb06d4520aac5b717 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 25 Feb 2026 23:39:28 -0500 Subject: [PATCH 05/10] define publishNewRelation --- .../src/components/ImportNodesModal.tsx | 6 +- apps/obsidian/src/utils/importNodes.ts | 4 +- apps/obsidian/src/utils/publishNode.ts | 81 +++++++++++++++++-- apps/obsidian/src/utils/relationsStore.ts | 7 ++ 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx index 16f72d78c..bd46c1309 100644 --- a/apps/obsidian/src/components/ImportNodesModal.tsx +++ b/apps/obsidian/src/components/ImportNodesModal.tsx @@ -45,15 +45,13 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => { return; } - const groups = await getAvailableGroups(client); - if (groups.length === 0) { + const groupIds = await getAvailableGroups(client); + if (groupIds.length === 0) { new Notice("You are not a member of any groups"); onClose(); return; } - const groupIds = groups.map((g) => g.group_id); - const publishedNodes = await getPublishedNodesForGroups({ client, groupIds, diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index bf7159f7a..21a035cd5 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -11,7 +11,7 @@ import { spaceUriAndLocalIdToRid, ridToSpaceUriAndLocalId } from "./rid"; export const getAvailableGroups = async ( client: DGSupabaseClient, -): Promise<{ group_id: string }[]> => { +): Promise => { const { data, error } = await client .from("group_membership") .select("group_id") @@ -22,7 +22,7 @@ export const getAvailableGroups = async ( throw new Error(`Failed to fetch groups: ${error.message}`); } - return data || []; + return (data || []).map((g) => g.group_id); }; export const getPublishedNodesForGroups = async ({ diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index bc7005aa0..6e2a69215 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -6,10 +6,13 @@ import mime from "mime-types"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { getRelationsForNodeInstanceId, + getFileForNodeInstanceId, getFileForNodeInstanceIds, loadRelations, saveRelations, } from "./relationsStore"; +import type { RelationInstance } from "~/types"; +import { getAvailableGroups } from "./importNodes"; const publishSchema = async ({ client, @@ -62,6 +65,76 @@ const publishSchema = async ({ } }; +const intersection = (set1: Set, set2: Set): Set => { + // @ts-ignore-error + if (set1.intersection) return set1.intersection(set2); + const r: Set = new Set(); + for (const x of set1) { + if (set2.has(x)) r.add(x); + } + return r; +}; + +export const publishNewRelation = async ( + plugin: DiscourseGraphPlugin, + relation: RelationInstance, +) => { + const client = await getLoggedInClient(plugin); + if (!client) throw new Error("Cannot get client"); + const context = await getSupabaseContext(plugin); + if (!context) throw new Error("Cannot get context"); + const sourceFile = getFileForNodeInstanceId(plugin, relation.source); + const destinationFile = getFileForNodeInstanceId( + plugin, + relation.destination, + ); + if (!sourceFile || !destinationFile) return; + const sourceFm = + plugin.app.metadataCache.getFileCache(sourceFile)?.frontmatter; + const destinationFm = + plugin.app.metadataCache.getFileCache(destinationFile)?.frontmatter; + if (!sourceFm || !destinationFm) return; + + const sourceGroups = sourceFm.publishedToGroups; + const destinationGroups = destinationFm.publishedToGroups; + if (!Array.isArray(sourceGroups) || !Array.isArray(destinationGroups)) return; + const relationTriples = plugin.settings.discourseRelations ?? []; + const triple = relationTriples.find( + (triple) => + triple.relationshipTypeId === relation.type && + triple.sourceId === sourceFm.nodeTypeId && + triple.destinationId === destinationFm.nodeTypeId, + ); + if (!triple) return; + const resourceIds = [relation.id, relation.type, triple.id]; + const myGroups = await getAvailableGroups(client); + const targetGroups = intersection( + new Set(myGroups), + intersection( + new Set(sourceGroups), + new Set(destinationGroups), + ), + ); + if (!targetGroups.size) return; + const entries = []; + for (const group of targetGroups) { + for (const id of resourceIds) { + entries.push({ + /* eslint-disable @typescript-eslint/naming-convention */ + account_uid: group, + source_local_id: id, + space_id: context.spaceId, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + } + } + const publishResponse = await client + .from("ResourceAccess") + .upsert(entries, { ignoreDuplicates: true }); + if (publishResponse.error && publishResponse.error.code !== "23505") + throw publishResponse.error; +}; + export const publishNodeRelations = async ({ plugin, client, @@ -155,13 +228,7 @@ export const publishNode = async ({ }): Promise => { const client = await getLoggedInClient(plugin); if (!client) throw new Error("Cannot get client"); - const myGroupsResponse = await client - .from("group_membership") - .select("group_id"); - if (myGroupsResponse.error) throw myGroupsResponse.error; - const myGroups = new Set( - myGroupsResponse.data.map(({ group_id }) => group_id), - ); + const myGroups = new Set(await getAvailableGroups(client)); if (myGroups.size === 0) throw new Error("Cannot get group"); const existingPublish = (frontmatter.publishedToGroups as undefined | string[]) || []; diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index f3db77f8c..705fc2c80 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -6,6 +6,7 @@ import { checkAndCreateFolder } from "~/utils/file"; import { getVaultId } from "./supabaseContext"; import type { RelationInstance } from "~/types"; import { QueryEngine } from "~/services/QueryEngine"; +import { publishNewRelation } from "./publishNode"; const RELATIONS_FILE_NAME = "relations.json"; const RELATIONS_FILE_VERSION = 1; @@ -115,6 +116,12 @@ export const addRelationNoCheck = async ( const data = await loadRelations(plugin); data.relations[id] = instance; await saveRelations(plugin, data); + try { + await publishNewRelation(plugin, instance); + } catch (error) { + console.error(error); + // do not fail adding the relation; but we need a way to look at this later. + } return id; }; From 29c735628730a78d9f99c0b564986a198ab5a89a Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 27 Feb 2026 10:09:36 -0500 Subject: [PATCH 06/10] Sync relations before publishing new relation. Some renames and optimization --- apps/obsidian/src/utils/publishNode.ts | 3 + apps/obsidian/src/utils/registerCommands.ts | 5 +- .../src/utils/syncDgNodesToSupabase.ts | 69 +++++++------------ 3 files changed, 29 insertions(+), 48 deletions(-) diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 6e2a69215..8bc0af1f9 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -13,6 +13,7 @@ import { } from "./relationsStore"; import type { RelationInstance } from "~/types"; import { getAvailableGroups } from "./importNodes"; +import { syncAllNodesAndRelations } from "./syncDgNodesToSupabase"; const publishSchema = async ({ client, @@ -116,6 +117,8 @@ export const publishNewRelation = async ( ), ); if (!targetGroups.size) return; + // in that case, sync all relations (only) before publishing + await syncAllNodesAndRelations(plugin, context, true); const entries = []; for (const group of targetGroups) { for (const id of resourceIds) { diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 00accd707..394ede96b 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -8,12 +8,11 @@ import { createDiscourseNode } from "./createNode"; import { refreshAllImportedFiles } from "./importNodes"; import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import { createCanvas } from "~/components/canvas/utils/tldraw"; -import { createOrUpdateDiscourseEmbedding } from "./syncDgNodesToSupabase"; +import { syncAllNodesAndRelations } from "./syncDgNodesToSupabase"; import { publishNode } from "./publishNode"; import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUtils"; import type { DiscourseNode } from "~/types"; - type ModifyNodeSubmitParams = { nodeType: DiscourseNode; title: string; @@ -234,7 +233,7 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { return false; } if (!checking) { - void createOrUpdateDiscourseEmbedding(plugin) + void syncAllNodesAndRelations(plugin) .then(() => { new Notice("Discourse nodes synced successfully", 3000); }) diff --git a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts index 7a2ec59a5..589550659 100644 --- a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts +++ b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts @@ -403,41 +403,13 @@ const buildChangedNodesFromNodes = async ({ return changedNodes; }; -/** - * Get all discourse nodes that have changed compared to what's stored in Supabase. - * Detects what specifically changed: title, content, or new file - * - * Flow: - * 1. Collect all discourse nodes from vault - * 2. Query database for existing titles - * 3. Get last sync time for the space - * 4. For each node, detect what changed - * 5. Return only nodes that have changes - */ -const getChangedDiscourseNodes = async ({ - plugin, - supabaseClient, - context, -}: { - plugin: DiscourseGraphPlugin; - supabaseClient: DGSupabaseClient; - context: SupabaseContext; -}): Promise => { - const dgNodesInVault = await collectDiscourseNodesFromVault(plugin); - - return buildChangedNodesFromNodes({ - nodes: dgNodesInVault, - supabaseClient, - context, - }); -}; - -export const createOrUpdateDiscourseEmbedding = async ( +export const syncAllNodesAndRelations = async ( plugin: DiscourseGraphPlugin, supabaseContext?: SupabaseContext, + relationsOnly?: boolean, ): Promise => { try { - console.debug("Starting createOrUpdateDiscourseEmbedding"); + console.debug("Starting syncAllNodesAndRelations"); const context = supabaseContext ?? (await getSupabaseContext(plugin)); if (!context) { @@ -451,14 +423,18 @@ export const createOrUpdateDiscourseEmbedding = async ( } console.debug("Supabase client:", supabaseClient); - // Get all discourse nodes that have changed compared to what's stored in Supabase - const allNodeInstances = await getChangedDiscourseNodes({ - plugin, - supabaseClient, - context, - }); - console.log("allNodeInstances", allNodeInstances); - console.debug(`Found ${allNodeInstances.length} nodes to sync`); + const allNodes = await collectDiscourseNodesFromVault(plugin); + + const changedNodeInstances = relationsOnly + ? [] + : await buildChangedNodesFromNodes({ + nodes: allNodes, + supabaseClient, + context, + }); + + console.log("changedNodeInstances", changedNodeInstances); + console.debug(`Found ${changedNodeInstances.length} nodes to sync`); const accountLocalId = plugin.settings.accountLocalId; if (!accountLocalId) { @@ -466,7 +442,7 @@ export const createOrUpdateDiscourseEmbedding = async ( } await upsertNodesToSupabaseAsContentWithEmbeddings({ - obsidianNodes: allNodeInstances, + obsidianNodes: changedNodeInstances, supabaseClient, context, accountLocalId, @@ -474,19 +450,20 @@ export const createOrUpdateDiscourseEmbedding = async ( }); await convertDgToSupabaseConcepts({ - nodesSince: allNodeInstances, + nodesSince: changedNodeInstances, supabaseClient, context, accountLocalId, plugin, + allNodes, }); // When synced nodes are already published, ensure non-text assets are in storage. - await syncPublishedNodesAssets(plugin, allNodeInstances); + await syncPublishedNodesAssets(plugin, changedNodeInstances); console.debug("Sync completed successfully"); } catch (error) { - console.error("createOrUpdateDiscourseEmbedding: Process failed:", error); + console.error("syncAllNodesAndRelations: Process failed:", error); throw error; } }; @@ -497,12 +474,14 @@ const convertDgToSupabaseConcepts = async ({ context, accountLocalId, plugin, + allNodes, }: { nodesSince: ObsidianDiscourseNodeData[]; supabaseClient: DGSupabaseClient; context: SupabaseContext; accountLocalId: string; plugin: DiscourseGraphPlugin; + allNodes?: DiscourseNodeInVault[]; }): Promise => { const lastNodeSchemaSync = ( await getLastNodeSchemaSyncTime(supabaseClient, context.spaceId) @@ -516,7 +495,7 @@ const convertDgToSupabaseConcepts = async ({ const nodeTypes = plugin.settings.nodeTypes ?? []; const relationTypes = plugin.settings.relationTypes ?? []; const discourseRelations = plugin.settings.discourseRelations ?? []; - const allNodes = await collectDiscourseNodesFromVault(plugin); + allNodes = allNodes ?? (await collectDiscourseNodesFromVault(plugin)); const allNodesById = Object.fromEntries( allNodes.map((n) => [n.nodeInstanceId, n]), ); @@ -904,7 +883,7 @@ export const initializeSupabaseSync = async ( ); } - await createOrUpdateDiscourseEmbedding(plugin, context).catch((error) => { + await syncAllNodesAndRelations(plugin, context).catch((error) => { new Notice(`Initial sync failed: ${error}`); console.error("Initial sync failed:", error); }); From 6344326f2c2131f534da677be5047500d584105e Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 27 Feb 2026 11:31:00 -0500 Subject: [PATCH 07/10] AI corrections --- apps/obsidian/src/services/QueryEngine.ts | 4 ++++ apps/obsidian/src/utils/conceptConversion.ts | 3 ++- apps/obsidian/src/utils/publishNode.ts | 18 +++++++++++++++--- apps/obsidian/src/utils/relationsStore.ts | 2 +- apps/roam/src/utils/conceptConversion.ts | 4 +++- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 7e5922d72..25595be1e 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -94,6 +94,10 @@ export class QueryEngine { return null; } + if (!nodeInstanceId.match(/^[-.+\w]+$/)) { + console.error("Malformed id:", nodeInstanceId); + return null; + } try { const dcQuery = `@page and exists(nodeInstanceId) and nodeInstanceId = "${nodeInstanceId}"`; const potentialNodes = this.dc.query(dcQuery); diff --git a/apps/obsidian/src/utils/conceptConversion.ts b/apps/obsidian/src/utils/conceptConversion.ts index 238c5f7db..53b740533 100644 --- a/apps/obsidian/src/utils/conceptConversion.ts +++ b/apps/obsidian/src/utils/conceptConversion.ts @@ -288,6 +288,8 @@ const orderConceptsRec = ({ remainder: { [key: string]: LocalConceptDataInput }; processed: Set; }): Set => { + // Add to processed at the start to prevent cycles + processed.add(concept.source_local_id!); const relatedConceptIds = relatedConcepts(concept); let missing: Set = new Set(); while (relatedConceptIds.length > 0) { @@ -310,7 +312,6 @@ const orderConceptsRec = ({ } } ordered.push(concept); - processed.add(concept.source_local_id!); delete remainder[concept.source_local_id!]; return missing; }; diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 8bc0af1f9..4fff787b2 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -67,8 +67,8 @@ const publishSchema = async ({ }; const intersection = (set1: Set, set2: Set): Set => { - // @ts-ignore-error - if (set1.intersection) return set1.intersection(set2); + // @ts-expect-error - Set.intersection is ES2025 feature + if (set1.intersection) return set1.intersection(set2); // eslint-disable-line const r: Set = new Set(); for (const x of set1) { if (set2.has(x)) r.add(x); @@ -136,6 +136,13 @@ export const publishNewRelation = async ( .upsert(entries, { ignoreDuplicates: true }); if (publishResponse.error && publishResponse.error.code !== "23505") throw publishResponse.error; + relation.publishedToGroupId = [ + ...new Set([ + ...(relation.publishedToGroupId || []), + ...targetGroups.values(), + ]).values(), + ]; + return relation; }; export const publishNodeRelations = async ({ @@ -275,7 +282,12 @@ export const publishNodeToGroup = async ({ const lastModifiedDb = new Date( idResponse.data.last_modified + "Z", ).getTime(); - await publishNodeRelations({ plugin, client, nodeId, myGroup, spaceId }); + try { + await publishNodeRelations({ plugin, client, nodeId, myGroup, spaceId }); + } catch (error) { + // do not fail to publish node for that reason + console.error("Could not publish relations", error); + } const embeds = plugin.app.metadataCache.getFileCache(file)?.embeds ?? []; const attachments = embeds .map(({ link }) => { diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 705fc2c80..630b0ae72 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -115,13 +115,13 @@ export const addRelationNoCheck = async ( }; const data = await loadRelations(plugin); data.relations[id] = instance; - await saveRelations(plugin, data); try { await publishNewRelation(plugin, instance); } catch (error) { console.error(error); // do not fail adding the relation; but we need a way to look at this later. } + await saveRelations(plugin, data); return id; }; diff --git a/apps/roam/src/utils/conceptConversion.ts b/apps/roam/src/utils/conceptConversion.ts index 15037fef6..8ee98dae4 100644 --- a/apps/roam/src/utils/conceptConversion.ts +++ b/apps/roam/src/utils/conceptConversion.ts @@ -237,6 +237,9 @@ const orderConceptsRec = ({ remainder: { [key: string]: LocalConceptDataInput }; processed: Set; }): Set => { + // Add to processed at the start to prevent cycles + processed.add(concept.source_local_id!); + const relatedConceptIds = relatedConcepts(concept); let missing: Set = new Set(); while (relatedConceptIds.length > 0) { @@ -259,7 +262,6 @@ const orderConceptsRec = ({ } } ordered.push(concept); - processed.add(concept.source_local_id!); delete remainder[concept.source_local_id!]; return missing; }; From f1f9de54999332b4b984e815f6f160c574bc8fdf Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 27 Feb 2026 11:34:42 -0500 Subject: [PATCH 08/10] linting --- apps/obsidian/src/utils/publishNode.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 4fff787b2..c8162b6c3 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -96,8 +96,10 @@ export const publishNewRelation = async ( plugin.app.metadataCache.getFileCache(destinationFile)?.frontmatter; if (!sourceFm || !destinationFm) return; - const sourceGroups = sourceFm.publishedToGroups; - const destinationGroups = destinationFm.publishedToGroups; + const sourceGroups = sourceFm.publishedToGroups as string[] | undefined; + const destinationGroups = destinationFm.publishedToGroups as + | string[] + | undefined; if (!Array.isArray(sourceGroups) || !Array.isArray(destinationGroups)) return; const relationTriples = plugin.settings.discourseRelations ?? []; const triple = relationTriples.find( From ae6c6f697ac7e9738ae42be614d45d1de6d202ef Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 27 Feb 2026 18:53:08 -0500 Subject: [PATCH 09/10] rename function --- apps/obsidian/src/components/ImportNodesModal.tsx | 4 ++-- apps/obsidian/src/utils/importNodes.ts | 2 +- apps/obsidian/src/utils/publishNode.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx index bd46c1309..f926a3e16 100644 --- a/apps/obsidian/src/components/ImportNodesModal.tsx +++ b/apps/obsidian/src/components/ImportNodesModal.tsx @@ -4,7 +4,7 @@ import { StrictMode, useState, useEffect, useCallback } from "react"; import type DiscourseGraphPlugin from "../index"; import type { ImportableNode, GroupWithNodes } from "~/types"; import { - getAvailableGroups, + getAvailableGroupIds, getPublishedNodesForGroups, getLocalNodeInstanceIds, getSpaceNameFromIds, @@ -45,7 +45,7 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => { return; } - const groupIds = await getAvailableGroups(client); + const groupIds = await getAvailableGroupIds(client); if (groupIds.length === 0) { new Notice("You are not a member of any groups"); onClose(); diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 21a035cd5..8169bb1b5 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -9,7 +9,7 @@ import type { DiscourseNode, ImportableNode } from "~/types"; import { QueryEngine } from "~/services/QueryEngine"; import { spaceUriAndLocalIdToRid, ridToSpaceUriAndLocalId } from "./rid"; -export const getAvailableGroups = async ( +export const getAvailableGroupIds = async ( client: DGSupabaseClient, ): Promise => { const { data, error } = await client diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index c8162b6c3..1d1fac043 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -12,7 +12,7 @@ import { saveRelations, } from "./relationsStore"; import type { RelationInstance } from "~/types"; -import { getAvailableGroups } from "./importNodes"; +import { getAvailableGroupIds } from "./importNodes"; import { syncAllNodesAndRelations } from "./syncDgNodesToSupabase"; const publishSchema = async ({ @@ -110,7 +110,7 @@ export const publishNewRelation = async ( ); if (!triple) return; const resourceIds = [relation.id, relation.type, triple.id]; - const myGroups = await getAvailableGroups(client); + const myGroups = await getAvailableGroupIds(client); const targetGroups = intersection( new Set(myGroups), intersection( @@ -240,7 +240,7 @@ export const publishNode = async ({ }): Promise => { const client = await getLoggedInClient(plugin); if (!client) throw new Error("Cannot get client"); - const myGroups = new Set(await getAvailableGroups(client)); + const myGroups = new Set(await getAvailableGroupIds(client)); if (myGroups.size === 0) throw new Error("Cannot get group"); const existingPublish = (frontmatter.publishedToGroups as undefined | string[]) || []; From 2a23e5af2641189ba1a353a45e0ebcaaf88582b3 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 27 Feb 2026 19:01:21 -0500 Subject: [PATCH 10/10] Devin: two-stage save --- apps/obsidian/src/utils/publishNode.ts | 15 ++++++++------- apps/obsidian/src/utils/relationsStore.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/obsidian/src/utils/publishNode.ts b/apps/obsidian/src/utils/publishNode.ts index 1d1fac043..fe6c538a0 100644 --- a/apps/obsidian/src/utils/publishNode.ts +++ b/apps/obsidian/src/utils/publishNode.ts @@ -79,7 +79,7 @@ const intersection = (set1: Set, set2: Set): Set => { export const publishNewRelation = async ( plugin: DiscourseGraphPlugin, relation: RelationInstance, -) => { +): Promise => { const client = await getLoggedInClient(plugin); if (!client) throw new Error("Cannot get client"); const context = await getSupabaseContext(plugin); @@ -89,18 +89,19 @@ export const publishNewRelation = async ( plugin, relation.destination, ); - if (!sourceFile || !destinationFile) return; + if (!sourceFile || !destinationFile) return false; const sourceFm = plugin.app.metadataCache.getFileCache(sourceFile)?.frontmatter; const destinationFm = plugin.app.metadataCache.getFileCache(destinationFile)?.frontmatter; - if (!sourceFm || !destinationFm) return; + if (!sourceFm || !destinationFm) return false; const sourceGroups = sourceFm.publishedToGroups as string[] | undefined; const destinationGroups = destinationFm.publishedToGroups as | string[] | undefined; - if (!Array.isArray(sourceGroups) || !Array.isArray(destinationGroups)) return; + if (!Array.isArray(sourceGroups) || !Array.isArray(destinationGroups)) + return false; const relationTriples = plugin.settings.discourseRelations ?? []; const triple = relationTriples.find( (triple) => @@ -108,7 +109,7 @@ export const publishNewRelation = async ( triple.sourceId === sourceFm.nodeTypeId && triple.destinationId === destinationFm.nodeTypeId, ); - if (!triple) return; + if (!triple) return false; const resourceIds = [relation.id, relation.type, triple.id]; const myGroups = await getAvailableGroupIds(client); const targetGroups = intersection( @@ -118,7 +119,7 @@ export const publishNewRelation = async ( new Set(destinationGroups), ), ); - if (!targetGroups.size) return; + if (!targetGroups.size) return false; // in that case, sync all relations (only) before publishing await syncAllNodesAndRelations(plugin, context, true); const entries = []; @@ -144,7 +145,7 @@ export const publishNewRelation = async ( ...targetGroups.values(), ]).values(), ]; - return relation; + return true; }; export const publishNodeRelations = async ({ diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 630b0ae72..e859f3c8f 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -115,13 +115,18 @@ export const addRelationNoCheck = async ( }; const data = await loadRelations(plugin); data.relations[id] = instance; + // save so it can be synced if needed + await saveRelations(plugin, data); try { - await publishNewRelation(plugin, instance); + const published = await publishNewRelation(plugin, instance); + if (published) { + // save again with publication data + await saveRelations(plugin, data); + } } catch (error) { console.error(error); // do not fail adding the relation; but we need a way to look at this later. } - await saveRelations(plugin, data); return id; };