Skip to content

[ENG-1391] Migrate relations into vault relations.json as objects#748

Merged
trangdoan982 merged 5 commits intomainfrom
eng-1391-migrate-relation-creation-existing-relation-into
Feb 8, 2026
Merged

[ENG-1391] Migrate relations into vault relations.json as objects#748
trangdoan982 merged 5 commits intomainfrom
eng-1391-migrate-relation-creation-existing-relation-into

Conversation

@trangdoan982
Copy link
Collaborator

@trangdoan982 trangdoan982 commented Feb 5, 2026

https://www.loom.com/share/a87c77fe07034cce9daf5fd4b55ab2e3


Open with Devin

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced a dedicated relations store for persistent relationship management, replacing frontmatter-based storage.
    • Automatic migration of existing frontmatter relations to the new relations store on plugin initialization.
    • Enhanced relationship tracking with versioning for improved consistency.
  • Refactor

    • Restructured relationship management architecture to use a centralized JSON store instead of frontmatter links.

@linear
Copy link

linear bot commented Feb 5, 2026

@supabase
Copy link

supabase bot commented Feb 5, 2026

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@trangdoan982 trangdoan982 changed the title [ENG-1391] Migrate relations into vault [ENG-1391] Migrate relations into vault relations.json as objects Feb 5, 2026
@trangdoan982 trangdoan982 marked this pull request as ready for review February 5, 2026 05:13
@trangdoan982
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

✅ Actions performed

Full review triggered.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

This pull request refactors relation persistence from a frontmatter-based system to a dedicated relations.json store, introducing node-instance IDs for node identification. Components throughout the canvas and relationship UI layers are updated to use the new relation store APIs and async operations, with legacy frontmatter integration removed and replaced by a migration pathway.

Changes

Cohort / File(s) Summary
Relation Store Implementation
apps/obsidian/src/utils/relationsStore.ts, apps/obsidian/src/components/canvas/utils/relationJsonUtils.ts
New relation persistence layer with relations.json file storage, supporting add/remove/query operations by node-instance ID; new addRelationToRelationsJson utility wraps relation store calls and returns relationInstanceId.
Node Instance ID Management
apps/obsidian/src/utils/nodeInstanceId.ts, apps/obsidian/src/utils/syncDgNodesToSupabase.ts
Extracted ensureNodeInstanceId utility to dedicated module; refactored syncDgNodesToSupabase to use external utility instead of local implementation.
Relationship UI Components
apps/obsidian/src/components/RelationshipSection.tsx
Updated RelationshipSection to coordinate via relationsVersion counter; AddRelationship now accepts onRelationsChange callback; CurrentRelationships consumes relationsVersion prop to trigger reloads; replaced frontmatter-based link manipulation with relation store calls.
Canvas Relation Components
apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx, apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx
RelationPanel refactored to make computeRelations async and use node-instance relations instead of frontmatter parsing; DiscourseRelationShape updated to persist relations via addRelationToRelationsJson instead of frontmatter, storing relationInstanceId in shape metadata.
Canvas Infrastructure
apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Minor import addition of DiscourseNodeUtil from DiscourseNodeShape module; extraneous blank line removed.
Frontmatter Integration Removal
apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts
Deleted entire module exporting addRelationToFrontmatter; functionality replaced by relation store persistence.
Plugin Initialization
apps/obsidian/src/index.ts
Added call to migrateFrontmatterRelationsToRelationsJson during plugin initialization to migrate existing frontmatter relations to relations.json with error handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • [ENG-603] Add existing relations #412 — Adds RelationsOverlay/RelationPanel infrastructure and relation type helpers that are now consumed by the refactored RelationPanel component using async computeRelations and node-instance IDs.
  • Tldraw obsidian #406 — Implements canvas relation shapes and frontmatter relation utilities that are being replaced by the new relations store and node-instance ID–based approach in this PR.
  • [ENG-599] Discourse node shape #341 — Adds DiscourseNodeUtil and canvas shape infrastructure that TldrawViewComponent now imports alongside the updated shape integration.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title '[ENG-1391] Migrate relations into vault relations.json as objects' clearly and specifically describes the main change: migrating relations into a vault file (relations.json) with object-based storage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx`:
- Around line 427-447: The current code always calls addRelation which can
create duplicate relation records; instead, after resolving files with
resolveLinkedFileFromSrc and obtaining sourceId/destId via
getNodeInstanceIdForFile, first check for an existing relation instance that
matches relationTypeId, sourceId and destId (e.g., via the relations store or a
findRelation helper), and if found use its id, otherwise call addRelation;
finally call editor.updateShape to set shape.meta.relationInstanceId to the
existing or newly created id (update the same editor.updateShape/shape.meta
usage).

