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
12 changes: 12 additions & 0 deletions apps/obsidian/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export type DiscourseRelation = {
modified: number;
};

export type RelationInstance = {
id: string;
type: string;
source: string;
destination: string;
created: number;
author: string;
lastModified?: number;
importedFromSpaceId?: number;
publishedToGroupId?: string[];
};

export type Settings = {
nodeTypes: DiscourseNode[];
discourseRelations: DiscourseRelation[];
Expand Down
147 changes: 146 additions & 1 deletion apps/obsidian/src/utils/conceptConversion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { TFile } from "obsidian";
import type { DiscourseNode } from "~/types";
import type {
DiscourseNode,
DiscourseRelation,
DiscourseRelationType,
RelationInstance,
} from "~/types";
import type { SupabaseContext } from "./supabaseContext";
import type { DiscourseNodeInVault } from "./syncDgNodesToSupabase";
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase";
import type { Json } from "@repo/database/dbTypes";
Expand Down Expand Up @@ -53,6 +59,78 @@ export const discourseNodeSchemaToLocalConcept = ({
};
};

const STANDARD_ROLES = ["source", "destination"];

export const discourseRelationTypeToLocalConcept = ({
context,
relationType,
accountLocalId,
}: {
context: SupabaseContext;
relationType: DiscourseRelationType;
accountLocalId: string;
}): LocalConceptDataInput => {
const { id, label, complement, created, modified, ...otherData } =
relationType;
return {
space_id: context.spaceId,
name: label,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 discourseRelationTypeToLocalConcept uses non-unique label as concept name, causing upsert failure and cascading errors

The new discourseRelationTypeToLocalConcept function sets name: label where label is the user-facing display label of a relation type (e.g., "Supports", "Opposes"). This can collide with the unique constraint concept_space_and_name_idx on (space_id, name) in the database.

Root Cause and Cascading Failure

The database enforces CREATE UNIQUE INDEX concept_space_and_name_idx ON public."Concept" (space_id, name); (packages/database/supabase/schemas/concept.sql:87). Node type schemas are created with name: name (the node type's name) in discourseNodeSchemaToLocalConcept (apps/obsidian/src/utils/conceptConversion.ts:47). The new relation type concepts use name: label at line 77.

If a relation type's label matches any node type's name (e.g., both named "Evidence"), or if two relation types share the same label, only one concept can be inserted. The SQL upsert_concepts function handles this with RETURN NEXT -1 (packages/database/supabase/schemas/concept.sql:379), so the relation type concept silently fails to insert.

The real problem is cascading: discourseRelationSchemaToLocalConcept at line 127 puts relation_type: relationshipTypeId in local_reference_content. When the SQL function _local_concept_to_db_concept processes this, it does SELECT cpt.id INTO STRICT ref_single_val FROM public."Concept" AS cpt WHERE cpt.source_local_id = ... (packages/database/supabase/schemas/concept.sql:316-318). Since the relation type concept was never inserted (due to the name collision), INTO STRICT throws an unhandled exception, aborting the entire upsert_concepts call and failing all remaining concepts in the batch.

Impact: Any name collision between a relation type label and a node type name (or between two relation type labels) causes the entire concept sync to fail, preventing all node and relation data from being uploaded to Supabase.

The Roam version avoids this by using name: getPageTitleByPageUid(relation.id) (apps/roam/src/utils/conceptConversion.ts:125), which yields a unique identifier.

Suggested change
name: label,
name: `${id}-${label}`,
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

source_local_id: id,
is_schema: true,
author_local_id: accountLocalId,
created: new Date(created).toISOString(),
last_modified: new Date(modified).toISOString(),
literal_content: {
label,
complement,
source_data: otherData,
} as unknown as Json,
};
};

export const discourseRelationSchemaToLocalConcept = ({
context,
relation,
accountLocalId,
nodeTypesById,
relationTypesById,
}: {
context: SupabaseContext;
relation: DiscourseRelation;
accountLocalId: string;
nodeTypesById: Record<string, DiscourseNode>;
relationTypesById: Record<string, DiscourseRelationType>;
}): LocalConceptDataInput => {
const { id, relationshipTypeId, sourceId, destinationId, created, modified } =
relation;
const sourceName = nodeTypesById[sourceId]?.name ?? sourceId;
const destinationName = nodeTypesById[destinationId]?.name ?? destinationId;
const relationType = relationTypesById[relationshipTypeId];
if (!relationType)
throw new Error(`missing relation type ${relationshipTypeId}`);
const { label, complement } = relationType;

return {
space_id: context.spaceId,
name: `${sourceName} -${label}-> ${destinationName}`,
source_local_id: id,
is_schema: true,
author_local_id: accountLocalId,
created: new Date(created).toISOString(),
last_modified: new Date(modified).toISOString(),
literal_content: {
roles: STANDARD_ROLES,
label,
complement,
},
local_reference_content: {
relation_type: relationshipTypeId,
source: sourceId,
destination: destinationId,
},
};
};

/**
* Convert discourse node instance (file) to LocalConceptDataInput
*/
Expand Down Expand Up @@ -81,6 +159,73 @@ export const discourseNodeInstanceToLocalConcept = ({
};
};

export const relationInstanceToLocalConcept = ({
context,
relationTypesById,
relationTriples,
allNodesById,
relationData,
}: {
context: SupabaseContext;
relationTypesById: Record<string, DiscourseRelationType>;
relationTriples: DiscourseRelation[];
allNodesById: Record<string, DiscourseNodeInVault>;
relationData: RelationInstance;
}): LocalConceptDataInput | null => {
const relationType = relationTypesById[relationData.type];
if (!relationType) {
console.error("Missing relation type " + relationData.type);
return null;
}
const sourceNode = allNodesById[relationData.source];
if (!sourceNode) {
console.error("Missing source node " + relationData.source);
return null;
}
const destinationNode = allNodesById[relationData.destination];
if (!destinationNode) {
console.error("Missing destination node " + relationData.destination);
return null;
}
const sourceTypeId = sourceNode.nodeTypeId;
const destinationTypeId = destinationNode.nodeTypeId;
const triples = relationTriples.filter(
(triple) =>
triple.relationshipTypeId == relationData.type &&
triple.sourceId === sourceTypeId &&
triple.destinationId === destinationTypeId,
);
if (triples.length === 0) {
console.error(
`Missing destination triple for ${sourceTypeId}-${relationData.type}->${destinationTypeId}`,
);
return null;
}
if (triples.length > 1) {
console.warn(
`Multiple triples for ${sourceTypeId}-${relationData.type}->${destinationTypeId}`,
);
}
const triple = triples[0]!;

return {
space_id: context.spaceId,
name: `[[${sourceNode.file.basename}]] -${relationType.label}-> [[${destinationNode.file.basename}]]`,
source_local_id: relationData.id,
author_local_id: relationData.author,
schema_represented_by_local_id: triple.id,
is_schema: false,
created: new Date(relationData.created).toISOString(),
last_modified: new Date(
relationData.lastModified || relationData.created,
).toISOString(),
local_reference_content: {
source: relationData.source,
destination: relationData.destination,
},
};
};

export const relatedConcepts = (concept: LocalConceptDataInput): string[] => {
const relations = Object.values(
concept.local_reference_content || {},
Expand Down
35 changes: 15 additions & 20 deletions apps/obsidian/src/utils/relationsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type DiscourseGraphPlugin from "~/index";
import { ensureNodeInstanceId } from "~/utils/nodeInstanceId";
import { checkAndCreateFolder } from "~/utils/file";
import { getVaultId } from "./supabaseContext";
import type { RelationInstance } from "~/types";

const RELATIONS_FILE_NAME = "relations.json";
const RELATIONS_FILE_VERSION = 1;
Expand All @@ -16,18 +17,6 @@ export const getRelationsFilePath = (plugin: DiscourseGraphPlugin): string => {
: normalizePath(RELATIONS_FILE_NAME);
};

export type RelationInstance = {
id: string;
type: string;
source: string;
destination: string;
created: number;
author: string;
lastModified?: number;
importedFromSpaceId?: number;
publishedToGroupId?: string[];
};

export type RelationsFile = {
version: number;
lastModified: number;
Expand Down Expand Up @@ -162,7 +151,7 @@ export const removeRelationById = async (
delete data.relations[relationInstanceId];
await saveRelations(plugin, data);
return true;
}
};

export const getRelationsForNodeInstanceId = async (
plugin: DiscourseGraphPlugin,
Expand All @@ -174,7 +163,7 @@ export const getRelationsForNodeInstanceId = async (
return Object.values(relations).filter(
(r) => r.source === nodeInstanceId || r.destination === nodeInstanceId,
);
}
};

const DEFAULT_CACHE_WAIT_MS = 500;
const CACHE_POLL_INTERVAL_MS = 30;
Expand Down Expand Up @@ -215,7 +204,7 @@ export const getNodeInstanceIdForFile = async (
return null;
}
return await ensureNodeInstanceId(plugin, file, frontmatter);
}
};

/**
* Returns the node type id from a file's frontmatter (nodeTypeId).
Expand Down Expand Up @@ -252,7 +241,7 @@ export const getFileForNodeInstanceId = async (
}
}
return null;
}
};

/**
* Find a relation instance by source, destination, and type. Returns the first match.
Expand Down Expand Up @@ -313,7 +302,11 @@ export const removeRelationBySourceDestinationType = async (
const data = await loadRelations(plugin);
let removed = 0;
for (const [id, r] of Object.entries(data.relations)) {
if (r.source === source && r.destination === destination && r.type === type) {
if (
r.source === source &&
r.destination === destination &&
r.type === type
) {
delete data.relations[id];
removed++;
}
Expand All @@ -322,7 +315,7 @@ export const removeRelationBySourceDestinationType = async (
await saveRelations(plugin, data);
}
return removed;
}
};

/**
* Returns true if the frontmatter link (e.g. "[[path]]" or "[[path.md]]") resolves to the same file as targetFile.
Expand All @@ -334,7 +327,9 @@ const frontmatterLinkPointsToFile = (
sourceFilePath: string,
targetFile: TFile,
): boolean => {
const match = String(linkStr).trim().match(/\[\[(.*?)\]\]/);
const match = String(linkStr)
.trim()
.match(/\[\[(.*?)\]\]/);
const linkpath = match?.[1]?.trim();
if (!linkpath) return false;
const resolved = plugin.app.metadataCache.getFirstLinkpathDest(
Expand Down Expand Up @@ -476,4 +471,4 @@ export const migrateFrontmatterRelationsToRelationsJson = async (
);
}
}
}
};
Loading