Two environments that share one working tree — most concretely Windows and WSL — can't safely share a single `.codegraph/`: the daemon lockfile records a platform-specific pid + socket (named pipe vs Unix socket), and SQLite locking across the WSL2/Windows filesystem boundary is unreliable, so two daemons over one index risks corruption. Add a `CODEGRAPH_DIR` env var (default `.codegraph`) that overrides the per-project data directory name, so each environment keeps its own index in the same tree (e.g. `CODEGRAPH_DIR=.codegraph-win` on Windows). The name is resolved live and validated (rejects separators / `..` / absolute, falling back to the default with a one-time stderr warning). Indexing and file-watching now skip ANY `.codegraph-*` sibling so neither side trips over the other's data. Routes the previously-hardcoded `.codegraph` literals (db path, lockfile, error log, watcher ignore, file-scan skip, installer) through the resolver. No extraction-version bump — index content is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
7.2 KiB
TypeScript
246 lines
7.2 KiB
TypeScript
/**
|
|
* Database Layer
|
|
*
|
|
* Handles SQLite database initialization and connection management.
|
|
*/
|
|
|
|
import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { SchemaVersion } from '../types';
|
|
import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
|
|
import { getCodeGraphDir } from '../directory';
|
|
|
|
export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
|
|
|
|
/**
|
|
* Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
|
|
* paths can't drift.
|
|
*
|
|
* `busy_timeout` is set FIRST, before any pragma that might touch the database
|
|
* file (notably `journal_mode`). If another process holds a write lock at open
|
|
* time, the later pragmas — and the connection's first query — then wait out
|
|
* the lock instead of throwing "database is locked" immediately. See issue #238.
|
|
*
|
|
* The 5s window (was 120s) rides out a normal incremental sync; the old
|
|
* 2-minute wait presented as a frozen, hung agent. With WAL, reads never block
|
|
* on a writer, so this timeout only governs cross-process write contention
|
|
* (e.g. the git-hook `codegraph sync` running while the MCP server writes).
|
|
*/
|
|
function configureConnection(db: SqliteDatabase): void {
|
|
db.pragma('busy_timeout = 5000'); // MUST be first — see above
|
|
db.pragma('foreign_keys = ON');
|
|
db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform
|
|
db.pragma('synchronous = NORMAL'); // safe with WAL mode
|
|
db.pragma('cache_size = -64000'); // 64 MB page cache
|
|
db.pragma('temp_store = MEMORY'); // temp tables in memory
|
|
db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
|
|
}
|
|
|
|
/**
|
|
* Database connection wrapper with lifecycle management
|
|
*/
|
|
export class DatabaseConnection {
|
|
private db: SqliteDatabase;
|
|
private dbPath: string;
|
|
private backend: SqliteBackend;
|
|
|
|
private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) {
|
|
this.db = db;
|
|
this.dbPath = dbPath;
|
|
this.backend = backend;
|
|
}
|
|
|
|
/**
|
|
* Initialize a new database at the given path
|
|
*/
|
|
static initialize(dbPath: string): DatabaseConnection {
|
|
// Ensure parent directory exists
|
|
const dir = path.dirname(dbPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
// Create and configure database
|
|
const { db, backend } = createDatabase(dbPath);
|
|
|
|
configureConnection(db);
|
|
|
|
// Run schema initialization
|
|
const schemaPath = path.join(__dirname, 'schema.sql');
|
|
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
|
db.exec(schema);
|
|
|
|
// Record current schema version so migrations aren't re-applied on open
|
|
const currentVersion = getCurrentVersion(db);
|
|
if (currentVersion < CURRENT_SCHEMA_VERSION) {
|
|
db.prepare(
|
|
'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
|
|
).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations');
|
|
}
|
|
|
|
return new DatabaseConnection(db, dbPath, backend);
|
|
}
|
|
|
|
/**
|
|
* Open an existing database
|
|
*/
|
|
static open(dbPath: string): DatabaseConnection {
|
|
if (!fs.existsSync(dbPath)) {
|
|
throw new Error(`Database not found: ${dbPath}`);
|
|
}
|
|
|
|
const { db, backend } = createDatabase(dbPath);
|
|
|
|
configureConnection(db);
|
|
|
|
// Check and run migrations if needed
|
|
const conn = new DatabaseConnection(db, dbPath, backend);
|
|
const currentVersion = getCurrentVersion(db);
|
|
|
|
if (currentVersion < CURRENT_SCHEMA_VERSION) {
|
|
runMigrations(db, currentVersion);
|
|
}
|
|
|
|
return conn;
|
|
}
|
|
|
|
/**
|
|
* Get the underlying database instance
|
|
*/
|
|
getDb(): SqliteDatabase {
|
|
return this.db;
|
|
}
|
|
|
|
/**
|
|
* Get the SQLite backend serving this connection. Per-instance so
|
|
* MCP cross-project queries report the right backend even when
|
|
* multiple project DBs are open in the same process.
|
|
*/
|
|
getBackend(): SqliteBackend {
|
|
return this.backend;
|
|
}
|
|
|
|
/**
|
|
* Get database file path
|
|
*/
|
|
getPath(): string {
|
|
return this.dbPath;
|
|
}
|
|
|
|
/**
|
|
* The journal mode actually in effect (e.g. 'wal', 'delete').
|
|
*
|
|
* SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on
|
|
* filesystems without shared-memory support (some network/virtualized mounts,
|
|
* WSL2 /mnt), and always on the wasm backend. So the effective mode can differ
|
|
* from what `configureConnection` requested. Surfaced in `codegraph status` so
|
|
* a "database is locked" report is triageable: 'wal' ⇒ readers never block on a
|
|
* writer; anything else ⇒ they can. See issue #238.
|
|
*/
|
|
getJournalMode(): string {
|
|
const raw = this.db.pragma('journal_mode');
|
|
const row = Array.isArray(raw) ? raw[0] : raw;
|
|
const mode = row && typeof row === 'object'
|
|
? (row as Record<string, unknown>).journal_mode
|
|
: row;
|
|
return String(mode ?? '').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Get current schema version
|
|
*/
|
|
getSchemaVersion(): SchemaVersion | null {
|
|
const row = this.db
|
|
.prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1')
|
|
.get() as { version: number; applied_at: number; description: string | null } | undefined;
|
|
|
|
if (!row) return null;
|
|
|
|
return {
|
|
version: row.version,
|
|
appliedAt: row.applied_at,
|
|
description: row.description ?? undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute a function within a transaction
|
|
*/
|
|
transaction<T>(fn: () => T): T {
|
|
return this.db.transaction(fn)();
|
|
}
|
|
|
|
/**
|
|
* Get database file size in bytes
|
|
*/
|
|
getSize(): number {
|
|
const stats = fs.statSync(this.dbPath);
|
|
return stats.size;
|
|
}
|
|
|
|
/**
|
|
* Optimize database (vacuum and analyze)
|
|
*/
|
|
optimize(): void {
|
|
this.db.exec('VACUUM');
|
|
this.db.exec('ANALYZE');
|
|
}
|
|
|
|
/**
|
|
* Lightweight, non-blocking maintenance to run after bulk writes
|
|
* (indexAll, sync). Two operations:
|
|
*
|
|
* - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes
|
|
* tables whose row counts changed materially since the last
|
|
* ANALYZE. Without it, the query planner has no statistics on the
|
|
* freshly-bulk-loaded tables and can pick suboptimal indexes.
|
|
*
|
|
* - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
|
|
* into the main database file so the WAL file doesn't grow
|
|
* unboundedly between automatic checkpoints (auto-fires at 1000
|
|
* pages by default; large indexAll runs blow past that).
|
|
*
|
|
* Both operations are silently swallowed on failure — they're a
|
|
* best-effort optimization, never load-bearing for correctness.
|
|
*/
|
|
runMaintenance(): void {
|
|
try {
|
|
this.db.exec('PRAGMA optimize');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
this.db.exec('PRAGMA wal_checkpoint(PASSIVE)');
|
|
} catch {
|
|
// ignore (e.g., not in WAL mode)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close the database connection
|
|
*/
|
|
close(): void {
|
|
this.db.close();
|
|
}
|
|
|
|
/**
|
|
* Check if the database connection is open
|
|
*/
|
|
isOpen(): boolean {
|
|
return this.db.open;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default database filename
|
|
*/
|
|
export const DATABASE_FILENAME = 'codegraph.db';
|
|
|
|
/**
|
|
* Get the default database path for a project
|
|
*/
|
|
export function getDatabasePath(projectRoot: string): string {
|
|
return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME);
|
|
}
|