In `@apps/obsidian/src/components/canvas/utils/relationJsonUtils.ts`:
- Around line 37-46: The helper currently only queries forward-direction
duplicates using findRelationBySourceDestinationType(data, sourceId, destId,
relationTypeId) which allows reversed edges to be added; update the guard to
also check for a reverse-direction match by calling
findRelationBySourceDestinationType(data, destId, sourceId, relationTypeId) (or
extend findRelationBySourceDestinationType to accept a flag for bidirectional
checks), and if either call returns a result return { alreadyExisted: true,
relationInstanceId: existing.id } (use the matching instance's id); keep using
loadRelations and preserve existing return shape.

In `@apps/obsidian/src/components/RelationshipSection.tsx`:
- Around line 213-226: Before calling addRelation, query existing relations
between the resolved node instance IDs and the chosen relation type and skip or
reuse the existing relation ID if found; specifically after resolving sourceId
and destId via getNodeInstanceIdForFile(plugin, activeFile) and
getNodeInstanceIdForFile(plugin, selectedNode), call the repository/query method
that lists relations for a node (or a helper you already have) to check for an
existing relation with type === selectedRelationType and source === sourceId and
destination === destId, and only call addRelation(plugin, { type:
selectedRelationType, source: sourceId, destination: destId }) when no matching
relation exists (or return/reuse the existing relation’s ID instead of inserting
a duplicate).

In `@apps/obsidian/src/utils/relationsStore.ts`:
- Around line 322-334: The code currently calls
removeRelationLinkFromFrontmatter(...) while migrated relations are only
in-memory and then calls saveRelations(plugin, data) afterwards, risking data
loss if saving fails; fix by persisting relations before mutating frontmatter:
either (A) after updating data and setting data.lastModified call await
saveRelations(plugin, data) immediately (or inside the per-file loop) and only
then call removeRelationLinkFromFrontmatter(...) for that file, or (B) if you
prefer batching, defer all removeRelationLinkFromFrontmatter(...) calls until
after a successful await saveRelations(plugin, data) so frontmatter cleanup
happens only after relations are safely saved.
🧹 Nitpick comments (8)
apps/obsidian/src/utils/nodeInstanceId.ts (1)

9-13: Use named parameters for ensureNodeInstanceId.

This function takes 3 parameters; switching to object destructuring avoids call-site mix‑ups as the API expands.

♻️ Proposed refactor
-export const ensureNodeInstanceId = async (
-  plugin: DiscourseGraphPlugin,
-  file: TFile,
-  frontmatter: Record<string, unknown>,
-): Promise<string> => {
+export const ensureNodeInstanceId = async ({
+  plugin,
+  file,
+  frontmatter,
+}: {
+  plugin: DiscourseGraphPlugin;
+  file: TFile;
+  frontmatter: Record<string, unknown>;
+}): Promise<string> => {

As per coding guidelines: Use named parameters (object destructuring) when a function has more than 2 parameters.

apps/obsidian/src/index.ts (1)

42-44: Consider persisting a migration flag/version to avoid re-scanning the vault on every load.

Once migration succeeds, storing a “completed” marker (settings or relations.json version) would keep startup fast in large vaults.

apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx (1)

1213-1227: Update naming/logging to reflect relations.json persistence (not frontmatter).

The method name and error message still reference frontmatter even though persistence moved to relations.json; renaming helps avoid confusion when debugging.

apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx (1)

562-567: Cache node-instance → file resolution to avoid repeated vault scans.

getFileForNodeInstanceId scans all markdown files each call; doing this inside the loop makes relation loading scale poorly. Consider building a map once per computeRelations call.

apps/obsidian/src/components/RelationshipSection.tsx (2)

31-34: Add explicit return types to component functions.

♻️ Suggested typings
-const AddRelationship = ({
+const AddRelationship = ({
   activeFile,
   onRelationsChange,
-}: AddRelationshipProps) => {
+}: AddRelationshipProps): JSX.Element => {
-const CurrentRelationships = ({
+const CurrentRelationships = ({
   activeFile,
   relationsVersion,
-}: CurrentRelationshipsProps) => {
+}: CurrentRelationshipsProps): JSX.Element | null => {
-export const RelationshipSection = ({
+export const RelationshipSection = ({
   activeFile,
-}: RelationshipSectionProps) => {
+}: RelationshipSectionProps): JSX.Element => {

As per coding guidelines: Use explicit return types for functions in TypeScript.

Also applies to: 351-354, 534-552


405-413: Consider caching getFileForNodeInstanceId lookups in loadCurrentRelationships.

That helper scans the vault each call; building a one-time map per load avoids O(N*M) behavior as relations grow.

apps/obsidian/src/utils/relationsStore.ts (2)

97-118: Consider documenting the duplicate-handling expectation.

addRelation does not check for duplicates (same source/destination/type), which is fine since migrateFrontmatterRelationsToRelationsJson handles this. However, other callers might inadvertently create duplicates.

Consider adding a JSDoc comment clarifying that callers are responsible for deduplication, or optionally add a check:

📝 Optional: Add documentation or duplicate check
+/**
+ * Adds a new relation to the store. Does not check for duplicates—callers
+ * should use findRelationBySourceDestinationType beforehand if needed.
+ */
 export const addRelation = async (
   plugin: DiscourseGraphPlugin,
   params: AddRelationParams,
 ): Promise<string> => {

161-175: Potential performance concern for large vaults.

This function performs a linear scan through all markdown files. For vaults with thousands of files, repeated calls could become slow.

If this function is called frequently (e.g., when rendering multiple relations), consider building an in-memory index of nodeInstanceId → TFile that's updated on file changes.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +299 to +303
const sourceNodeInstanceId = await ensureNodeInstanceId(
plugin,
file,
frontmatter as Record<string, unknown>,
);

Choose a reason for hiding this comment

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

🔴 Migration can generate duplicate nodeInstanceIds for the same file due to stale cache

The migrateFrontmatterRelationsToRelationsJson function can generate multiple different nodeInstanceId values for the same file, causing created relations to reference invalid/orphaned IDs.

Root Cause

When the migration iterates through files, it calls ensureNodeInstanceId which:

  1. Checks the passed frontmatter object for an existing nodeInstanceId
  2. If not found, generates a new UUID and writes it to the file

The bug occurs because:

  • When processing file A that links to file B, ensureNodeInstanceId is called for B (line 324-328), generating and writing id-B1
  • Later when processing file B itself (line 299-303), the frontmatter is read from metadataCache.getFileCache(file) which may be stale (not yet reflecting the previous write)
  • Since the stale frontmatter has no nodeInstanceId, a NEW id id-B2 is generated and written, overwriting id-B1
  • The relation created earlier with destination id-B1 now points to a non-existent node

Impact: Relations created during migration may reference orphaned node IDs, effectively losing the relationship data. This is a data corruption issue that occurs silently during plugin initialization.

Example scenario:

// File A links to B, File B links to A
// Processing A: ensureNodeInstanceId(A) -> "id-A", ensureNodeInstanceId(B) -> "id-B1"
// Relation added: id-A -> id-B1
// Processing B: cache for B is stale, ensureNodeInstanceId(B) -> "id-B2" (overwrites!)
// Now relation id-A -> id-B1 is orphaned
Prompt for agents
Fix the migration function to track already-generated nodeInstanceIds in memory to avoid generating duplicates. Add a Map<string, string> (file.path -> nodeInstanceId) at the start of migrateFrontmatterRelationsToRelationsJson. Before calling ensureNodeInstanceId, check if we already have an ID for that file path in our map. If so, use the cached ID. If not, call ensureNodeInstanceId and store the result in the map. This ensures each file gets exactly one nodeInstanceId regardless of how many times it's encountered during migration (as source or as target of a link).
Open in Devin Review

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

@trangdoan982 trangdoan982 requested a review from maparent February 5, 2026 17:05
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 9 additional findings in Devin Review.

Open in Devin Review

Comment on lines 237 to 241
await addRelation(plugin, {
type: selectedRelationType,
source: sourceId,
destination: destId,
});

Choose a reason for hiding this comment

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

🔴 Relation direction ignored when adding relationship - always uses activeFile as source

When adding a new relation in RelationshipSection.tsx, the code ignores the isSource flag from the selected relation type option. The addRelationship function always stores the relation with activeFile as source and selectedNode as destination (lines 237-241), regardless of whether the user selected a "complement" relation type where the direction should be reversed.

Root Cause

The dropdown selection only stores the relation type ID (option.id) at line 287, losing the isSource information:

onSelect={(option) => option && setSelectedRelationType(option.id)}

Then in addRelationship, the relation is always created with the same direction:

await addRelation(plugin, {
  type: selectedRelationType,
  source: sourceId,  // always activeFile
  destination: destId,  // always selectedNode
});

Compare this to RelationPanel.tsx:366-367 which correctly handles direction:

const sourceFile = isSource ? currentFile : targetFile;
const destFile = isSource ? targetFile : currentFile;

Impact: When a user selects a complement relation (e.g., "is supported by" instead of "supports"), the relation is stored with incorrect source/destination. This causes relations to display incorrectly when viewed from either node, and the semantic meaning of the relation is inverted.

Prompt for agents
In apps/obsidian/src/components/RelationshipSection.tsx, the AddRelationship component needs to track the isSource flag when the user selects a relation type. 

1. Add state to track the selected isSource: add useState for selectedIsSource similar to selectedRelationType

2. Update the DropdownSelect onSelect handler at line 287 to also store the isSource value:
   onSelect={(option) => {
     if (option) {
       setSelectedRelationType(option.id);
       setSelectedIsSource(option.isSource);
     }
   }}

3. In the addRelationship function around lines 237-241, use selectedIsSource to determine the correct source and destination:
   const actualSource = selectedIsSource ? sourceId : destId;
   const actualDest = selectedIsSource ? destId : sourceId;
   await addRelation(plugin, {
     type: selectedRelationType,
     source: actualSource,
     destination: actualDest,
   });

4. Add selectedIsSource to the useCallback dependency array.

5. Reset selectedIsSource in resetState function.
Open in Devin Review

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

Copy link
Collaborator

@maparent maparent left a comment

Choose a reason for hiding this comment

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

Some changes, but overall we'll be good to go.

@trangdoan982 trangdoan982 requested a review from maparent February 7, 2026 05:30
@trangdoan982 trangdoan982 merged commit 6adfcd0 into main Feb 8, 2026
5 checks passed
@trangdoan982 trangdoan982 deleted the eng-1391-migrate-relation-creation-existing-relation-into branch February 8, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants