diff --git a/src/builder.js b/src/builder.js index c4d1322c3..12a4ec4bf 100644 --- a/src/builder.js +++ b/src/builder.js @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadConfig } from './config.js'; import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js'; -import { getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js'; +import { closeDb, getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js'; import { readJournal, writeJournalHeader } from './journal.js'; import { debug, info, warn } from './logger.js'; import { getActiveEngine, parseFilesAuto } from './parser.js'; @@ -418,7 +418,7 @@ export async function buildGraph(rootDir, opts = {}) { } } info('No changes detected. Graph is up to date.'); - db.close(); + closeDb(db); writeJournalHeader(rootDir, Date.now()); return; } @@ -477,7 +477,9 @@ export async function buildGraph(rootDir, opts = {}) { info( `Incremental: ${parseChanges.length} changed, ${removed.length} removed${reverseDeps.size > 0 ? `, ${reverseDeps.size} reverse-deps` : ''}`, ); - + if (parseChanges.length > 0) + debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`); + if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`); // Remove embeddings/metrics/edges/nodes for changed and removed files // Embeddings must be deleted BEFORE nodes (we need node IDs to find them) const deleteEmbeddingsForFile = hasEmbeddings @@ -1010,7 +1012,7 @@ export async function buildGraph(rootDir, opts = {}) { debug(`Failed to write build metadata: ${err.message}`); } - db.close(); + closeDb(db); // Write journal header after successful build writeJournalHeader(rootDir, Date.now()); diff --git a/src/cli.js b/src/cli.js index 11e973472..5e22731a3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -470,6 +470,7 @@ program `Embedding strategy: ${EMBEDDING_STRATEGIES.join(', ')}. "structured" uses graph context (callers/callees), "source" embeds raw code`, 'structured', ) + .option('-d, --db ', 'Path to graph.db') .action(async (dir, opts) => { if (!EMBEDDING_STRATEGIES.includes(opts.strategy)) { console.error( @@ -479,7 +480,7 @@ program } const root = path.resolve(dir || '.'); const model = opts.model || config.embeddings?.model || DEFAULT_MODEL; - await buildEmbeddings(root, model, undefined, { strategy: opts.strategy }); + await buildEmbeddings(root, model, opts.db, { strategy: opts.strategy }); }); program diff --git a/src/cochange.js b/src/cochange.js index 259547697..b08ce8db5 100644 --- a/src/cochange.js +++ b/src/cochange.js @@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { normalizePath } from './constants.js'; -import { findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js'; +import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js'; import { warn } from './logger.js'; import { isTestFile } from './queries.js'; @@ -145,7 +145,7 @@ export function analyzeCoChanges(customDbPath, opts = {}) { const repoRoot = path.resolve(path.dirname(dbPath), '..'); if (!fs.existsSync(path.join(repoRoot, '.git'))) { - db.close(); + closeDb(db); return { error: `Not a git repository: ${repoRoot}` }; } @@ -245,7 +245,7 @@ export function analyzeCoChanges(customDbPath, opts = {}) { const totalPairs = db.prepare('SELECT COUNT(*) as cnt FROM co_changes').get().cnt; - db.close(); + closeDb(db); return { pairsFound: totalPairs, @@ -275,14 +275,14 @@ export function coChangeData(file, customDbPath, opts = {}) { try { db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); } catch { - db.close(); + closeDb(db); return { error: 'No co-change data found. Run `codegraph co-change --analyze` first.' }; } // Resolve file via partial match const resolvedFile = resolveCoChangeFile(db, file); if (!resolvedFile) { - db.close(); + closeDb(db); return { error: `No co-change data found for file matching "${file}"` }; } @@ -311,7 +311,7 @@ export function coChangeData(file, customDbPath, opts = {}) { } const meta = getCoChangeMeta(db); - db.close(); + closeDb(db); return { file: resolvedFile, partners, meta }; } @@ -334,7 +334,7 @@ export function coChangeTopData(customDbPath, opts = {}) { try { db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); } catch { - db.close(); + closeDb(db); return { error: 'No co-change data found. Run `codegraph co-change --analyze` first.' }; } @@ -363,7 +363,7 @@ export function coChangeTopData(customDbPath, opts = {}) { } const meta = getCoChangeMeta(db); - db.close(); + closeDb(db); return { pairs, meta }; } diff --git a/src/db.js b/src/db.js index f5f373a14..236e0d7c6 100644 --- a/src/db.js +++ b/src/db.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import Database from 'better-sqlite3'; -import { debug } from './logger.js'; +import { debug, warn } from './logger.js'; // ─── Schema Migrations ───────────────────────────────────────────────── export const MIGRATIONS = [ @@ -134,11 +134,59 @@ export function setBuildMeta(db, entries) { export function openDb(dbPath) { const dir = path.dirname(dbPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + acquireAdvisoryLock(dbPath); const db = new Database(dbPath); db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + db.__lockPath = `${dbPath}.lock`; return db; } +export function closeDb(db) { + db.close(); + if (db.__lockPath) releaseAdvisoryLock(db.__lockPath); +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function acquireAdvisoryLock(dbPath) { + const lockPath = `${dbPath}.lock`; + try { + if (fs.existsSync(lockPath)) { + const content = fs.readFileSync(lockPath, 'utf-8').trim(); + const pid = Number(content); + if (pid && pid !== process.pid && isProcessAlive(pid)) { + warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`); + } + } + } catch { + /* ignore read errors */ + } + try { + fs.writeFileSync(lockPath, String(process.pid), 'utf-8'); + } catch { + /* best-effort */ + } +} + +function releaseAdvisoryLock(lockPath) { + try { + const content = fs.readFileSync(lockPath, 'utf-8').trim(); + if (Number(content) === process.pid) { + fs.unlinkSync(lockPath); + } + } catch { + /* ignore */ + } +} + export function initSchema(db) { db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`); diff --git a/src/embedder.js b/src/embedder.js index 6e2a38293..4b9d43f06 100644 --- a/src/embedder.js +++ b/src/embedder.js @@ -2,8 +2,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { createInterface } from 'node:readline'; -import Database from 'better-sqlite3'; -import { findDbPath, openReadonlyOrFail } from './db.js'; +import { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db.js'; import { info, warn } from './logger.js'; /** @@ -407,7 +406,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options = process.exit(1); } - const db = new Database(dbPath); + const db = openDb(dbPath); initEmbeddingsSchema(db); db.exec('DELETE FROM embeddings'); @@ -512,7 +511,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options = console.log( `\nStored ${vectors.length} embeddings (${dim}d, ${config.name}, strategy: ${strategy}) in graph.db`, ); - db.close(); + closeDb(db); } /** diff --git a/src/update-check.js b/src/update-check.js index 07b5f8a06..7956b1983 100644 --- a/src/update-check.js +++ b/src/update-check.js @@ -109,6 +109,7 @@ export async function checkForUpdates(currentVersion, options = {}) { if (process.env.CI) return null; if (process.env.NO_UPDATE_CHECK) return null; if (!process.stderr.isTTY) return null; + if (currentVersion.includes('-')) return null; const cachePath = options.cachePath || CACHE_PATH; const fetchFn = options._fetchLatest || fetchLatestVersion; diff --git a/src/watcher.js b/src/watcher.js index 0afe05e0d..8ee5726cd 100644 --- a/src/watcher.js +++ b/src/watcher.js @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { readFileSafe } from './builder.js'; import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js'; -import { initSchema, openDb } from './db.js'; +import { closeDb, initSchema, openDb } from './db.js'; import { appendJournalEntries } from './journal.js'; import { info, warn } from './logger.js'; import { createParseTreeCache, getActiveEngine, parseFileIncremental } from './parser.js'; @@ -261,7 +261,7 @@ export async function watchProject(rootDir, opts = {}) { } } if (cache) cache.clear(); - db.close(); + closeDb(db); process.exit(0); }); } diff --git a/tests/unit/db.test.js b/tests/unit/db.test.js index 315f52590..10fcbcde8 100644 --- a/tests/unit/db.test.js +++ b/tests/unit/db.test.js @@ -8,6 +8,7 @@ import path from 'node:path'; import Database from 'better-sqlite3'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { + closeDb, findDbPath, getBuildMeta, initSchema, @@ -74,7 +75,7 @@ describe('openDb', () => { const db = openDb(dbPath); expect(fs.existsSync(dbDir)).toBe(true); expect(db).toBeDefined(); - db.close(); + closeDb(db); }); it('returns a functional database', () => { @@ -89,7 +90,25 @@ describe('openDb', () => { ); const row = db.prepare('SELECT * FROM nodes WHERE name = ?').get('test'); expect(row.name).toBe('test'); - db.close(); + closeDb(db); + }); + + it('sets busy_timeout pragma to 5000', () => { + const dbPath = path.join(tmpDir, 'busy-timeout.db'); + const db = openDb(dbPath); + const timeout = db.pragma('busy_timeout', { simple: true }); + expect(timeout).toBe(5000); + closeDb(db); + }); + + it('creates lock file on open and removes on closeDb', () => { + const dbPath = path.join(tmpDir, 'locktest.db'); + const lockPath = `${dbPath}.lock`; + const db = openDb(dbPath); + expect(fs.existsSync(lockPath)).toBe(true); + expect(fs.readFileSync(lockPath, 'utf-8').trim()).toBe(String(process.pid)); + closeDb(db); + expect(fs.existsSync(lockPath)).toBe(false); }); }); @@ -196,7 +215,7 @@ describe('openReadonlyOrFail', () => { const dbPath = path.join(tmpDir, 'readonly-test.db'); const db = openDb(dbPath); initSchema(db); - db.close(); + closeDb(db); const readDb = openReadonlyOrFail(dbPath); expect(readDb).toBeDefined(); diff --git a/tests/unit/update-check.test.js b/tests/unit/update-check.test.js index fe4b54af8..1bd41cff9 100644 --- a/tests/unit/update-check.test.js +++ b/tests/unit/update-check.test.js @@ -240,6 +240,34 @@ describe('checkForUpdates', () => { } }); + it('returns null for prerelease versions (e.g. beta)', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('2.0.0-beta.1', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + + it('returns null for dev versions (e.g. -dev)', async () => { + const origIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + try { + const result = await checkForUpdates('1.5.0-dev', { + cachePath, + _fetchLatest: async () => '2.0.0', + }); + expect(result).toBeNull(); + } finally { + process.stderr.isTTY = origIsTTY; + } + }); + it('returns null from fresh cache when version is current', async () => { const origIsTTY = process.stderr.isTTY; process.stderr.isTTY = true;