Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions __tests__/migrations-022.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,189 @@ describe('Migration 022 — content_hash index + structural-clear preservation',
}
});

it('pruneOrphanSummaries deletes only summaries whose node_id is gone', () => {
const dbPath = path.join(dir, 'prune.db');
const conn = DatabaseConnection.initialize(dbPath);
try {
const raw = conn.getDb();
const queries = new QueryBuilder(raw);

// All three nodes exist initially (so FK on summaries is happy).
const insertNode = raw.prepare(`
INSERT INTO nodes (id, kind, name, qualified_name, file_path, language,
start_line, end_line, start_column, end_column, updated_at)
VALUES (?, 'function', 'foo', 'foo', 'a.ts', 'typescript', 1, 5, 0, 0, 0)
`);
insertNode.run('n1');
insertNode.run('n2');
insertNode.run('n3');

raw.prepare(`
INSERT INTO symbol_summaries (node_id, content_hash, summary, model, generated_at)
VALUES ('n1', 'h1', 'live summary', 'm', 100),
('n2', 'h2', 'orphan A', 'm', 100),
('n3', 'h3', 'orphan B', 'm', 100)
`).run();

const liveVec = Buffer.from(new Float32Array([1, 0, 0]).buffer);
const orphanVec = Buffer.from(new Float32Array([0, 1, 0]).buffer);
raw.prepare(`
INSERT INTO symbol_embeddings (node_id, embedding, embedding_model, source_content_hash)
VALUES ('n1', ?, 'em', 'src-1'),
('n2', ?, 'em', 'src-2')
`).run(liveVec, orphanVec);

// Simulate clearStructural's effect: delete n2/n3 from `nodes`
// with FKs OFF, leaving the summary + embedding rows orphaned.
// This is the exact post-clearStructural-then-reindex state where
// pruneOrphanSummaries is meant to clean up.
raw.exec('PRAGMA foreign_keys = OFF');
raw.prepare("DELETE FROM nodes WHERE id IN ('n2', 'n3')").run();
raw.exec('PRAGMA foreign_keys = ON');

const result = queries.pruneOrphanSummaries();

// Two summaries removed (n2, n3) — n1 stayed.
expect(result.summariesDeleted).toBe(2);
// One embedding removed via FK cascade (n2 had an embedding).
expect(result.embeddingsDeleted).toBe(1);

// Verify final state: only the live row remains.
expect((raw.prepare('SELECT COUNT(*) AS c FROM symbol_summaries').get() as { c: number }).c).toBe(1);
expect((raw.prepare('SELECT COUNT(*) AS c FROM symbol_embeddings').get() as { c: number }).c).toBe(1);
const survivor = raw.prepare("SELECT summary FROM symbol_summaries WHERE node_id = 'n1'").get() as { summary: string };
expect(survivor.summary).toBe('live summary');
} finally {
conn.close();
}
});

it('pruneOrphanDirectorySummaries deletes only dirs with no indexed files', () => {
const dbPath = path.join(dir, 'prune-dirs.db');
const conn = DatabaseConnection.initialize(dbPath);
try {
const raw = conn.getDb();
const queries = new QueryBuilder(raw);

// Indexed files: 2 in src/a, 1 in src/b, 1 directly in src/.
// Live = exactly the immediate parents the dir-summarizer would
// have written for: src/a, src/b, and src (only because main.ts
// lives directly in src/). NOT all ancestors — see the comment
// in pruneOrphanDirectorySummaries explaining why we don't walk
// the chain (would let stale ancestor-only summaries survive).
const insFile = raw.prepare(`
INSERT INTO files (path, language, content_hash, modified_at, size, indexed_at)
VALUES (?, 'typescript', 'h', 0, 100, 0)
`);
insFile.run('src/a/foo.ts');
insFile.run('src/a/bar.ts');
insFile.run('src/b/baz.ts');
insFile.run('src/main.ts');

// Plant 5 dir summaries: 3 live, 2 orphan.
const insDir = raw.prepare(`
INSERT INTO directory_summaries (dir_path, summary, content_hash, model, generated_at)
VALUES (?, ?, 'h', 'm', 100)
`);
insDir.run('src/a', 'live a');
insDir.run('src/b', 'live b');
insDir.run('src', 'live root (has main.ts directly)');
insDir.run('src/c', 'orphan deleted dir');
insDir.run('vendor/lib', 'orphan outside tree');

const result = queries.pruneOrphanDirectorySummaries();

expect(result.directorySummariesDeleted).toBe(2);

const remaining = (raw
.prepare('SELECT dir_path FROM directory_summaries ORDER BY dir_path')
.all() as Array<{ dir_path: string }>)
.map((r) => r.dir_path);
expect(remaining).toEqual(['src', 'src/a', 'src/b']);
} finally {
conn.close();
}
});

