index.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. /**
  2. * Database Layer
  3. *
  4. * Handles SQLite database initialization and connection management.
  5. */
  6. import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter';
  7. import * as fs from 'fs';
  8. import * as path from 'path';
  9. import { SchemaVersion } from '../types';
  10. import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
  11. import { getCodeGraphDir } from '../directory';
  12. export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
  13. /**
  14. * Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
  15. * paths can't drift.
  16. *
  17. * `busy_timeout` is set FIRST, before any pragma that might touch the database
  18. * file (notably `journal_mode`). If another process holds a write lock at open
  19. * time, the later pragmas — and the connection's first query — then wait out
  20. * the lock instead of throwing "database is locked" immediately. See issue #238.
  21. *
  22. * The 5s window (was 120s) rides out a normal incremental sync; the old
  23. * 2-minute wait presented as a frozen, hung agent. With WAL, reads never block
  24. * on a writer, so this timeout only governs cross-process write contention
  25. * (e.g. the git-hook `codegraph sync` running while the MCP server writes).
  26. */
  27. function configureConnection(db: SqliteDatabase): void {
  28. db.pragma('busy_timeout = 5000'); // MUST be first — see above
  29. db.pragma('foreign_keys = ON');
  30. db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform
  31. db.pragma('synchronous = NORMAL'); // safe with WAL mode
  32. db.pragma('cache_size = -64000'); // 64 MB page cache
  33. db.pragma('temp_store = MEMORY'); // temp tables in memory
  34. db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
  35. }
  36. /**
  37. * Database connection wrapper with lifecycle management
  38. */
  39. export class DatabaseConnection {
  40. private db: SqliteDatabase;
  41. private dbPath: string;
  42. private backend: SqliteBackend;
  43. /**
  44. * `dev:ino` of the DB file at the moment we opened it (or null when the
  45. * platform/filesystem reports no usable inode). Lets us notice when the file
  46. * we hold open has been unlinked and REPLACED by a new file at the same path
  47. * — a git worktree removed and re-added, or `.codegraph/` deleted and
  48. * re-`init`ed under a long-lived server — at which point our fd reads a now
  49. * dead inode forever (#925). See `isReplacedOnDisk`.
  50. */
  51. private openedInode: string | null;
  52. private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) {
  53. this.db = db;
  54. this.dbPath = dbPath;
  55. this.backend = backend;
  56. this.openedInode = statInode(dbPath);
  57. }
  58. /**
  59. * Initialize a new database at the given path
  60. */
  61. static initialize(dbPath: string): DatabaseConnection {
  62. // Ensure parent directory exists
  63. const dir = path.dirname(dbPath);
  64. if (!fs.existsSync(dir)) {
  65. fs.mkdirSync(dir, { recursive: true });
  66. }
  67. // Create and configure database
  68. const { db, backend } = createDatabase(dbPath);
  69. configureConnection(db);
  70. // Run schema initialization
  71. const schemaPath = path.join(__dirname, 'schema.sql');
  72. const schema = fs.readFileSync(schemaPath, 'utf-8');
  73. db.exec(schema);
  74. // Record current schema version so migrations aren't re-applied on open
  75. const currentVersion = getCurrentVersion(db);
  76. if (currentVersion < CURRENT_SCHEMA_VERSION) {
  77. db.prepare(
  78. 'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
  79. ).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
  80. }
  81. return new DatabaseConnection(db, dbPath, backend);
  82. }
  83. /**
  84. * Open an existing database
  85. */
  86. static open(dbPath: string): DatabaseConnection {
  87. if (!fs.existsSync(dbPath)) {
  88. throw new Error(`Database not found: ${dbPath}`);
  89. }
  90. const { db, backend } = createDatabase(dbPath);
  91. configureConnection(db);
  92. // Check and run migrations if needed
  93. const conn = new DatabaseConnection(db, dbPath, backend);
  94. const currentVersion = getCurrentVersion(db);
  95. if (currentVersion < CURRENT_SCHEMA_VERSION) {
  96. runMigrations(db, currentVersion);
  97. }
  98. // Self-heal a bulk-load window that never closed (crash between
  99. // beginBulkNodeLoad and endBulkNodeLoad): the FTS triggers are missing and
  100. // nodes_fts is stale. Rebuild + recreate so search stays in sync.
  101. conn.healBulkNodeLoad();
  102. return conn;
  103. }
  104. /**
  105. * FTS maintenance triggers dropped/recreated around a bulk load.
  106. * Names must match schema.sql.
  107. */
  108. private static readonly FTS_TRIGGER_NAMES = ['nodes_ai', 'nodes_ad', 'nodes_au'] as const;
  109. /**
  110. * Enter bulk-load mode: drop the per-row FTS sync triggers so mass node
  111. * inserts skip per-row tokenization. MUST be paired with endBulkNodeLoad()
  112. * (use try/finally); a crash inside the window is healed on the next open().
  113. * The window is DB-wide (triggers are schema objects), which is safe because
  114. * endBulkNodeLoad() rebuilds nodes_fts from the nodes table wholesale — any
  115. * row written by anyone during the window is captured by the rebuild.
  116. */
  117. beginBulkNodeLoad(): void {
  118. for (const t of DatabaseConnection.FTS_TRIGGER_NAMES) {
  119. this.db.exec(`DROP TRIGGER IF EXISTS ${t}`);
  120. }
  121. }
  122. /**
  123. * Leave bulk-load mode: rebuild the whole FTS index from the nodes table in
  124. * one pass (far cheaper than per-row trigger firings), then recreate the
  125. * triggers by re-running schema.sql (idempotent — everything in it is
  126. * IF NOT EXISTS).
  127. */
  128. endBulkNodeLoad(): void {
  129. this.db.exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`);
  130. this.recreateFtsTriggers();
  131. }
  132. /**
  133. * Names of the NON-UNIQUE edge indexes dropped for a bulk edge load.
  134. * idx_edges_identity deliberately stays: INSERT OR IGNORE's dedup conflicts
  135. * on it (#1034), and its leftmost column is `source`, so the source-keyed
  136. * reads resolution makes mid-window (supertype walks over
  137. * `implements`/`extends`) keep an index via its prefix — verified with
  138. * EXPLAIN QUERY PLAN. Target-keyed and kind-keyed reads (traversal,
  139. * synthesis) happen only after endBulkEdgeLoad().
  140. */
  141. private static readonly BULK_EDGE_INDEX_NAMES = [
  142. 'idx_edges_kind',
  143. 'idx_edges_source_kind',
  144. 'idx_edges_target_kind',
  145. 'idx_edges_provenance',
  146. ] as const;
  147. /**
  148. * Enter bulk-edge-load mode: drop the non-unique edge indexes so the mass
  149. * INSERT OR IGNORE stream pays one B-tree (the identity index) instead of
  150. * five — measured 2.8s → 1.1s inserting a 224k-edge resolution set, with
  151. * recreation costing ~0.3s. MUST be paired with endBulkEdgeLoad(); a crash
  152. * inside the window is healed on the next DatabaseConnection open (schema.sql
  153. * re-applies CREATE INDEX IF NOT EXISTS).
  154. */
  155. beginBulkEdgeLoad(): void {
  156. for (const idx of DatabaseConnection.BULK_EDGE_INDEX_NAMES) {
  157. this.db.exec(`DROP INDEX IF EXISTS ${idx}`);
  158. }
  159. }
  160. /**
  161. * Leave bulk-edge-load mode: recreate the dropped indexes in one pass each
  162. * over the (now fully loaded) edges table — far cheaper than maintaining
  163. * them per-insert. DDL is extracted from schema.sql so it cannot drift.
  164. *
  165. * Async with a yield BETWEEN the four CREATE INDEX statements: each build is
  166. * a synchronous scan of the whole edges table (~20s apiece at Linux-kernel
  167. * scale, 79s total measured), and running them back-to-back is a single
  168. * event-loop stall longer than the #850 liveness watchdog's 60s window — a
  169. * daemon-triggered re-index would be SIGKILLed right after doing the work.
  170. * One yield per statement keeps every stall to a single index build, which
  171. * stays inside the window.
  172. */
  173. async endBulkEdgeLoad(): Promise<void> {
  174. const schemaPath = path.join(__dirname, 'schema.sql');
  175. const schema = fs.readFileSync(schemaPath, 'utf-8');
  176. for (const idx of DatabaseConnection.BULK_EDGE_INDEX_NAMES) {
  177. const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`));
  178. if (!m) throw new Error(`schema.sql: edge index ${idx} not found for bulk-load recreation`);
  179. this.db.exec(m[0]);
  180. await new Promise((resolve) => setImmediate(resolve));
  181. }
  182. }
  183. /** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */
  184. private healBulkNodeLoad(): void {
  185. const row = this.db
  186. .prepare(
  187. `SELECT count(*) AS c FROM sqlite_master WHERE type = 'trigger' AND name IN ('nodes_ai','nodes_ad','nodes_au')`
  188. )
  189. .get() as { c: number } | undefined;
  190. if ((row?.c ?? 0) >= DatabaseConnection.FTS_TRIGGER_NAMES.length) return;
  191. this.endBulkNodeLoad();
  192. }
  193. /**
  194. * Recreate the FTS sync triggers from schema.sql — extracted from the file
  195. * rather than duplicated here so the DDL cannot drift from the schema.
  196. * (Re-execing the whole schema is not an option: it contains data INSERTs
  197. * that are not idempotent, e.g. schema_versions.)
  198. */
  199. private recreateFtsTriggers(): void {
  200. const schemaPath = path.join(__dirname, 'schema.sql');
  201. const schema = fs.readFileSync(schemaPath, 'utf-8');
  202. const triggerDdls = schema.match(
  203. /CREATE TRIGGER IF NOT EXISTS nodes_a[idu]\b[\s\S]*?END;/g
  204. );
  205. if (!triggerDdls || triggerDdls.length !== DatabaseConnection.FTS_TRIGGER_NAMES.length) {
  206. throw new Error(
  207. `schema.sql: expected ${DatabaseConnection.FTS_TRIGGER_NAMES.length} nodes FTS triggers, found ${triggerDdls?.length ?? 0}`
  208. );
  209. }
  210. for (const ddl of triggerDdls) {
  211. this.db.exec(ddl);
  212. }
  213. }
  214. /**
  215. * Get the underlying database instance
  216. */
  217. getDb(): SqliteDatabase {
  218. return this.db;
  219. }
  220. /**
  221. * Get the SQLite backend serving this connection. Per-instance so
  222. * MCP cross-project queries report the right backend even when
  223. * multiple project DBs are open in the same process.
  224. */
  225. getBackend(): SqliteBackend {
  226. return this.backend;
  227. }
  228. /**
  229. * Get database file path
  230. */
  231. getPath(): string {
  232. return this.dbPath;
  233. }
  234. /**
  235. * The journal mode actually in effect (e.g. 'wal', 'delete').
  236. *
  237. * SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on
  238. * filesystems without shared-memory support (some network/virtualized mounts,
  239. * WSL2 /mnt). So the effective mode can differ
  240. * from what `configureConnection` requested. Surfaced in `codegraph status` so
  241. * a "database is locked" report is triageable: 'wal' ⇒ readers never block on a
  242. * writer; anything else ⇒ they can. See issue #238.
  243. */
  244. getJournalMode(): string {
  245. const raw = this.db.pragma('journal_mode');
  246. const row = Array.isArray(raw) ? raw[0] : raw;
  247. const mode = row && typeof row === 'object'
  248. ? (row as Record<string, unknown>).journal_mode
  249. : row;
  250. return String(mode ?? '').toLowerCase();
  251. }
  252. /**
  253. * Get current schema version
  254. */
  255. getSchemaVersion(): SchemaVersion | null {
  256. const row = this.db
  257. .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1')
  258. .get() as { version: number; applied_at: number; description: string | null } | undefined;
  259. if (!row) return null;
  260. return {
  261. version: row.version,
  262. appliedAt: row.applied_at,
  263. description: row.description ?? undefined,
  264. };
  265. }
  266. /**
  267. * Execute a function within a transaction
  268. */
  269. transaction<T>(fn: () => T): T {
  270. return this.db.transaction(fn)();
  271. }
  272. /**
  273. * Get database file size in bytes
  274. */
  275. getSize(): number {
  276. const stats = fs.statSync(this.dbPath);
  277. return stats.size;
  278. }
  279. /**
  280. * Size of the `-wal` sidecar file in bytes. 0 when it doesn't exist (non-WAL
  281. * journal mode, in-memory DB, or no write since the last checkpoint+reset).
  282. */
  283. getWalSizeBytes(): number {
  284. if (!this.dbPath || this.dbPath === ':memory:') return 0;
  285. try {
  286. return fs.statSync(`${this.dbPath}-wal`).size;
  287. } catch {
  288. return 0;
  289. }
  290. }
  291. /** Current `wal_autocheckpoint` interval in pages (0 = disabled). */
  292. getWalAutocheckpoint(): number {
  293. const v = this.db.pragma('wal_autocheckpoint', { simple: true });
  294. const n = Number(v);
  295. return Number.isFinite(n) ? n : 0;
  296. }
  297. /**
  298. * Set the connection's `wal_autocheckpoint` interval (pages; 0 disables).
  299. * Bulk indexing defers checkpoints entirely (#1231): the default 1000-page
  300. * auto-checkpoint re-writes hot B-tree/FTS pages into the main DB file over
  301. * and over — measured at ~95% of ALL disk I/O during a bulk index, and the
  302. * difference between 45s and 19+ minutes on HDD-class storage. During
  303. * deferral a {@link WalCheckpointValve} bounds WAL growth off-thread.
  304. */
  305. setWalAutocheckpoint(pages: number): void {
  306. this.db.pragma(`wal_autocheckpoint = ${Math.max(0, Math.floor(pages))}`);
  307. }
  308. /**
  309. * `PRAGMA wal_checkpoint(PASSIVE)` on a worker thread with its own
  310. * connection. PASSIVE never blocks the writer, and running it off-thread
  311. * means the main thread — and the #850 watchdog heartbeat — keep turning
  312. * even when the backfill is minutes of I/O on slow storage (a synchronous
  313. * checkpoint that exceeds the watchdog's 60s window gets a healthy index
  314. * SIGKILLed — observed in the #1231 repro).
  315. *
  316. * Returns SQLite's checkpoint result row — `log === checkpointed` with
  317. * `busy === 0` means the ENTIRE WAL was backfilled, so the writer's next
  318. * commit restarts the WAL from the top and the file stops growing. The
  319. * WAL valve needs that signal because a WAL file's SIZE never shrinks:
  320. * after the first wrap, raw file size says nothing about the un-backfilled
  321. * backlog. Best-effort: returns null on any failure (including worker
  322. * threads being unavailable — a potentially minutes-long checkpoint must
  323. * never run inline on the main thread).
  324. */
  325. async checkpointWalPassive(): Promise<{ busy: number; log: number; checkpointed: number } | null> {
  326. if (!this.dbPath || this.dbPath === ':memory:') {
  327. try {
  328. const row = this.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as Record<string, number> | undefined;
  329. return row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null;
  330. } catch {
  331. return null;
  332. }
  333. }
  334. try {
  335. const { Worker } = await import('node:worker_threads');
  336. const workerSource = `
  337. const { workerData, parentPort } = require('node:worker_threads');
  338. let row = null;
  339. try {
  340. const { DatabaseSync } = require('node:sqlite');
  341. const db = new DatabaseSync(workerData.dbPath);
  342. try { row = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get(); } catch {}
  343. try { db.close(); } catch {}
  344. } catch {}
  345. parentPort.postMessage({ row });
  346. `;
  347. return await new Promise((resolve) => {
  348. let settled = false;
  349. const finish = (row?: Record<string, number> | null): void => {
  350. if (settled) return;
  351. settled = true;
  352. resolve(row ? { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) } : null);
  353. };
  354. try {
  355. const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath } });
  356. worker.once('message', (m: { row?: Record<string, number> | null }) => { void worker.terminate(); finish(m?.row ?? null); });
  357. worker.once('error', () => { void worker.terminate(); finish(null); });
  358. worker.once('exit', () => finish(null));
  359. } catch {
  360. finish(null);
  361. }
  362. });
  363. } catch {
  364. return null;
  365. }
  366. }
  367. /**
  368. * Optimize database (vacuum and analyze)
  369. */
  370. optimize(): void {
  371. this.db.exec('VACUUM');
  372. this.db.exec('ANALYZE');
  373. }
  374. /**
  375. * Lightweight maintenance to run after bulk writes (indexAll, sync).
  376. * Two operations:
  377. *
  378. * - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes
  379. * tables whose row counts changed materially since the last
  380. * ANALYZE. Without it, the query planner has no statistics on the
  381. * freshly-bulk-loaded tables and can pick suboptimal indexes.
  382. *
  383. * - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
  384. * into the main database file so the WAL file doesn't grow
  385. * unboundedly between automatic checkpoints (auto-fires at 1000
  386. * pages by default; large indexAll runs blow past that).
  387. *
  388. * Runs on a WORKER THREAD with its own connection: on a multi-GB index
  389. * these pragmas are minutes of synchronous IO (a 95k-file kernel index
  390. * left a 593MB WAL whose checkpoint alone blew the #850 watchdog's 60s
  391. * window and got a COMPLETED index SIGKILLed at the finish line). WAL
  392. * checkpointing from a second connection is standard SQLite; `PRAGMA
  393. * optimize` persists its statistics in sqlite_stat tables, so the main
  394. * connection benefits the same. The main thread just awaits a message,
  395. * so the event loop — and the watchdog heartbeat — keep turning.
  396. *
  397. * Everything is silently swallowed on failure — best-effort
  398. * optimization, never load-bearing for correctness. If worker threads
  399. * are unavailable, falls back to a bounded in-line `PRAGMA optimize`
  400. * and SKIPS the checkpoint (the final close() checkpoints after the
  401. * CLI has already disarmed its watchdog).
  402. */
  403. async runMaintenance(): Promise<void> {
  404. // In-memory / test databases: nothing worth a worker round-trip.
  405. if (!this.dbPath || this.dbPath === ':memory:') {
  406. try { this.db.exec('PRAGMA optimize'); } catch { /* ignore */ }
  407. try { this.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); } catch { /* ignore */ }
  408. return;
  409. }
  410. await this.runPragmasOffThread(
  411. ['PRAGMA analysis_limit=1000', 'PRAGMA optimize', 'PRAGMA wal_checkpoint(PASSIVE)'],
  412. // Worker threads unavailable — bounded in-line fallback, no checkpoint.
  413. ['PRAGMA analysis_limit=1000', 'PRAGMA optimize']
  414. );
  415. }
  416. /**
  417. * Run pragmas on a worker thread against its own connection to this DB
  418. * (shared machinery for {@link runMaintenance} and
  419. * {@link checkpointWalPassive}). Each pragma is individually best-effort;
  420. * the whole call is best-effort. `inlineFallback` (if any) runs on THIS
  421. * connection only when worker threads are unavailable — keep it to pragmas
  422. * that are safe to run synchronously on the main thread.
  423. */
  424. private async runPragmasOffThread(pragmas: string[], inlineFallback: string[] = []): Promise<void> {
  425. try {
  426. const { Worker } = await import('node:worker_threads');
  427. const workerSource = `
  428. const { workerData, parentPort } = require('node:worker_threads');
  429. try {
  430. const { DatabaseSync } = require('node:sqlite');
  431. const db = new DatabaseSync(workerData.dbPath);
  432. for (const p of workerData.pragmas) { try { db.exec(p); } catch {} }
  433. try { db.close(); } catch {}
  434. } catch {}
  435. parentPort.postMessage('done');
  436. `;
  437. await new Promise<void>((resolve) => {
  438. let settled = false;
  439. const finish = (): void => {
  440. if (!settled) { settled = true; resolve(); }
  441. };
  442. try {
  443. const worker = new Worker(workerSource, { eval: true, workerData: { dbPath: this.dbPath, pragmas } });
  444. worker.once('message', () => { void worker.terminate(); finish(); });
  445. worker.once('error', () => { void worker.terminate(); finish(); });
  446. worker.once('exit', finish);
  447. } catch {
  448. finish();
  449. }
  450. });
  451. } catch {
  452. for (const p of inlineFallback) {
  453. try { this.db.exec(p); } catch { /* ignore */ }
  454. }
  455. }
  456. }
  457. /**
  458. * Close the database connection
  459. */
  460. close(): void {
  461. this.db.close();
  462. }
  463. /**
  464. * Check if the database connection is open
  465. */
  466. isOpen(): boolean {
  467. return this.db.open;
  468. }
  469. /**
  470. * True when the DB file at our path has been REPLACED on disk since we opened
  471. * it — a different inode now lives at the same path, so the fd we still hold
  472. * points at a now-unlinked inode that can never receive new writes (#925).
  473. * The trigger is removing and recreating `.codegraph/` at the same path under
  474. * a long-lived process (`git worktree remove` + re-add, or `rm -rf
  475. * .codegraph` + `codegraph init`). Returns false when the inode is unchanged,
  476. * when the file is momentarily absent (mid-recreate — nothing to reopen onto
  477. * yet), or when the platform doesn't report a usable inode (Windows can't
  478. * unlink an open file and its st_ino is unreliable, so this never fires there).
  479. */
  480. isReplacedOnDisk(): boolean {
  481. if (this.openedInode === null) return false;
  482. const current = statInode(this.dbPath);
  483. return current !== null && current !== this.openedInode;
  484. }
  485. }
  486. /**
  487. * `dev:ino` for a path, or null if it can't be stat'd or the platform doesn't
  488. * report a usable inode. Windows st_ino is unreliable across handle reopens, so
  489. * we deliberately return null there — the deleted-but-open-inode hazard this
  490. * guards (#925) is a POSIX file-semantics issue that doesn't arise on Windows
  491. * (an open file can't be unlinked).
  492. */
  493. function statInode(p: string): string | null {
  494. if (process.platform === 'win32') return null;
  495. try {
  496. const s = fs.statSync(p);
  497. return `${s.dev}:${s.ino}`;
  498. } catch {
  499. return null;
  500. }
  501. }
  502. /**
  503. * Default database filename
  504. */
  505. export const DATABASE_FILENAME = 'codegraph.db';
  506. /**
  507. * SQLite's sidecar files in WAL mode — the write-ahead log and its shared-memory
  508. * index. They sit beside the main DB file and are removed alongside it when the
  509. * database is discarded (see `removeDatabaseFiles`).
  510. */
  511. const WAL_SIDECAR_SUFFIXES = ['-wal', '-shm'] as const;
  512. /**
  513. * Get the default database path for a project
  514. */
  515. export function getDatabasePath(projectRoot: string): string {
  516. return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME);
  517. }
  518. /**
  519. * Delete a database file and its WAL sidecars (`-wal`/`-shm`).
  520. *
  521. * This is how a FULL re-index discards an existing database — rather than
  522. * opening the old graph and DELETE-ing every row. On a large or pre-fix
  523. * poisoned index (e.g. an old graph that scanned an ignored gitlink corpus into
  524. * ~1.6M nodes with a multi-GB WAL, #1065) the per-row `nodes_fts` delete-trigger
  525. * churn blocks the main thread long enough to trip the #850 liveness watchdog
  526. * before indexing even starts, so the rebuild could never recover the bad state
  527. * (#1067). Unlinking is O(1) regardless of DB size and also reclaims the disk
  528. * the bloated WAL would otherwise keep.
  529. *
  530. * POSIX removes the directory entry even while another process (a daemon/MCP
  531. * server) still holds the file open; that holder heals via `reopenIfReplaced`
  532. * (#925). On Windows a live holder can make the unlink fail with EBUSY/EPERM —
  533. * that is thrown for the caller to surface ("stop the other process and retry").
  534. * The `-wal`/`-shm` sidecars are best-effort: SQLite recreates them on the next
  535. * open, so a leftover sidecar is harmless.
  536. */
  537. export function removeDatabaseFiles(dbPath: string): void {
  538. // The main DB file first — its removal is the operation that must succeed (or
  539. // report why it couldn't). force:true treats an already-missing file as done.
  540. fs.rmSync(dbPath, { force: true });
  541. for (const suffix of WAL_SIDECAR_SUFFIXES) {
  542. try {
  543. fs.rmSync(dbPath + suffix, { force: true });
  544. } catch {
  545. // A sidecar still held/locked is harmless — SQLite rebuilds it on open.
  546. }
  547. }
  548. }