فهرست منبع

fix(db): make edge rebinding atomic (#1833) (#1849)

Co-authored-by: Colby Mchenry <me@colbymchenry.com>
Christopher Beaulieu 5 روز پیش
والد
کامیت
58c07e8745
4فایلهای تغییر یافته به همراه98 افزوده شده و 2 حذف شده
  1. 2 0
      CHANGELOG.md
  2. 80 0
      __tests__/sync-rebuild-convergence.test.ts
  3. 15 0
      src/db/queries.ts
  4. 1 2
      src/extraction/index.ts

+ 2 - 0
CHANGELOG.md

@@ -157,6 +157,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)

+ 80 - 0
__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.

+ 15 - 0
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.

+ 1 - 2
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;
   }