fix(storage): scope graph nodes per project (fix #78)#84
Open
serhiizghama wants to merge 2 commits into
Open
Conversation
Graph `nodes` are project-scoped data, but uniqueness was enforced globally on `name` and `upsertNode` fetched by `name` with no project filter. When two projects produced a node with the same name — e.g. a `main`/`master` branch node, which is the common case — the second project's `INSERT OR IGNORE` was dropped and the unscoped `SELECT` returned the first project's node id, so its records were linked to another project's entity. A `reset` of the first project then deleted the shared node, leaving the second project with dangling links. Make node uniqueness per project: - Schema migration V2 rebuilds `nodes` with `UNIQUE(project_id, name)` instead of the global `name UNIQUE`. Node ids (referenced by `record_entities`/`edges`) are preserved, so existing links survive. - `upsertNode` scopes its lookup with `AND project_id = ?`. Both halves are required: the SELECT fix alone can't work while the global UNIQUE still blocks the second project's insert. The rebuild drops the table that `record_entities`/`edges` reference, so `applyMigrations` now disables foreign keys around the migration per the SQLite schema-change procedure and re-checks integrity before re-enabling them — `PRAGMA foreign_keys` is a no-op inside a transaction, and `defer_foreign_keys` is insufficient because dropping a parent leaves a deferred-constraint count a later rename can't clear. Closes agentctxhq#78
- entities.test.ts: two projects sharing a node name get distinct ids, a record links to its own project's node, single-project upsert stays idempotent, and uniqueness holds per (project_id, name). - migrations.test.ts: the V1->V2 rebuild preserves node ids and their record links, re-enables foreign keys with no violations, and admits the same node name in a different project afterward.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Closes #78.
Graph
nodesare project-scoped data, but their uniqueness is enforced globally onname, andupsertNodefetches bynamewith no project filter:When two projects produce a node with the same
name, project B'sINSERT OR IGNOREis dropped by the globalUNIQUE, and the unscopedSELECT … WHERE name = ?returns project A's node id — solinkRecordToEntitylinks project B's record to project A's entity.This is reached in practice, not just theory: branch nodes use the bare branch name (
upsertNode(db, projectId, "branch", branch)inpost-tool-use.ts), andmain/master/developare universal, so essentially every multi-project install collides. That nodes are meant to be project-scoped is confirmed bydeleteProjectData(nodes WHERE project_id = ?) — which also means aresetof project A deletes the shared node project B still points at, leaving project B with dangling links.What this changes
nodeswithUNIQUE(project_id, name)instead of the globalname UNIQUE, as a new append-onlyMIGRATIONSentry. Node ids (the PRIMARY KEY referenced byrecord_entities/edges) are preserved, so existing links survive the rebuild.upsertNodescopes its lookup withAND project_id = ?. Both halves are required — the SELECT fix alone can't work while the globalUNIQUEstill blocks the second project's insert.applyMigrationsdisables foreign keys around the migration and re-checks integrity (foreign_key_check) before re-enabling them, following the SQLite procedure for table-rebuild schema changes.Why this approach
The migration rebuilds
nodes(CREATE new → copy → DROP old → RENAME), which drops the table thatrecord_entities/edgesreference.PRAGMA foreign_keysis a no-op inside a transaction, anddefer_foreign_keysis not sufficient here: dropping a parent table leaves a deferred-constraint count that a laterRENAMEcan't clear, soCOMMITfails even whenforeign_key_checkreports no dangling rows. Togglingforeign_keysaround the per-migration transactions (the documented SQLite approach) is the correct fix and benefits any future table-rebuild migration. The repo is pre-release, so no production data-dedup is required.No change to single-project behavior.
Testing
npm run checkis green (biome + typecheck + 305 tests).test/storage/entities.test.ts— two projects sharing a node name (main) get distinct ids; a record in project B links to project B's node (verified down toproject_id); single-project upsert stays idempotent; uniqueness holds per(project_id, name).test/storage/migrations.test.ts— the V1→V2 upgrade preserves node ids and theirrecord_entitieslinks, re-enables foreign keys with zeroforeign_key_checkviolations, and admits the same node name in a different project afterward.Confirmed as a true regression guard: reverting just the scoped
SELECT(keeping the migration) fails the cross-project tests, since the unscoped lookup still returns the first project's node.