diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b68b091..b17c09d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing +- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship. + - The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454) - Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532) diff --git a/__tests__/sync-rebuild-convergence.test.ts b/__tests__/sync-rebuild-convergence.test.ts index fc9c626ae..688e5e37c 100644 --- a/__tests__/sync-rebuild-convergence.test.ts +++ b/__tests__/sync-rebuild-convergence.test.ts @@ -146,6 +146,86 @@ describe('Incremental sync converges to a full rebuild (CG-33)', () => { expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0'); }); + it('keeps one edge when re-resolution selects the same target', async () => { + write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`); + write('src/alpha.ts', `export function pct(n: number): number {\n return n;\n}\n`); + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + + // zeta.ts introduces a competing definition, so the existing edge is + // reopened, but alpha.ts remains the deterministic first candidate. + write('src/zeta.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`); + const result = await cg.sync(); + expect(result.definitionDelta).toContain('pct'); + + const targets = withDb((db) => + ( + db + .prepare( + `SELECT target.file_path AS file + FROM edges edge + JOIN nodes source ON source.id = edge.source + JOIN nodes target ON target.id = edge.target + WHERE source.name = 'run' + AND target.name = 'pct' + AND edge.kind = 'calls'` + ) + .all() as Array<{ file: string }> + ).map((row) => row.file) + ); + expect(targets).toEqual(['src/alpha.ts']); + }); + + it('rolls back edge deletion when requeueing its reference is interrupted', async () => { + write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`); + write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`); + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + + const originalEdge = withDb((db) => { + const row = db + .prepare( + `SELECT edge.source, edge.target, edge.kind + FROM edges edge + JOIN nodes source ON source.id = edge.source + JOIN nodes target ON target.id = edge.target + WHERE source.name = 'run' + AND target.name = 'pct' + AND edge.kind = 'calls'` + ) + .get() as { source: string; target: string; kind: string }; + db.exec( + `CREATE TRIGGER interrupt_pct_requeue + BEFORE INSERT ON unresolved_refs + WHEN NEW.reference_name = 'pct' + BEGIN + SELECT RAISE(ABORT, 'forced rebind interruption'); + END;` + ); + return `${row.source}|${row.target}|${row.kind}`; + }); + + write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`); + await expect(cg.sync()).rejects.toThrow(/forced rebind interruption/); + + // A failed requeue leaves the last committed graph answer untouched. + expect(edgeSet().has(originalEdge)).toBe(true); + const queued = withDb( + (db) => + ( + db + .prepare( + `SELECT COUNT(*) AS count + FROM unresolved_refs ref + JOIN nodes source ON source.id = ref.from_node_id + WHERE source.name = 'run' AND ref.reference_name = 'pct'` + ) + .get() as { count: number } + ).count + ); + expect(queued).toBe(0); + }); + /** * The mirror direction: removing a definition narrows the candidate set too, * so the delta must include names the sync DROPPED, not just names it added. diff --git a/src/db/queries.ts b/src/db/queries.ts index 37c078845..1bce611a1 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3526,6 +3526,21 @@ export class QueryBuilder { return changed; } + /** + * Replace resolution edges with their original unresolved references as one + * transaction. If ref insertion fails, the edge deletion is rolled back. + */ + replaceResolutionEdgesWithUnresolvedRefs( + edgeIds: number[], + refs: UnresolvedReference[] + ): number { + return this.db.transaction(() => { + const changed = this.deleteEdgesByIds(edgeIds); + this.insertUnresolvedRefsBatch(refs); + return changed; + })(); + } + /** * Distinct node names present in the given files — the symbol names a sync * pass uses to look up retryable failed refs after those files changed. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 8095ee5b5..54c6c18ab 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -2906,8 +2906,7 @@ export class ExtractionOrchestrator { // rebind to the same target is a clean no-op, but leaving the old row in // place for a rebind ELSEWHERE would keep both, turning drift into // duplication. - this.queries.deleteEdgesByIds(edgeIds); - this.queries.insertUnresolvedRefsBatch(refs); + this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs); return refs.length; }