it('pruneOrphanDirectorySummaries: stale ancestor-only dir IS pruned', () => {
// Regression for an under-pruning bug: if files used to live
// directly in `src/` (so the dir-summarizer wrote a `src` summary)
// but have all moved into subdirs like `src/core/`, the stale
// `src` summary must be pruned. The earlier draft walked the
// ancestor chain and would have kept it.
const dbPath = path.join(dir, 'prune-dirs-ancestor.db');
const conn = DatabaseConnection.initialize(dbPath);
try {
const raw = conn.getDb();
const queries = new QueryBuilder(raw);

raw.prepare(`
INSERT INTO files (path, language, content_hash, modified_at, size, indexed_at)
VALUES ('src/core/foo.ts', 'typescript', 'h', 0, 100, 0)
`).run();

raw.prepare(`
INSERT INTO directory_summaries (dir_path, summary, content_hash, model, generated_at)
VALUES ('src/core', 'live', 'h', 'm', 100),
('src', 'stale ancestor — no file lives directly here', 'h', 'm', 100)
`).run();

const result = queries.pruneOrphanDirectorySummaries();
expect(result.directorySummariesDeleted).toBe(1);
const remaining = (raw
.prepare('SELECT dir_path FROM directory_summaries')
.all() as Array<{ dir_path: string }>)
.map((r) => r.dir_path);
expect(remaining).toEqual(['src/core']);
} finally {
conn.close();
}
});

it('pruneOrphanDirectorySummaries: empty files table prunes everything', () => {
const dbPath = path.join(dir, 'prune-dirs-empty.db');
const conn = DatabaseConnection.initialize(dbPath);
try {
const raw = conn.getDb();
const queries = new QueryBuilder(raw);
raw.prepare(`
INSERT INTO directory_summaries (dir_path, summary, content_hash, model, generated_at)
VALUES ('src/a', 's', 'h', 'm', 100), ('src/b', 's', 'h', 'm', 100)
`).run();

const result = queries.pruneOrphanDirectorySummaries();
expect(result.directorySummariesDeleted).toBe(2);
expect((raw.prepare('SELECT COUNT(*) AS c FROM directory_summaries').get() as { c: number }).c).toBe(0);
} finally {
conn.close();
}
});

it('pruneOrphanSummaries is a no-op when nothing is orphaned', () => {
const dbPath = path.join(dir, 'noop.db');
const conn = DatabaseConnection.initialize(dbPath);
try {
const raw = conn.getDb();
const queries = new QueryBuilder(raw);
raw.prepare(`
INSERT INTO nodes (id, kind, name, qualified_name, file_path, language,
start_line, end_line, start_column, end_column, updated_at)
VALUES ('n1', 'function', 'foo', 'foo', 'a.ts', 'typescript', 1, 5, 0, 0, 0)
`).run();
raw.prepare(`
INSERT INTO symbol_summaries (node_id, content_hash, summary, model, generated_at)
VALUES ('n1', 'h', 's', 'm', 100)
`).run();

const result = queries.pruneOrphanSummaries();
expect(result.summariesDeleted).toBe(0);
expect(result.embeddingsDeleted).toBe(0);
expect((raw.prepare('SELECT COUNT(*) AS c FROM symbol_summaries').get() as { c: number }).c).toBe(1);
} finally {
conn.close();
}
});

