Repository: colbymchenry/codegraph (2026-05-26T17:46:20Z)

Issue:
FOREIGN KEY constraint failed during watch sync (v0.9.5 — regression from #28/#42/#53)
## Summary

`FOREIGN KEY constraint failed` errors during watch sync, identical to previously fixed #28/#42/#53. However, this occurs on **v0.9.5** (latest), where the fix from PR #62 should already be included.

## Environment

- **CodeGraph version**: 0.9.5 (latest via npm)
- **OS**: Ubuntu 24.04, Linux x64
- **Project**: Python monorepo, 397 files, 7710 nodes, 7325 edges
- **Languages**: Python (384), YAML (12), JavaScript (1)
- **Storage**: External USB drive (ext4)
- **Runtime**: Node.js v22.22.2

## Reproduction

1. `codegraph init` + `codegraph index` — succeeds cleanly
2. Start daemon via `codegraph serve --mcp`
3. Watch sync triggers on file changes → FK errors appear intermittently
4. Some syncs succeed, some fail — inconsistent

## daemon.log (after fresh reinit)

```
[CodeGraph daemon] Listening on ... (pid 593640, v0.9.5). Idle timeout 300000ms.
[CodeGraph MCP] File watcher active — graph will auto-sync on changes
[CodeGraph MCP] Auto-synced 1 file(s) in 1025ms
[CodeGraph MCP] Auto-synced 1 file(s) in 307ms
[CodeGraph MCP] Auto-synced 2 file(s) in 324ms
[CodeGraph MCP] Auto-synced 1 file(s) in 285ms
[CodeGraph] Watch sync failed { error: 'FOREIGN KEY constraint failed' }
[CodeGraph MCP] Auto-sync error: FOREIGN KEY constraint failed
[CodeGraph] Watch sync failed { error: 'FOREIGN KEY constraint failed' }
[CodeGraph MCP] Auto-sync error: FOREIGN KEY constraint failed
... (76 occurrences total in ~2 days of uptime)
```

## Key observations

1. **Not limited to C/C++ headers**: Previous issues (#28/#42/#53) were triggered by tree-sitter producing empty-named nodes in C header files. This project is **Python-only** (384 files) — the trigger is different.
2. **Intermittent**: Some watch syncs succeed, others fail. Not deterministic.
3. **Compounding**: Once FK integrity is broken, subsequent syncs continue to fail.
4. **Survives reindex**: After fresh `codegraph init` + `codegraph index`, the daemon produces FK errors again on its first watch sync cycle (2 errors observed immediately).

## Workaround

Delete `.codegraph/` entirely and reinitialize:
```bash
rm -rf .codegraph/
codegraph init .
codegraph index .
```

This resolves it temporarily, but FK errors reappear over time.

## Related

- #28 — Original FK issue (v0.5.7, C headers)
- #42 — Same issue (v0.6.2)
- #53 — Same issue (v0.6.x)
- PR #62 — Fix for the above


Discussion:
Fixed in #463, on `main`.

**Root cause.** PR #62 filtered out stale-target edges at the extraction-layer `insertEdges` site (your `unresolved_refs` table is clean), but the resolution-layer `insertEdges` — the one watch sync hits — wasn't guarded the same way. Some framework resolvers cache native `Node` references in a `WeakMap` keyed by the resolver context that persists across syncs; a file rewrite between map build and resolution can leave a dangling node ID in there, and the resulting edge fails FK and aborts the whole resolution batch. That's why it (a) reappeared on a Python project — your repo has 1 JS file which can trigger detection of frameworks with this caching pattern, and (b) compounded — a sync that fails leaves the rest of its edges unwritten, so the next sync has more to do and more chances to hit the same stale entry.

**Fix.** The resolution layer now mirrors #62's defense — one cache-aware `getNodesByIds` per pass drops any edge whose source or target is no longer in `nodes`, so the rest of the batch still lands. The regression test reproduces the exact `FOREIGN KEY constraint failed` you saw and confirms it goes away.

Will ship in the next release.
Heads-up: I initially merged #463 to fix this, then realized #462 had a cleaner approach — DB-layer filter inside `QueryBuilder.insertEdges` itself instead of a filter spread across three resolution-layer call sites. The lower-layer fix protects every present and future `insertEdges` caller automatically and doesn't depend on the queries-layer `nodeCache` invalidation staying perfect.

So #463 was reverted on `main` (commit 028d25f) and #462 (commit 572b1ed) is what's actually shipping. Same user-facing fix, slightly cleaner internals. Thanks to @msnandhis for the better take.



Patch:
diff --git a/src/db/queries.ts b/src/db/queries.ts
index 9419a313f..131f2894d 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -21,6 +21,8 @@ import { safeJsonParse } from '../utils';
 import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils';
 import { parseQuery, boundedEditDistance } from '../search/query-parser';
 
+const SQLITE_PARAM_CHUNK_SIZE = 500;
+
 /**
  * Database row types (snake_case from SQLite)
  */
@@ -419,9 +421,8 @@ export class QueryBuilder {
     // Chunk under SQLite's parameter limit (default 999, raised to 32766
     // in better-sqlite3 builds — chunk at 500 for safety across both
     // backends and to keep the query plan simple).
-    const CHUNK = 500;
-    for (let i = 0; i < misses.length; i += CHUNK) {
-      const chunk = misses.slice(i, i + CHUNK);
+    for (let i = 0; i < misses.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = misses.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
       const placeholders = chunk.map(() => '?').join(',');
       const rows = this.db
         .prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`)
@@ -435,6 +436,25 @@ export class QueryBuilder {
     return out;
   }
 
+  private getExistingNodeIds(ids: readonly string[]): Set<string> {
+    const out = new Set<string>();
+    if (ids.length === 0) return out;
+
+    const uniqueIds = [...new Set(ids)];
+    for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
+      const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
+      const placeholders = chunk.map(() => '?').join(',');
+      const rows = this.db
+        .prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`)
+        .all(...chunk) as { id: string }[];
+      for (const row of rows) {
+        out.add(row.id);
+      }
+    }
+
+    return out;
+  }
+
   /**
    * Add a node to the cache, evicting oldest if needed
    */
@@ -1039,8 +1059,20 @@ export class QueryBuilder {
    * Insert multiple edges in a transaction
    */
   insertEdges(edges: Edge[]): void {
+    if (edges.length === 0) return;
+
     this.db.transaction(() => {
+      const endpointIds = new Set<string>();
       for (const edge of edges) {
+        endpointIds.add(edge.source);
+        endpointIds.add(edge.target);
+      }
+      const existingNodeIds = this.getExistingNodeIds([...endpointIds]);
+
+      for (const edge of edges) {
+        if (!existingNodeIds.has(edge.source) || !existingNodeIds.has(edge.target)) {
+          continue;
+        }
         this.insertEdge(edge);
       }
     })();


Test patch:
diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts
index 256cf92c5..1dc3f1eb6 100644
--- a/__tests__/db-perf.test.ts
+++ b/__tests__/db-perf.test.ts
@@ -7,6 +7,7 @@
  *      doesn't serve a stale cached row on next `getNodeById`.
  *   3. `runMaintenance` runs `PRAGMA optimize` + `wal_checkpoint(PASSIVE)`
  *      after indexAll/sync without throwing.
+ *   4. `insertEdges` validates endpoints from the DB, not stale node cache.
  */
 
 import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -128,6 +129,51 @@ describe('insertNode cache invalidation', () => {
   });
 });
 
+describe('insertEdges endpoint validation', () => {
+  let dir: string;
+  let db: DatabaseConnection;
+  let q: QueryBuilder;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-edges-'));
+    db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
+    q = new QueryBuilder(db.getDb());
+  });
+
+  afterEach(() => {
+    db.close();
+    if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('skips edges with missing endpoints instead of failing the whole batch', () => {
+    q.insertNodes([makeNode('source'), makeNode('target'), makeNode('other')]);
+
+    expect(() =>
+      q.insertEdges([
+        { source: 'source', target: 'target', kind: 'calls' },
+        { source: 'source', target: 'missing-target', kind: 'calls' },
+        { source: 'missing-source', target: 'other', kind: 'references' },
+      ])
+    ).not.toThrow();
+
+    const edges = q.getOutgoingEdges('source');
+    expect(edges).toHaveLength(1);
+    expect(edges[0]).toMatchObject({ source: 'source', target: 'target', kind: 'calls' });
+  });
+
+  it('does not trust stale cached nodes when validating edge endpoints', () => {
+    q.insertNodes([makeNode('source'), makeNode('target')]);
+    expect(q.getNodeById('target')!.id).toBe('target');
+
+    db.getDb().prepare('DELETE FROM nodes WHERE id = ?').run('target');
+
+    expect(() =>
+      q.insertEdges([{ source: 'source', target: 'target', kind: 'calls' }])
+    ).not.toThrow();
+    expect(q.getOutgoingEdges('source')).toEqual([]);
+  });
+});
+
 describe('runMaintenance', () => {
   let dir: string;
   let db: DatabaseConnection;