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
4 changes: 3 additions & 1 deletion .claude/hooks/enrich-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ if [ -z "$REL_PATH" ]; then
fi

# Guard: codegraph DB must exist
DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db"
# Use git worktree root so each worktree reads its own DB (avoids WAL contention)
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
DB_PATH="$WORK_ROOT/.codegraph/graph.db"
if [ ! -f "$DB_PATH" ]; then
exit 0
fi
Expand Down
8 changes: 5 additions & 3 deletions .claude/hooks/update-graph.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,18 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then
fi

# Guard: codegraph DB must exist (project has been built at least once)
DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db"
# Use git worktree root so each worktree uses its own DB (avoids WAL contention)
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
DB_PATH="$WORK_ROOT/.codegraph/graph.db"
if [ ! -f "$DB_PATH" ]; then
exit 0
fi

# Run incremental build (skips unchanged files via hash check)
if command -v codegraph &>/dev/null; then
codegraph build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true
codegraph build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true
else
node "${CLAUDE_PROJECT_DIR}/src/cli.js" build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true
node "${CLAUDE_PROJECT_DIR:-$WORK_ROOT}/src/cli.js" build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true
fi

exit 0
4 changes: 3 additions & 1 deletion docs/examples/claude-code-hooks/enrich-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ if [ -z "$REL_PATH" ]; then
fi

# Guard: codegraph DB must exist
DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db"
# Use git worktree root so each worktree reads its own DB (avoids WAL contention)
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
DB_PATH="$WORK_ROOT/.codegraph/graph.db"
if [ ! -f "$DB_PATH" ]; then
exit 0
fi
Expand Down
8 changes: 5 additions & 3 deletions docs/examples/claude-code-hooks/update-graph.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,18 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then
fi

# Guard: codegraph DB must exist (project has been built at least once)
DB_PATH="${CLAUDE_PROJECT_DIR:-.}/.codegraph/graph.db"
# Use git worktree root so each worktree uses its own DB (avoids WAL contention)
WORK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || WORK_ROOT="${CLAUDE_PROJECT_DIR:-.}"
DB_PATH="$WORK_ROOT/.codegraph/graph.db"
if [ ! -f "$DB_PATH" ]; then
exit 0
fi

# Run incremental build (skips unchanged files via hash check)
if command -v codegraph &>/dev/null; then
codegraph build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true
codegraph build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true
else
npx --yes @optave/codegraph build "${CLAUDE_PROJECT_DIR:-.}" -d "$DB_PATH" 2>/dev/null || true
npx --yes @optave/codegraph build "$WORK_ROOT" -d "$DB_PATH" 2>/dev/null || true
fi

exit 0
16 changes: 14 additions & 2 deletions src/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export function openReadonlyOrFail(customPath?: string): BetterSqlite3Database {
}
const Database = getDatabase();
const db = new Database(dbPath, { readonly: true }) as unknown as BetterSqlite3Database;
db.pragma('busy_timeout = 5000');

warnOnVersionMismatch(() => {
const row = db
Expand Down Expand Up @@ -378,7 +379,13 @@ export function openRepo(
// Re-throw user-visible errors (e.g. DB not found) — only silently
// fall back for native-engine failures (e.g. incompatible native binary).
if (e instanceof DbError) throw e;
debug(`openRepo: native path failed, falling back to better-sqlite3: ${toErrorMessage(e)}`);
// Re-throw locking/busy errors — falling back to better-sqlite3 would
// hit the same contention (and potentially hang without busy_timeout).
const msg = toErrorMessage(e);
if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) {
throw new DbError(`Database is busy (another process may be writing): ${msg}`, {});
}
debug(`openRepo: native path failed, falling back to better-sqlite3: ${msg}`);
}
}

Expand Down Expand Up @@ -412,7 +419,12 @@ export function openReadonlyWithNative(customPath?: string): {
const native = getNative();
nativeDb = native.NativeDatabase.openReadonly(dbPath);
} catch (e) {
debug(`openReadonlyWithNative: native path failed: ${toErrorMessage(e)}`);
const msg = toErrorMessage(e);
if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) {
debug(`openReadonlyWithNative: native path busy, skipping native DB: ${msg}`);
} else {
debug(`openReadonlyWithNative: native path failed: ${msg}`);
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions tests/unit/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,16 @@ describe('openReadonlyOrFail', () => {
expect(tables).toContain('nodes');
readDb.close();
});

it('sets busy_timeout pragma to 5000 on readonly connections', () => {
const dbPath = path.join(tmpDir, 'readonly-busy.db');
const db = openDb(dbPath);
initSchema(db);
closeDb(db);

const readDb = openReadonlyOrFail(dbPath);
const timeout = readDb.pragma('busy_timeout', { simple: true });
expect(timeout).toBe(5000);
readDb.close();
});
});
Comment on lines +384 to 395
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No test coverage for the openRepo locking-error re-throw

The new test validates busy_timeout on readonly connections (the openReadonlyOrFail fix), but there is no test for the second fix: the regex guard that re-throws locking errors in openRepo instead of silently falling back to better-sqlite3.

Consider adding a test that mocks openRepoNative (or isNativeAvailable) to throw an error with SQLITE_BUSY in the message and verifies that openRepo re-throws a DbError rather than silently degrading:

it('re-throws SQLITE_BUSY errors instead of falling back to better-sqlite3', () => {
  // ... mock isNativeAvailable() to return true and getNative() to throw SQLITE_BUSY
  expect(() => openRepo(dbPath)).toThrow(DbError);
  expect(() => openRepo(dbPath)).toThrow(/busy/i);
});

Without this, a regression that removes the regex guard would not be caught by the automated test suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 61fe866 — added tests/unit/openRepo-busy.test.ts which mocks the native engine to throw SQLITE_BUSY and verifies that openRepo re-throws it as a DbError (with code DB_ERROR and message containing SQLITE_BUSY) instead of silently falling back. Two test cases cover both the error message check and the DbError type/code assertions.

54 changes: 54 additions & 0 deletions tests/unit/openRepo-busy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Tests that openRepo re-throws SQLITE_BUSY errors from the native engine
* instead of silently falling back to better-sqlite3 (which could hang).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';

// Mock native module to simulate SQLITE_BUSY
vi.mock('../../src/infrastructure/native.js', () => ({
isNativeAvailable: () => true,
getNative: () => ({
NativeDatabase: {
openReadonly: () => {
throw new Error('Failed to open DB readonly: SQLITE_BUSY');
},
},
}),
}));

import { closeDb, initSchema, openDb, openRepo } from '../../src/db/index.js';

let tmpDir: string;
let dbPath: string;

beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-busy-'));
dbPath = path.join(tmpDir, 'graph.db');
const db = openDb(dbPath);
initSchema(db);
closeDb(db);
});

afterAll(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe('openRepo locking error handling', () => {
it('re-throws SQLITE_BUSY as DbError instead of falling back', () => {
expect(() => openRepo(dbPath)).toThrow(/Database is busy/);
});

it('thrown error is a DbError with code DB_ERROR', () => {
try {
openRepo(dbPath);
expect.unreachable('should have thrown');
} catch (err) {
expect(err.name).toBe('DbError');
expect(err.code).toBe('DB_ERROR');
expect(err.message).toContain('SQLITE_BUSY');
}
});
});
Loading