it('clear (full reset) wipes summaries too', () => {
const dbPath = path.join(dir, 'wipe.db');
const conn = DatabaseConnection.initialize(dbPath);
Expand Down
156 changes: 156 additions & 0 deletions __tests__/wasm-savepoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* WASM adapter — nested transaction semantics via savepoints.
*
* `node-sqlite3-wasm` only supports a single top-level transaction at
* a time. Bare BEGIN inside an active transaction errors with "cannot
* start a transaction within a transaction". Our adapter wraps that
* into a SAVEPOINT/RELEASE/ROLLBACK TO scheme so callers can nest
* `transaction()` calls transparently — matching better-sqlite3's
* native nested-transaction support.
*
* These tests import `WasmDatabaseAdapter` directly so they exercise
* the WASM path even on machines where better-sqlite3 is also
* installed (where `createDatabase` would otherwise pick native).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { WasmDatabaseAdapter } from '../src/db/sqlite-adapter';

function tempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-wasm-'));
}

function cleanup(dir: string): void {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
}

describe('WasmDatabaseAdapter — nested transactions via savepoints', () => {
let dir: string;
let db: WasmDatabaseAdapter;
beforeEach(() => {
dir = tempDir();
db = new WasmDatabaseAdapter(path.join(dir, 'test.db'));
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT NOT NULL)');
});
afterEach(() => {
db.close();
cleanup(dir);
});

it('single-level transaction commits writes', () => {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('a')");
})();
const rows = db.prepare('SELECT val FROM t').all();
expect(rows).toEqual([{ val: 'a' }]);
});

it('nested commit: outer + inner both commit', () => {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('outer')");
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('inner')");
})();
})();
const vals = (db.prepare('SELECT val FROM t ORDER BY val').all() as Array<{ val: string }>)
.map((r) => r.val);
expect(vals).toEqual(['inner', 'outer']);
});

it('inner throw: only inner rolls back, outer commits', () => {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('outer-keeps')");
try {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('inner-discards')");
throw new Error('inner failure');
})();
} catch {
// swallow — testing that outer can recover from inner failure
}
db.exec("INSERT INTO t (val) VALUES ('outer-after-recovery')");
})();
const vals = (db.prepare('SELECT val FROM t ORDER BY val').all() as Array<{ val: string }>)
.map((r) => r.val);
// 'inner-discards' is gone; everything outer wrote (before AND
// after the inner failure) survives. Pre-fix this would have
// thrown on the inner BEGIN with "cannot start a transaction
// within a transaction".
expect(vals).toEqual(['outer-after-recovery', 'outer-keeps']);
});

it('outer throw: full rollback, no rows survive', () => {
expect(() => {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('a')");
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('b')");
})();
throw new Error('outer failure');
})();
}).toThrow('outer failure');
expect((db.prepare('SELECT COUNT(*) AS c FROM t').get() as { c: number }).c).toBe(0);
});

it('depth counter resets after both success and failure paths', () => {
// First run: success → depth back to 0.
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('first')");
})();
// Second run: same depth-zero starting point, must use BEGIN
// (not SAVEPOINT). If the depth counter wasn't restored after the
// first run's finally, this would raise "no such savepoint".
expect(() => {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('second')");
throw new Error('second failure');
})();
}).toThrow('second failure');
// Third run: again depth-zero. The error path's finally must have
// restored depth too.
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('third')");
})();
const vals = (db.prepare('SELECT val FROM t ORDER BY val').all() as Array<{ val: string }>)
.map((r) => r.val);
expect(vals).toEqual(['first', 'third']);
});

it('three-deep nesting: each level commits or rolls back independently', () => {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('L1')");
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('L2')");
try {
db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('L3-discards')");
throw new Error('L3 fails');
})();
} catch { /* swallow */ }
db.exec("INSERT INTO t (val) VALUES ('L2-survives-L3-failure')");
})();
})();
const vals = (db.prepare('SELECT val FROM t ORDER BY val').all() as Array<{ val: string }>)
.map((r) => r.val);
expect(vals).toEqual(['L1', 'L2', 'L2-survives-L3-failure']);
});

it('transaction() return value passes through', () => {
const result = db.transaction(() => {
db.exec("INSERT INTO t (val) VALUES ('x')");
return 42;
})();
expect(result).toBe(42);
});

it('transaction() forwards args to the wrapped function', () => {
const seen: number[] = [];
const tx = db.transaction((a: number, b: number) => {
seen.push(a, b);
});
tx(7, 11);
expect(seen).toEqual([7, 11]);
});
});
Loading