Skip to content

ENG-1249 Add a cached relations index and link resolution - #1413

Closed
trangdoan982 wants to merge 4 commits into
mainfrom
eng-1249-relations-index
Closed

ENG-1249 Add a cached relations index and link resolution#1413
trangdoan982 wants to merge 4 commits into
mainfrom
eng-1249-relations-index

Conversation

@trangdoan982

@trangdoan982 trangdoan982 commented Sep 7, 2026

Copy link
Copy Markdown
Member

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

https://www.loom.com/share/2f5badd2168546dba687e2844ebc2d46

First of three stacked PRs for ENG-1249. Stack: this#1414#1415.

Reviewer brief

Result: No user-visible change. This adds the data layer the overlay needs; nothing consumes it yet.

Review focus: the concurrency rules in RelationsIndex, and what the relation count deliberately excludes. Both are expanded below.

Why an index exists at all — the cost argument

getRelationsForFile reads and parses the whole of relations.json, then scans every relation in it:

flowchart LR
  A["vault.read relations.json"] --> B["JSON.parse — 50 relations"]
  B --> C["Object.values .filter"]
Loading

That is fine for the Discourse Context panel, which asks once per file open. The overlay asks once per discourse-node link on screen, per viewport update. On a note with 40 such links that is 40 file reads and 40 parses to answer 40 questions about one document — on every keystroke.

RelationsIndex keeps a parsed snapshot grouped by endpoint id, so a lookup is a Map hit and answers synchronously — which matters as much as speed, because a CodeMirror ViewPlugin cannot await anything while building decorations.

flowchart LR
  subgraph store["relations.json"]
    R1["r1: a to b"]
    R2["r2: c to a"]
    R3["r3: a to a"]
  end
  store --> IDX["endpoint index<br>a → r1, r2, r3<br>b → r1<br>c → r2"]
  IDX --> Q["get('a') → 3<br>O(1), no await"]
Loading

Each relation is filed under both endpoints, so "relations touching X" is one lookup regardless of direction. A self-relation like r3 is filed once, not twice, so one endpoint never yields it twice.

The concurrency rules — each one is load-bearing
Rule Why it exists
Generation counter A load that began before an invalidation is stale when it resolves and must not overwrite a newer snapshot. Not exotic: writing a relation modifies the file while a read may be in flight.
inFlight cleared in a finally If a superseded load returns early without clearing it, ensureLoaded hands out an already-settled promise forever — the snapshot stays stale and every later read requests a load that never runs.
Reads never schedule loads A read that triggers a load triggers a notify → re-render → read. Loading belongs to initialize() and invalidate() only.
Invalidation keeps the old snapshot Dropping it would flash every badge to 0 until the reload lands — on the very action that triggered it, since saving a relation writes relations.json.
A version counter So a ViewPlugin, which can only observe transactions, can detect that counts changed. Used by #1414.
What the count excludes, and why it must

"How many relations does this node have?" is ambiguous. A node in the test vault has 6 relations in relations.json; the panel lists 4.

Relations Type still configured? Panel lists it?
r1, r2 no — the type was deleted no
r3–r6 yes yes

Deleting a relation type does not delete its relations; they stay as orphans and the panel drops them while grouping by type. Imported relations awaiting acceptance (tentative === false) are likewise listed separately.

[!IMPORTANT]
The badge counts what the panel would list, not what the store holds. A badge reading 6 above a popover listing 4 advertises context the next click refuses to show. An earlier revision had exactly that bug.

Verification

  • pnpm install --frozen-lockfile + pnpm ci:validate: 8/8 check-types, 5/5 test:unit.
  • Loaded in a real vault over CDP: the index initializes, reports the expected relation count for a known node, and renders no UI.

Scope check

  • Ran $scope-check against the ENG ticket and final diff.
  • Scope beyond Done When: None. Groundwork for the ticket's overlay requirement.

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context.

Review findings addressed here: relations.json renames now invalidate the snapshot, and markdown links with a #heading subpath now match. One finding is open by design — see the thread on discourseLinkFrontmatter.ts.

🤖 Generated with Claude Code

Reading relations from disk costs a full vault file read plus a JSON
parse per call. That is fine for the Discourse Context panel, which asks
once per file open, but not for anything that renders per link.

RelationsIndex keeps a parsed snapshot grouped by endpoint id so a render
path can ask synchronously, rebuilt from vault events so it covers our
own writes and edits arriving over sync alike.

