index.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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. private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) {
  44. this.db = db;
  45. this.dbPath = dbPath;
  46. this.backend = backend;
  47. }
  48. /**
  49. * Initialize a new database at the given path
  50. */
  51. static initialize(dbPath: string): DatabaseConnection {
  52. // Ensure parent directory exists
  53. const dir = path.dirname(dbPath);
  54. if (!fs.existsSync(dir)) {
  55. fs.mkdirSync(dir, { recursive: true });
  56. }
  57. // Create and configure database
  58. const { db, backend } = createDatabase(dbPath);
  59. configureConnection(db);
  60. // Run schema initialization
  61. const schemaPath = path.join(__dirname, 'schema.sql');
  62. const schema = fs.readFileSync(schemaPath, 'utf-8');
  63. db.exec(schema);
  64. // Record current schema version so migrations aren't re-applied on open
  65. const currentVersion = getCurrentVersion(db);
  66. if (currentVersion < CURRENT_SCHEMA_VERSION) {
  67. db.prepare(
  68. 'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
  69. ).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
  70. }
  71. return new DatabaseConnection(db, dbPath, backend);
  72. }
  73. /**
  74. * Open an existing database
  75. */
  76. static open(dbPath: string): DatabaseConnection {
  77. if (!fs.existsSync(dbPath)) {
  78. throw new Error(`Database not found: ${dbPath}`);
  79. }
  80. const { db, backend } = createDatabase(dbPath);
  81. configureConnection(db);
  82. // Check and run migrations if needed
  83. const conn = new DatabaseConnection(db, dbPath, backend);
  84. const currentVersion = getCurrentVersion(db);
  85. if (currentVersion < CURRENT_SCHEMA_VERSION) {
  86. runMigrations(db, currentVersion);
  87. }
  88. return conn;
  89. }
  90. /**
  91. * Get the underlying database instance
  92. */
  93. getDb(): SqliteDatabase {
  94. return this.db;
  95. }
  96. /**
  97. * Get the SQLite backend serving this connection. Per-instance so
  98. * MCP cross-project queries report the right backend even when
  99. * multiple project DBs are open in the same process.
  100. */
  101. getBackend(): SqliteBackend {
  102. return this.backend;
  103. }
  104. /**
  105. * Get database file path
  106. */
  107. getPath(): string {
  108. return this.dbPath;
  109. }
  110. /**
  111. * The journal mode actually in effect (e.g. 'wal', 'delete').
  112. *
  113. * SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on
  114. * filesystems without shared-memory support (some network/virtualized mounts,
  115. * WSL2 /mnt), and always on the wasm backend. So the effective mode can differ
  116. * from what `configureConnection` requested. Surfaced in `codegraph status` so
  117. * a "database is locked" report is triageable: 'wal' ⇒ readers never block on a
  118. * writer; anything else ⇒ they can. See issue #238.
  119. */
  120. getJournalMode(): string {
  121. const raw = this.db.pragma('journal_mode');
  122. const row = Array.isArray(raw) ? raw[0] : raw;
  123. const mode = row && typeof row === 'object'
  124. ? (row as Record<string, unknown>).journal_mode
  125. : row;
  126. return String(mode ?? '').toLowerCase();
  127. }
  128. /**
  129. * Get current schema version
  130. */
  131. getSchemaVersion(): SchemaVersion | null {
  132. const row = this.db
  133. .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1')
  134. .get() as { version: number; applied_at: number; description: string | null } | undefined;
  135. if (!row) return null;
  136. return {
  137. version: row.version,
  138. appliedAt: row.applied_at,
  139. description: row.description ?? undefined,
  140. };
  141. }
  142. /**
  143. * Execute a function within a transaction
  144. */
  145. transaction<T>(fn: () => T): T {
  146. return this.db.transaction(fn)();
  147. }
  148. /**
  149. * Get database file size in bytes
  150. */
  151. getSize(): number {
  152. const stats = fs.statSync(this.dbPath);
  153. return stats.size;
  154. }
  155. /**
  156. * Optimize database (vacuum and analyze)
  157. */
  158. optimize(): void {
  159. this.db.exec('VACUUM');
  160. this.db.exec('ANALYZE');
  161. }
  162. /**
  163. * Lightweight, non-blocking maintenance to run after bulk writes
  164. * (indexAll, sync). Two operations:
  165. *
  166. * - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes
  167. * tables whose row counts changed materially since the last
  168. * ANALYZE. Without it, the query planner has no statistics on the
  169. * freshly-bulk-loaded tables and can pick suboptimal indexes.
  170. *
  171. * - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
  172. * into the main database file so the WAL file doesn't grow
  173. * unboundedly between automatic checkpoints (auto-fires at 1000
  174. * pages by default; large indexAll runs blow past that).
  175. *
  176. * Both operations are silently swallowed on failure — they're a
  177. * best-effort optimization, never load-bearing for correctness.
  178. */
  179. runMaintenance(): void {
  180. try {
  181. this.db.exec('PRAGMA optimize');
  182. } catch {
  183. // ignore
  184. }
  185. try {
  186. this.db.exec('PRAGMA wal_checkpoint(PASSIVE)');
  187. } catch {
  188. // ignore (e.g., not in WAL mode)
  189. }
  190. }
  191. /**
  192. * Close the database connection
  193. */
  194. close(): void {
  195. this.db.close();
  196. }
  197. /**
  198. * Check if the database connection is open
  199. */
  200. isOpen(): boolean {
  201. return this.db.open;
  202. }
  203. }
  204. /**
  205. * Default database filename
  206. */
  207. export const DATABASE_FILENAME = 'codegraph.db';
  208. /**
  209. * Get the default database path for a project
  210. */
  211. export function getDatabasePath(projectRoot: string): string {
  212. return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME);
  213. }