Skip to content

Commit 6a700b2

Browse files
fix: cli improvements (embed --db, DB locking, prerelease check, build logging) (#111)
- Add missing --db flag to `embed` command, passing customDbPath to buildEmbeddings - Add busy_timeout pragma (5s) and advisory lockfile to openDb/closeDb for concurrent access safety - Suppress update-check notifications for prerelease/dev versions (contains '-') - Log changed/removed file names at debug level during incremental builds - Use closeDb() instead of db.close() for proper lock file cleanup in cochange and embedder - Use openDb() in buildEmbeddings for consistent locking and busy_timeout Impact: 12 functions changed, 13 affected Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 9c3e3ba commit 6a700b2

9 files changed

Lines changed: 121 additions & 23 deletions

File tree

src/builder.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'node:fs';
33
import path from 'node:path';
44
import { loadConfig } from './config.js';
55
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
6-
import { getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js';
6+
import { closeDb, getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js';
77
import { readJournal, writeJournalHeader } from './journal.js';
88
import { debug, info, warn } from './logger.js';
99
import { getActiveEngine, parseFilesAuto } from './parser.js';
@@ -418,7 +418,7 @@ export async function buildGraph(rootDir, opts = {}) {
418418
}
419419
}
420420
info('No changes detected. Graph is up to date.');
421-
db.close();
421+
closeDb(db);
422422
writeJournalHeader(rootDir, Date.now());
423423
return;
424424
}
@@ -477,7 +477,9 @@ export async function buildGraph(rootDir, opts = {}) {
477477
info(
478478
`Incremental: ${parseChanges.length} changed, ${removed.length} removed${reverseDeps.size > 0 ? `, ${reverseDeps.size} reverse-deps` : ''}`,
479479
);
480-
480+
if (parseChanges.length > 0)
481+
debug(`Changed files: ${parseChanges.map((c) => c.relPath).join(', ')}`);
482+
if (removed.length > 0) debug(`Removed files: ${removed.join(', ')}`);
481483
// Remove embeddings/metrics/edges/nodes for changed and removed files
482484
// Embeddings must be deleted BEFORE nodes (we need node IDs to find them)
483485
const deleteEmbeddingsForFile = hasEmbeddings
@@ -1010,7 +1012,7 @@ export async function buildGraph(rootDir, opts = {}) {
10101012
debug(`Failed to write build metadata: ${err.message}`);
10111013
}
10121014

1013-
db.close();
1015+
closeDb(db);
10141016

10151017
// Write journal header after successful build
10161018
writeJournalHeader(rootDir, Date.now());

src/cli.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ program
470470
`Embedding strategy: ${EMBEDDING_STRATEGIES.join(', ')}. "structured" uses graph context (callers/callees), "source" embeds raw code`,
471471
'structured',
472472
)
473+
.option('-d, --db <path>', 'Path to graph.db')
473474
.action(async (dir, opts) => {
474475
if (!EMBEDDING_STRATEGIES.includes(opts.strategy)) {
475476
console.error(
@@ -479,7 +480,7 @@ program
479480
}
480481
const root = path.resolve(dir || '.');
481482
const model = opts.model || config.embeddings?.model || DEFAULT_MODEL;
482-
await buildEmbeddings(root, model, undefined, { strategy: opts.strategy });
483+
await buildEmbeddings(root, model, opts.db, { strategy: opts.strategy });
483484
});
484485

485486
program

src/cochange.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process';
99
import fs from 'node:fs';
1010
import path from 'node:path';
1111
import { normalizePath } from './constants.js';
12-
import { findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js';
12+
import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from './db.js';
1313
import { warn } from './logger.js';
1414
import { isTestFile } from './queries.js';
1515

@@ -145,7 +145,7 @@ export function analyzeCoChanges(customDbPath, opts = {}) {
145145
const repoRoot = path.resolve(path.dirname(dbPath), '..');
146146

147147
if (!fs.existsSync(path.join(repoRoot, '.git'))) {
148-
db.close();
148+
closeDb(db);
149149
return { error: `Not a git repository: ${repoRoot}` };
150150
}
151151

@@ -245,7 +245,7 @@ export function analyzeCoChanges(customDbPath, opts = {}) {
245245

246246
const totalPairs = db.prepare('SELECT COUNT(*) as cnt FROM co_changes').get().cnt;
247247

248-
db.close();
248+
closeDb(db);
249249

250250
return {
251251
pairsFound: totalPairs,
@@ -275,14 +275,14 @@ export function coChangeData(file, customDbPath, opts = {}) {
275275
try {
276276
db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
277277
} catch {
278-
db.close();
278+
closeDb(db);
279279
return { error: 'No co-change data found. Run `codegraph co-change --analyze` first.' };
280280
}
281281

282282
// Resolve file via partial match
283283
const resolvedFile = resolveCoChangeFile(db, file);
284284
if (!resolvedFile) {
285-
db.close();
285+
closeDb(db);
286286
return { error: `No co-change data found for file matching "${file}"` };
287287
}
288288

@@ -311,7 +311,7 @@ export function coChangeData(file, customDbPath, opts = {}) {
311311
}
312312

313313
const meta = getCoChangeMeta(db);
314-
db.close();
314+
closeDb(db);
315315

316316
return { file: resolvedFile, partners, meta };
317317
}
@@ -334,7 +334,7 @@ export function coChangeTopData(customDbPath, opts = {}) {
334334
try {
335335
db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
336336
} catch {
337-
db.close();
337+
closeDb(db);
338338
return { error: 'No co-change data found. Run `codegraph co-change --analyze` first.' };
339339
}
340340

@@ -363,7 +363,7 @@ export function coChangeTopData(customDbPath, opts = {}) {
363363
}
364364

365365
const meta = getCoChangeMeta(db);
366-
db.close();
366+
closeDb(db);
367367

368368
return { pairs, meta };
369369
}

src/db.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33
import Database from 'better-sqlite3';
4-
import { debug } from './logger.js';
4+
import { debug, warn } from './logger.js';
55

66
// ─── Schema Migrations ─────────────────────────────────────────────────
77
export const MIGRATIONS = [
@@ -134,11 +134,59 @@ export function setBuildMeta(db, entries) {
134134
export function openDb(dbPath) {
135135
const dir = path.dirname(dbPath);
136136
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
137+
acquireAdvisoryLock(dbPath);
137138
const db = new Database(dbPath);
138139
db.pragma('journal_mode = WAL');
140+
db.pragma('busy_timeout = 5000');
141+
db.__lockPath = `${dbPath}.lock`;
139142
return db;
140143
}
141144

145+
export function closeDb(db) {
146+
db.close();
147+
if (db.__lockPath) releaseAdvisoryLock(db.__lockPath);
148+
}
149+
150+
function isProcessAlive(pid) {
151+
try {
152+
process.kill(pid, 0);
153+
return true;
154+
} catch {
155+
return false;
156+
}
157+
}
158+
159+
function acquireAdvisoryLock(dbPath) {
160+
const lockPath = `${dbPath}.lock`;
161+
try {
162+
if (fs.existsSync(lockPath)) {
163+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
164+
const pid = Number(content);
165+
if (pid && pid !== process.pid && isProcessAlive(pid)) {
166+
warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`);
167+
}
168+
}
169+
} catch {
170+
/* ignore read errors */
171+
}
172+
try {
173+
fs.writeFileSync(lockPath, String(process.pid), 'utf-8');
174+
} catch {
175+
/* best-effort */
176+
}
177+
}
178+
179+
function releaseAdvisoryLock(lockPath) {
180+
try {
181+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
182+
if (Number(content) === process.pid) {
183+
fs.unlinkSync(lockPath);
184+
}
185+
} catch {
186+
/* ignore */
187+
}
188+
}
189+
142190
export function initSchema(db) {
143191
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`);
144192

src/embedder.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { execFileSync } from 'node:child_process';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { createInterface } from 'node:readline';
5-
import Database from 'better-sqlite3';
6-
import { findDbPath, openReadonlyOrFail } from './db.js';
5+
import { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db.js';
76
import { info, warn } from './logger.js';
87

98
/**
@@ -407,7 +406,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
407406
process.exit(1);
408407
}
409408

410-
const db = new Database(dbPath);
409+
const db = openDb(dbPath);
411410
initEmbeddingsSchema(db);
412411

413412
db.exec('DELETE FROM embeddings');
@@ -512,7 +511,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
512511
console.log(
513512
`\nStored ${vectors.length} embeddings (${dim}d, ${config.name}, strategy: ${strategy}) in graph.db`,
514513
);
515-
db.close();
514+
closeDb(db);
516515
}
517516

518517
/**

src/update-check.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export async function checkForUpdates(currentVersion, options = {}) {
109109
if (process.env.CI) return null;
110110
if (process.env.NO_UPDATE_CHECK) return null;
111111
if (!process.stderr.isTTY) return null;
112+
if (currentVersion.includes('-')) return null;
112113

113114
const cachePath = options.cachePath || CACHE_PATH;
114115
const fetchFn = options._fetchLatest || fetchLatestVersion;

src/watcher.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { readFileSafe } from './builder.js';
44
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
5-
import { initSchema, openDb } from './db.js';
5+
import { closeDb, initSchema, openDb } from './db.js';
66
import { appendJournalEntries } from './journal.js';
77
import { info, warn } from './logger.js';
88
import { createParseTreeCache, getActiveEngine, parseFileIncremental } from './parser.js';
@@ -261,7 +261,7 @@ export async function watchProject(rootDir, opts = {}) {
261261
}
262262
}
263263
if (cache) cache.clear();
264-
db.close();
264+
closeDb(db);
265265
process.exit(0);
266266
});
267267
}

tests/unit/db.test.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'node:path';
88
import Database from 'better-sqlite3';
99
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
1010
import {
11+
closeDb,
1112
findDbPath,
1213
getBuildMeta,
1314
initSchema,
@@ -74,7 +75,7 @@ describe('openDb', () => {
7475
const db = openDb(dbPath);
7576
expect(fs.existsSync(dbDir)).toBe(true);
7677
expect(db).toBeDefined();
77-
db.close();
78+
closeDb(db);
7879
});
7980

8081
it('returns a functional database', () => {
@@ -89,7 +90,25 @@ describe('openDb', () => {
8990
);
9091
const row = db.prepare('SELECT * FROM nodes WHERE name = ?').get('test');
9192
expect(row.name).toBe('test');
92-
db.close();
93+
closeDb(db);
94+
});
95+
96+
it('sets busy_timeout pragma to 5000', () => {
97+
const dbPath = path.join(tmpDir, 'busy-timeout.db');
98+
const db = openDb(dbPath);
99+
const timeout = db.pragma('busy_timeout', { simple: true });
100+
expect(timeout).toBe(5000);
101+
closeDb(db);
102+
});
103+
104+
it('creates lock file on open and removes on closeDb', () => {
105+
const dbPath = path.join(tmpDir, 'locktest.db');
106+
const lockPath = `${dbPath}.lock`;
107+
const db = openDb(dbPath);
108+
expect(fs.existsSync(lockPath)).toBe(true);
109+
expect(fs.readFileSync(lockPath, 'utf-8').trim()).toBe(String(process.pid));
110+
closeDb(db);
111+
expect(fs.existsSync(lockPath)).toBe(false);
93112
});
94113
});
95114

@@ -196,7 +215,7 @@ describe('openReadonlyOrFail', () => {
196215
const dbPath = path.join(tmpDir, 'readonly-test.db');
197216
const db = openDb(dbPath);
198217
initSchema(db);
199-
db.close();
218+
closeDb(db);
200219

201220
const readDb = openReadonlyOrFail(dbPath);
202221
expect(readDb).toBeDefined();

tests/unit/update-check.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,34 @@ describe('checkForUpdates', () => {
240240
}
241241
});
242242

243+
it('returns null for prerelease versions (e.g. beta)', async () => {
244+
const origIsTTY = process.stderr.isTTY;
245+
process.stderr.isTTY = true;
246+
try {
247+
const result = await checkForUpdates('2.0.0-beta.1', {
248+
cachePath,
249+
_fetchLatest: async () => '2.0.0',
250+
});
251+
expect(result).toBeNull();
252+
} finally {
253+
process.stderr.isTTY = origIsTTY;
254+
}
255+
});
256+
257+
it('returns null for dev versions (e.g. -dev)', async () => {
258+
const origIsTTY = process.stderr.isTTY;
259+
process.stderr.isTTY = true;
260+
try {
261+
const result = await checkForUpdates('1.5.0-dev', {
262+
cachePath,
263+
_fetchLatest: async () => '2.0.0',
264+
});
265+
expect(result).toBeNull();
266+
} finally {
267+
process.stderr.isTTY = origIsTTY;
268+
}
269+
});
270+
243271
it('returns null from fresh cache when version is current', async () => {
244272
const origIsTTY = process.stderr.isTTY;
245273
process.stderr.isTTY = true;

0 commit comments

Comments
 (0)