resolveDiscourseLinkTarget answers, for one link, whether it points at a
discourse node and how many relations that node has — from in-memory
caches only, avoiding getNodeTypeIdForFile, which polls 500ms waiting on
frontmatter.

Link parsing moves to internalLinkParsing, which wikilinkDragHandler had
its own byte-identical copy of.

Nothing consumes this yet; the overlay that does follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 7, 2026

Copy link
Copy Markdown

ENG-1249

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
discourse-graph Skipped Skipped Sep 8, 2026 9:30pm UTC

Request Review

@supabase

supabase Bot commented Sep 7, 2026

Copy link
Copy Markdown

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 ↗︎.

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

Copy link
Copy Markdown
Contributor

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.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +15 to +20
const nodeInstanceId = asString(frontmatter?.nodeInstanceId);
const importedFromRid = asString(frontmatter?.importedFromRid);

if (nodeInstanceId) endpointIds.push(nodeInstanceId);
if (importedFromRid && importedFromRid !== nodeInstanceId) {
endpointIds.push(importedFromRid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Imported local relations disappear

Accepted imported relations vanish from local-node counts because getEndpointIdsFromFrontmatter omits the local node's canonical RID. The importer stores local endpoints under that RID in getLocalNodeKeyToEndpointId.

Prompt for agents
The link resolver must query every endpoint representation used by relations.json. apps/obsidian/src/utils/importRelations.ts stores existing local-node endpoints using the canonical local RID produced by getLocalNodeKeyToEndpointId, while getEndpointIdsFromFrontmatter currently returns only nodeInstanceId and importedFromRid. Update the resolution flow so local discourse nodes also query the canonical RID built from getLocalSpaceUri(plugin.app), nodeInstanceId, and spaceUriAndLocalIdToRid(..., "note"). Keep deduplication across representations and add coverage for accepted imported relations connected to a local node.
Devin Review

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not fixing here, deliberately — flagging why rather than resolving silently.

You're right that importRelationsForImportedNodes can store a local node's endpoint as the canonical RID from getLocalNodeKeyToEndpointId, and that getEndpointIdsFromFrontmatter doesn't query it.

But the same gap already exists in getRelationsForFile (relationsStore.ts), which is what the Discourse Context panel uses — it also matches only nodeInstanceId and importedFromRid. So this is pre-existing in the data layer, not introduced here.

That matters because the overlay's contract is that its count equals what the panel will list; an earlier revision of this work had the badge over-count and it was confusing precisely because the popover then showed fewer rows. Adding the canonical RID only on the badge side would recreate that inconsistency in the other direction.

It should be fixed in getRelationsForFile so both surfaces gain it together, which is a separate change with its own test surface. I checked this vault: no relation endpoint currently resolves to a local non-imported node this way, so nothing is being dropped today.

Comment thread apps/obsidian/src/utils/relationsIndex.ts
Comment thread apps/obsidian/src/utils/internalLinkParsing.ts Outdated
Two review findings:

Renaming relations.json out of the root, or another file into it, left the
snapshot stale; only modify/create/delete were watched.

Markdown links with a #heading or #^block subpath never matched, since the
pattern required .md immediately before the closing paren. Wikilinks with
subpaths already worked, so the two forms behaved differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread apps/obsidian/src/utils/relationsIndex.ts
Review finding: an invalidation arriving mid-read starts a newer load, but
the superseded load's finally cleared inFlight unconditionally, discarding
the newer load's tracking. Later callers then saw no load in flight and
started redundant ones.

Clearing is now gated on the generation still matching, which is the same
signal that decides whether the load's result is worth keeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@mdroidian mdroidian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@trangdoan982 I'm quite confused with this PR.

First off, there are merge conflicts, so let's resolve those.

Second, I didn't recall a "add cached relations" need/ticket, so I opened ENG-1249 to learn more, and it reads "Implement Discourse context overlay feature in Obsidian". And the first line of the ticket says: "This issue has been converted to a project Discourse context overlay feature in Obsidian"

That ticket also has 4 attached PRs.

Image

Would you mind sorting that out, then requesting for a review again.

@trangdoan982
trangdoan982 deleted the eng-1249-relations-index branch September 11, 2026 03:21
@trangdoan982

Copy link
Copy Markdown
Member Author

Superseded by #1433. Closed automatically when its head branch was renamed to eng-2248-add-a-cached-relations-index-and-link-resolution to match its new ticket, ENG-2248. Same commits, same body.

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