Skip to content

Commit 1f5d28b

Browse files
authored
fix+perf: dogfood fixes 9.1-9.4 and sub-100ms incremental rebuilds (#640)
* fix: db version warning, barrel export tracing, quieter tsconfig, Set compat 9.1 — Warn on graph load when DB was built with a different codegraph version. The check runs once per process in openReadonlyOrFail() and suggests `build --no-incremental`. 9.2 — Barrel-only files now emit reexport edges during build. Previously the entire file was skipped in buildImportEdges; now only non-reexport imports are skipped, so `codegraph exports` can follow re-export chains. 9.3 — Demote "Failed to parse tsconfig.json" from warn to debug level so it no longer clutters every build output. 9.4 — Document EXTENSIONS/IGNORE_DIRS Array→Set breaking change in CHANGELOG. Add .toArray() convenience method and export ArrayCompatSet type for consumers migrating from the pre-3.4 array API. * docs: soften EXTENSIONS/IGNORE_DIRS changelog wording * fix: address review feedback — version check, Set mutation, barrel edge duplication (#634) - Move _versionWarned flag outside mismatch conditional to avoid redundant build_meta queries when versions match. - Wrap SUPPORTED_EXTENSIONS in new Set() to avoid mutating the sibling module's export. - Delete outgoing edges for barrel-only files before re-adding them to fileSymbols during incremental builds, preventing duplicate reexport edges. * refactor: replace vendor.d.ts with @types/better-sqlite3 Delete the 39-LOC manual ambient type declarations for better-sqlite3 and use the community @types/better-sqlite3 package instead. The vendor file was a migration-era shim (allowJs is long gone from tsconfig). - Replace all BetterSqlite3.Database → BetterSqlite3Database (types.ts) - Replace all BetterSqlite3.Statement → SqliteStatement (types.ts) - Simplify constructor casts in connection.ts, branch-compare.ts, snapshot.ts (no longer needed with proper @types) - Clean up watcher.ts double-cast and info.ts @ts-expect-error - Widen transaction() return type for @types compatibility * fix: address Greptile review feedback - Restore warn level for tsconfig/jsconfig parse errors (P1: was incorrectly downgraded to debug; ENOENT is already guarded by existsSync before the try block) - Simplify openReadonlyOrFail constructor cast to match openDb pattern (P2) - Use Object.assign in withArrayCompat instead of cast-then-mutate (P2) - Remove unused BetterSqlite3Database import from branch-compare.ts - Remove stale biome-ignore suppression from snapshot.ts * fix: preserve transaction argument types via inline inference (#640) * perf: sub-100ms 1-file incremental rebuilds (466ms → 78-90ms) Four optimizations for small incremental builds (≤5 changed files): 1. Scope barrel re-parsing to related barrels only (resolve-imports.ts) Instead of parsing ALL barrel files one-by-one (~93ms), only re-parse barrels imported by or re-exporting from changed files, batch-parsed in one call (~11ms). 2. Fast-path structure metrics (build-structure.ts) For ≤5 changed files on large codebases (>20 files), use targeted per-file SQL queries (~2ms) instead of loading ALL definitions from DB and recomputing ALL metrics (~35ms). 3. Skip unnecessary finalize work (finalize.ts) - Skip setBuildMeta writes for ≤5 files (avoids WAL transaction) - Skip drift detection for ≤3 files - Skip auto-registration dynamic import for incremental builds - Move timing measurement before db.close() 4. Deferred db.close() for small incremental builds (connection.ts) WAL checkpoint in db.close() costs ~250ms on Windows NTFS. Defer to next event loop tick so buildGraph() returns immediately. Includes flushDeferredClose() for test compatibility and auto-flush on openDb().
1 parent 3e5ad45 commit 1f5d28b

23 files changed

Lines changed: 471 additions & 284 deletions

package-lock.json

Lines changed: 11 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"@commitlint/config-conventional": "^20.0",
9595
"@huggingface/transformers": "^3.8.1",
9696
"@tree-sitter-grammars/tree-sitter-hcl": "^1.2.0",
97+
"@types/better-sqlite3": "^7.6.13",
9798
"@vitest/coverage-v8": "^4.0.18",
9899
"commit-and-tag-version": "^12.5",
99100
"husky": "^9.1",

src/cli/commands/info.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export const command: CommandDefinition = {
4343
const dbPath = findDbPath();
4444
const fs = await import('node:fs');
4545
if (fs.existsSync(dbPath)) {
46-
// @ts-expect-error -- better-sqlite3 default export typing
4746
const db = new Database(dbPath, { readonly: true });
4847
const buildEngine = getBuildMeta(db, 'engine');
4948
const buildVersion = getBuildMeta(db, 'codegraph_version');

src/cli/shared/open-graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type Database from 'better-sqlite3';
21
import { openReadonlyOrFail } from '../../db/index.js';
2+
import type { BetterSqlite3Database } from '../../types.js';
33

44
/**
55
* Open the graph database in readonly mode with a clean close() handle.
66
*/
77
export function openGraph(opts: { db?: string } = {}): {
8-
db: Database.Database;
8+
db: BetterSqlite3Database;
99
close: () => void;
1010
} {
1111
const db = openReadonlyOrFail(opts.db);

src/db/connection.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -143,16 +143,12 @@ function isSameDirectory(a: string, b: string): boolean {
143143
}
144144

145145
export function openDb(dbPath: string): LockedDatabase {
146+
// Flush any deferred DB close from a previous build (avoids WAL contention)
147+
flushDeferredClose();
146148
const dir = path.dirname(dbPath);
147149
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
148150
acquireAdvisoryLock(dbPath);
149-
// vendor.d.ts declares Database as a callable; cast through unknown for construct usage
150-
const db = new (
151-
Database as unknown as new (
152-
path: string,
153-
opts?: Record<string, unknown>,
154-
) => LockedDatabase
155-
)(dbPath);
151+
const db = new Database(dbPath) as unknown as LockedDatabase;
156152
db.pragma('journal_mode = WAL');
157153
db.pragma('busy_timeout = 5000');
158154
db.__lockPath = `${dbPath}.lock`;
@@ -164,6 +160,54 @@ export function closeDb(db: LockedDatabase): void {
164160
if (db.__lockPath) releaseAdvisoryLock(db.__lockPath);
165161
}
166162

163+
/** Pending deferred-close DB handles (not yet closed). */
164+
const _deferredDbs: LockedDatabase[] = [];
165+
166+
/**
167+
* Synchronously close any DB handles queued by `closeDbDeferred()`.
168+
* Call before deleting DB files or in test teardown to avoid EBUSY on Windows.
169+
*/
170+
export function flushDeferredClose(): void {
171+
while (_deferredDbs.length > 0) {
172+
const db = _deferredDbs.pop()!;
173+
try {
174+
db.close();
175+
} catch {
176+
/* ignore — handle may already be closed */
177+
}
178+
}
179+
}
180+
181+
/**
182+
* Schedule DB close on the next event loop tick. Useful for incremental
183+
* builds where the WAL checkpoint in db.close() is expensive (~250ms on
184+
* Windows) and doesn't need to block the caller.
185+
*
186+
* The advisory lock is released immediately so subsequent opens succeed.
187+
* The actual handle close (+ WAL checkpoint) happens asynchronously.
188+
* Call `flushDeferredClose()` before deleting the DB file.
189+
*/
190+
export function closeDbDeferred(db: LockedDatabase): void {
191+
// Release the advisory lock immediately so the next open can proceed
192+
if (db.__lockPath) {
193+
releaseAdvisoryLock(db.__lockPath);
194+
db.__lockPath = undefined;
195+
}
196+
_deferredDbs.push(db);
197+
// Defer the expensive WAL checkpoint to after the caller returns
198+
setImmediate(() => {
199+
const idx = _deferredDbs.indexOf(db);
200+
if (idx !== -1) {
201+
_deferredDbs.splice(idx, 1);
202+
try {
203+
db.close();
204+
} catch {
205+
/* ignore — handle may already be closed by flush */
206+
}
207+
}
208+
});
209+
}
210+
167211
export function findDbPath(customPath?: string): string {
168212
if (customPath) return path.resolve(customPath);
169213
const rawCeiling = findRepoRoot();
@@ -214,12 +258,7 @@ export function openReadonlyOrFail(customPath?: string): BetterSqlite3Database {
214258
{ file: dbPath },
215259
);
216260
}
217-
const db = new (
218-
Database as unknown as new (
219-
path: string,
220-
opts?: Record<string, unknown>,
221-
) => BetterSqlite3Database
222-
)(dbPath, { readonly: true });
261+
const db = new Database(dbPath, { readonly: true }) as unknown as BetterSqlite3Database;
223262

224263
// Warn once per process if the DB was built with a different codegraph version
225264
if (!_versionWarned) {

src/db/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
export type { LockedDatabase } from './connection.js';
44
export {
55
closeDb,
6+
closeDbDeferred,
67
findDbPath,
78
findRepoRoot,
9+
flushDeferredClose,
810
openDb,
911
openReadonlyOrFail,
1012
openRepo,

src/domain/graph/builder/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* Each stage reads what it needs and writes what it produces.
55
* This replaces the closure-captured locals in the old monolithic buildGraph().
66
*/
7-
import type BetterSqlite3 from 'better-sqlite3';
87
import type {
8+
BetterSqlite3Database,
99
BuildGraphOpts,
1010
CodegraphConfig,
1111
EngineOpts,
@@ -20,7 +20,7 @@ import type {
2020
export class PipelineContext {
2121
// ── Inputs (set during setup) ──────────────────────────────────────
2222
rootDir!: string;
23-
db!: BetterSqlite3.Database;
23+
db!: BetterSqlite3Database;
2424
dbPath!: string;
2525
config!: CodegraphConfig;
2626
opts!: BuildGraphOpts;

src/domain/graph/builder/helpers.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
import { createHash } from 'node:crypto';
77
import fs from 'node:fs';
88
import path from 'node:path';
9-
import type BetterSqlite3 from 'better-sqlite3';
109
import { purgeFilesData } from '../../../db/index.js';
11-
import { debug, warn } from '../../../infrastructure/logger.js';
10+
import { warn } from '../../../infrastructure/logger.js';
1211
import { EXTENSIONS, IGNORE_DIRS } from '../../../shared/constants.js';
13-
import type { BetterSqlite3Database, CodegraphConfig, PathAliases } from '../../../types.js';
12+
import type {
13+
BetterSqlite3Database,
14+
CodegraphConfig,
15+
PathAliases,
16+
SqliteStatement,
17+
} from '../../../types.js';
1418

1519
export const BUILTIN_RECEIVERS: Set<string> = new Set([
1620
'console',
@@ -148,7 +152,7 @@ export function loadPathAliases(rootDir: string): PathAliases {
148152
}
149153
break;
150154
} catch (err: unknown) {
151-
debug(`Failed to parse ${configName}: ${(err as Error).message}`);
155+
warn(`Failed to parse ${configName}: ${(err as Error).message}`);
152156
}
153157
}
154158
return aliases;
@@ -198,7 +202,7 @@ export function readFileSafe(filePath: string, retries: number = 2): string {
198202
* Purge all graph data for the specified files.
199203
*/
200204
export function purgeFilesFromGraph(
201-
db: BetterSqlite3.Database,
205+
db: BetterSqlite3Database,
202206
files: string[],
203207
options: Record<string, unknown> = {},
204208
): void {
@@ -210,10 +214,10 @@ export function purgeFilesFromGraph(
210214
const BATCH_CHUNK = 500;
211215

212216
// Statement caches keyed by chunk size — avoids recompiling for every batch.
213-
const nodeStmtCache = new WeakMap<BetterSqlite3.Database, Map<number, BetterSqlite3.Statement>>();
214-
const edgeStmtCache = new WeakMap<BetterSqlite3.Database, Map<number, BetterSqlite3.Statement>>();
217+
const nodeStmtCache = new WeakMap<BetterSqlite3Database, Map<number, SqliteStatement>>();
218+
const edgeStmtCache = new WeakMap<BetterSqlite3Database, Map<number, SqliteStatement>>();
215219

216-
function getNodeStmt(db: BetterSqlite3.Database, chunkSize: number): BetterSqlite3.Statement {
220+
function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement {
217221
let cache = nodeStmtCache.get(db);
218222
if (!cache) {
219223
cache = new Map();
@@ -231,7 +235,7 @@ function getNodeStmt(db: BetterSqlite3.Database, chunkSize: number): BetterSqlit
231235
return stmt;
232236
}
233237

234-
function getEdgeStmt(db: BetterSqlite3.Database, chunkSize: number): BetterSqlite3.Statement {
238+
function getEdgeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement {
235239
let cache = edgeStmtCache.get(db);
236240
if (!cache) {
237241
cache = new Map();
@@ -253,7 +257,7 @@ function getEdgeStmt(db: BetterSqlite3.Database, chunkSize: number): BetterSqlit
253257
* Batch-insert node rows via multi-value INSERT statements.
254258
* Each row: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility]
255259
*/
256-
export function batchInsertNodes(db: BetterSqlite3.Database, rows: unknown[][]): void {
260+
export function batchInsertNodes(db: BetterSqlite3Database, rows: unknown[][]): void {
257261
if (!rows.length) return;
258262
for (let i = 0; i < rows.length; i += BATCH_CHUNK) {
259263
const end = Math.min(i + BATCH_CHUNK, rows.length);
@@ -272,7 +276,7 @@ export function batchInsertNodes(db: BetterSqlite3.Database, rows: unknown[][]):
272276
* Batch-insert edge rows via multi-value INSERT statements.
273277
* Each row: [source_id, target_id, kind, confidence, dynamic]
274278
*/
275-
export function batchInsertEdges(db: BetterSqlite3.Database, rows: unknown[][]): void {
279+
export function batchInsertEdges(db: BetterSqlite3Database, rows: unknown[][]): void {
276280
if (!rows.length) return;
277281
for (let i = 0; i < rows.length; i += BATCH_CHUNK) {
278282
const end = Math.min(i + BATCH_CHUNK, rows.length);

0 commit comments

Comments
 (0)