Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 2 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>', 'Path to graph.db')
.action(async (dir, opts) => {
if (!EMBEDDING_STRATEGIES.includes(opts.strategy)) {
console.error(
Expand All @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/cochange.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}` };
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}"` };
}

Expand Down Expand Up @@ -311,7 +311,7 @@ export function coChangeData(file, customDbPath, opts = {}) {
}

const meta = getCoChangeMeta(db);
db.close();
closeDb(db);

return { file: resolvedFile, partners, meta };
}
Expand All @@ -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.' };
}

Expand Down Expand Up @@ -363,7 +363,7 @@ export function coChangeTopData(customDbPath, opts = {}) {
}

const meta = getCoChangeMeta(db);
db.close();
closeDb(db);

return { pairs, meta };
}
Expand Down
50 changes: 49 additions & 1 deletion src/db.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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)`);

Expand Down
7 changes: 3 additions & 4 deletions src/embedder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/update-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -261,7 +261,7 @@ export async function watchProject(rootDir, opts = {}) {
}
}
if (cache) cache.clear();
db.close();
closeDb(db);
process.exit(0);
});
}
25 changes: 22 additions & 3 deletions tests/unit/db.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/update-check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down