fix(db): a database SQLite can't open is reported as that, not as a Node version problem (#2796)

Root cause: createDatabase() caught everything the node:sqlite connection
threw and reported it as "CodeGraph requires node:sqlite (Node.js 22.5+)",
though the module had loaded and SQLite itself had failed: a database
another program held open (SQLITE_CANTOPEN on Windows), a folder the user
can't write, an I/O error on a network share. Failures in the connection's
first statements (locked, not a database, a WAL I/O error) came out raw,
without the path. The MCP server then told the agent "No CodeGraph project
is loaded" for a default project whose index exists, or "Tool execution
failed: ... requires node:sqlite" for a projectPath one. Meanwhile a runtime
that really lacks node:sqlite never got the hint: isInitialized's schema
probe loaded the module outside its try, so the CLI and MCP server died with
"Unhandled rejection: No such built-in module: node:sqlite".

Fix:
- src/db/node-sqlite.ts loads node:sqlite in one place;
  NodeSqliteUnavailableError carries the Node 22.5+ hint, now only for a
  module that cannot be loaded. The schema probe treats it as "unknown", so
  the command goes on to the open and reports it from inside its own catch.
- src/db/open-error.ts: DatabaseOpenError names SQLite's code and the path,
  with a hint for BUSY, PERM, READONLY, IOERR (network share), CORRUPT,
  FULL, CANTOPEN (folder, permissions, another program holding the file)
  and NOTADB, keeping code/errcode/errstr and the cause. The Windows/WSL
  shared-index guidance still wins for its case.
- DatabaseConnection.open/initialize wrap the connection's first statements
  as well as the constructor, and close the connection on failure.
- MCP: the default project's open failure is reported to a tool call instead
  of "no project loaded", and a DatabaseOpenError or a missing node:sqlite
  is answered isError with the retry-once note (a malfunction, per
  AGENTS.md), worded as what failed rather than "an internal codegraph
  error". Not-indexed projects keep their success-shaped guidance.

Validation:
- Windows, an indexed project whose codegraph.db another process holds open
  with no sharing (a real SQLITE_CANTOPEN): CLI status, sync and query, an
  MCP default-project call and a projectPath call all said "requires
  node:sqlite" on main, or "No CodeGraph project is loaded" for the default
  project; with the fix all name SQLITE_CANTOPEN, the path and the hint.
  With --no-experimental-sqlite (node:sqlite unloadable), main's CLI and MCP
  server died with an unhandled rejection; the fix prints the Node 22.5+
  explanation and the MCP call answers with it.
- New __tests__/sqlite-open-error.test.ts (21 cases): main's source 18 fail
  / 3 pass, fix 21 pass. Real CANTOPEN (a directory, a file held
  exclusively), NOTADB and BUSY opens, the CLI and the MCP engine, plus
  each SQLite code's message. 5 mutations each failed at least one case.
- The POSIX-only wsl-shared-index case for a real I/O error outside WSL now
  expects the named error (DatabaseOpenError, SQLITE_IOERR, the path) instead
  of the bare "disk I/O error"; it is skipped on Windows, so it was not run
  here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-22 10:56:50 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1040d09f26
commit 63ca5681db
10 changed files with 505 additions and 25 deletions
+1
View File
@@ -42,6 +42,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The shared background server is no longer killed with "Main thread unresponsive" while it brings another project's index up to date, such as one a session names with `projectPath`: only its own project's index counted as progress, so a long update of any other looked like a hang, and the server was stopped and restarted again and again. Every index the server keeps up to date now counts, and so does the one a session updates on its own when the background server is unavailable. Thanks @mradwankhalil for the report and the first fix. (#2404)
- An index left incomplete by an indexing run that was stopped partway, by a crash, an out-of-memory kill or the server's own hang check, no longer gets the background server killed with "Main thread unresponsive" every time it starts: on a large project, restoring the database indexes the run had set aside took longer than the hang check allows, so each restart died at the same point and the index was never repaired. They are now restored in the background, and the project is served once they are back. Thanks @cbeaulieu-gt for the report and the first fix. (#1887)
- A new agent session no longer waits up to about 12 minutes for its first answer when the background server is stuck: a server whose process still holds its connection open but never responds made the session wait out a long series of retries. The session now gives up within seconds and answers on its own, and switches back to the background server once that has been restarted.
- When CodeGraph can't open a project's database, it now says what went wrong: SQLite's error, the file's path, and what to check, such as another program holding the file open, a folder you can't write to, or a network share. Before, every such failure read "CodeGraph requires node:sqlite (Node.js 22.5+)", and an agent was told no project was loaded. That hint now appears only when Node really can't load its built-in SQLite, a case that used to end in an unhandled error instead.
- `codegraph upgrade` no longer wipes your agent instructions file, such as a global `AGENTS.md`, `CLAUDE.md` or `GEMINI.md`, when it refreshes CodeGraph's section in it: a stray `<!-- CODEGRAPH_START -->` line above that section, like one left behind when part of an older section was deleted by hand, made the refresh replace everything in between, often the whole file. CodeGraph now rewrites only its own section, in `codegraph install` and `codegraph uninstall` too, and keeps everything around it exactly as it was, Windows line endings included. Thanks @pzaj2 for the report. (#2506)
- `codegraph install`, `upgrade` and `uninstall` now write through an instructions or config file that is a symbolic link, such as a `~/.codex/AGENTS.md` or `~/.claude.json` linked from your dotfiles, instead of replacing the link with a copy: the shared file gets CodeGraph's settings, uninstall takes them out again, and the link stays in place. A link CodeGraph can't write through, like one to a read-only file or into a folder that doesn't exist, is now reported by name and left alone, and an upgrade still refreshes your other agents. Thanks @jguerra6 for the report and @danusha2345 for the first fix. (#2721)
- `codegraph install` and `upgrade` no longer break Codex's `config.toml` when CodeGraph's `[mcp_servers.codegraph]` entry is written differently from how CodeGraph writes it, such as indented, with spaces or quotes inside the brackets, or with Windows line endings: the entry is now updated in place, where before a second copy was added and Codex could load none of the file. A file already broken that way is repaired on the next install or upgrade, an example of the entry inside a multiline setting such as `developer_instructions` is no longer mistaken for it, uninstall removes the entry however it is written, and an entry you wrote with dotted keys or as an inline table is left as you wrote it. When your `mcp_servers` settings are one inline table, CodeGraph now says it can't add itself instead of breaking the file. Thanks @dencoseca for the report, and @danusha2345 and @rudycelekli for the first fixes. (#2562)
+286
View File
@@ -0,0 +1,286 @@
/**
* A database SQLite cannot open is reported as what went wrong, with the path
* and a short hint, not as "CodeGraph requires node:sqlite (Node.js 22.5+)".
*
* Before, every failure inside the SQLite connection's constructor came out as
* that Node-version hint, so a database another program held open, one in a
* folder the user can't write, or one on a network share sent users after
* their Node version. A failure in the connection's first statements (locked,
* not a database, an I/O error) came out raw, with no path. The hint is now
* for a runtime that cannot load node:sqlite at all; the Windows/WSL shared
* index keeps its own guidance.
*/
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { DatabaseConnection, DatabaseOpenError, NodeSqliteUnavailableError, getDatabasePath } from '../src/db';
import { toDatabaseOpenError } from '../src/db/open-error';
import { WslSharedIndexError } from '../src/db/wsl-shared-index';
import { __setWslWindowsDriveForTests } from '../src/sync/watch-policy';
// Through require: vite does not resolve the node:sqlite builtin.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite') as { DatabaseSync: new (path: string) => { exec(sql: string): void; close(): void } };
const DIST = path.resolve(__dirname, '../dist');
const BIN = path.join(DIST, 'bin', 'codegraph.js');
const { MCPEngine } = require(path.join(DIST, 'mcp', 'engine.js')) as typeof import('../src/mcp/engine');
beforeAll(() => {
if (!fs.existsSync(BIN)) throw new Error('Build the project first (npm run build).');
});
const dirs: string[] = [];
const cleanups: Array<() => void | Promise<void>> = [];
afterEach(async () => {
for (const fn of cleanups.splice(0).reverse()) await fn();
__setWslWindowsDriveForTests(null);
for (const dir of dirs.splice(0)) {
const deadline = Date.now() + 10_000;
for (;;) {
try {
fs.rmSync(dir, { recursive: true, force: true });
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code ?? '';
if (!['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code) || Date.now() > deadline) throw error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
});
function tempDir(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-open-error-')));
dirs.push(dir);
return dir;
}
/** An indexed project, closed again. */
async function indexedProject(): Promise<{ root: string; dbPath: string }> {
const root = tempDir();
fs.writeFileSync(path.join(root, 'a.ts'), 'export function alpha(): number { return 1; }\n');
(await CodeGraph.init(root, { index: true })).close();
return { root, dbPath: getDatabasePath(root) };
}
/** A node:sqlite error as SQLite reports it. */
function sqliteError(errcode: number, message: string): Error {
return Object.assign(new Error(message), { code: 'ERR_SQLITE_ERROR', errcode, errstr: message });
}
/**
* Hold `file` open with no sharing, as a backup or antivirus scan can on
* Windows (libuv's UV_FS_O_EXLOCK): SQLite then can't open it at all.
*/
function holdExclusively(file: string): void {
const fd = fs.openSync(file, fs.constants.O_RDWR | 0x10000000);
cleanups.push(() => fs.closeSync(fd));
}
/**
* Lock the database from another connection so that no other can read it:
* exclusive locking mode, inside a write transaction.
*/
function lockDatabase(dbPath: string): void {
const holder = new DatabaseSync(dbPath);
holder.exec('PRAGMA locking_mode = EXCLUSIVE');
holder.exec('BEGIN EXCLUSIVE');
holder.exec('CREATE TABLE IF NOT EXISTS hold_2795 (x)');
cleanups.push(() => { holder.exec('ROLLBACK'); holder.close(); });
}
function thrownBy(fn: () => unknown): unknown {
try {
fn();
} catch (err) {
return err;
}
throw new Error('expected a throw');
}
describe('naming a failed open', () => {
const dbPath = path.join('/projects', 'app', '.codegraph', 'codegraph.db');
it.each([
[14, 'unable to open database file', 'SQLITE_CANTOPEN', 'Check that the folder exists'],
[526, 'unable to open database file', 'SQLITE_CANTOPEN', 'holds the file open'],
[10, 'disk I/O error', 'SQLITE_IOERR', 'network share'],
[3850, 'disk I/O error', 'SQLITE_IOERR', 'local disk'],
[5, 'database is locked', 'SQLITE_BUSY', 'locked'],
[26, 'file is not a database', 'SQLITE_NOTADB', 'codegraph init'],
[11, 'database disk image is malformed', 'SQLITE_CORRUPT', 'codegraph index'],
[8, 'attempt to write a readonly database', 'SQLITE_READONLY', 'read-only'],
])('reports SQLite code %i as %s, with the path and a hint', (errcode, message, name, hint) => {
const original = sqliteError(errcode, message);
const err = toDatabaseOpenError(original, dbPath) as DatabaseOpenError;
expect(err).toBeInstanceOf(DatabaseOpenError);
expect(err.message).toContain(dbPath);
expect(err.message).toContain(`${message} (${name}).`);
expect(err.message).toContain(hint);
expect(err.message).not.toContain('node:sqlite');
expect(err.sqliteCode).toBe(name);
// Callers branching on SQLite's codes see what they saw before.
expect(err.code).toBe('ERR_SQLITE_ERROR');
expect(err.errcode).toBe(errcode);
expect(err.cause).toBe(original);
});
it('names a code it has no hint for, without one', () => {
const err = toDatabaseOpenError(sqliteError(1, 'no such table: nodes'), dbPath) as DatabaseOpenError;
expect(err.message).toBe(`Could not open the CodeGraph database at ${dbPath}: no such table: nodes (SQLITE_ERROR).`);
});
it('leaves alone what SQLite did not report', () => {
const plain = new TypeError('The "path" argument must be of type string');
expect(toDatabaseOpenError(plain, dbPath)).toBe(plain);
const missing = new NodeSqliteUnavailableError(new Error('No such built-in module: node:sqlite'));
expect(toDatabaseOpenError(missing, dbPath)).toBe(missing);
});
it('keeps the Windows/WSL guidance for an index the two share', () => {
__setWslWindowsDriveForTests(() => true);
const err = toDatabaseOpenError(sqliteError(10, 'disk I/O error'), '/mnt/c/app/.codegraph/codegraph.db');
expect(err).toBeInstanceOf(WslSharedIndexError);
expect(toDatabaseOpenError(err, '/mnt/c/app/.codegraph/codegraph.db')).toBe(err);
});
it('keeps the node:sqlite explanation for a runtime that cannot load it', () => {
const err = new NodeSqliteUnavailableError(new Error('No such built-in module: node:sqlite'));
expect(err.message).toContain('CodeGraph requires node:sqlite (Node.js 22.5+)');
expect(err.message).toContain('No such built-in module: node:sqlite');
});
});
describe('a database SQLite cannot open', () => {
it('reports a path SQLite cannot open as that, not as a Node version problem', () => {
// A directory where the file should be: SQLITE_CANTOPEN in the open itself.
const dbPath = path.join(tempDir(), 'codegraph.db');
fs.mkdirSync(dbPath);
const err = thrownBy(() => DatabaseConnection.open(dbPath)) as Error;
expect(err).toBeInstanceOf(DatabaseOpenError);
expect(err.message).toContain(`Could not open the CodeGraph database at ${dbPath}: unable to open database file (SQLITE_CANTOPEN).`);
expect(err.message).not.toContain('node:sqlite');
});
it('reports a file that is not a database, with its path', () => {
const dbPath = path.join(tempDir(), 'codegraph.db');
fs.writeFileSync(dbPath, 'not a database '.repeat(300));
const err = thrownBy(() => DatabaseConnection.open(dbPath)) as DatabaseOpenError;
expect(err).toBeInstanceOf(DatabaseOpenError);
expect(err.sqliteCode).toBe('SQLITE_NOTADB');
expect(err.message).toContain(dbPath);
expect(err.message).toContain('codegraph init');
});
it('reports a database another connection keeps locked, and opens it once released', async () => {
const { dbPath } = await indexedProject();
lockDatabase(dbPath);
const err = thrownBy(() => DatabaseConnection.open(dbPath)) as DatabaseOpenError;
expect(err).toBeInstanceOf(DatabaseOpenError);
expect(err.sqliteCode).toBe('SQLITE_BUSY');
expect(err.message).toContain(dbPath);
await cleanups.pop()!();
DatabaseConnection.open(dbPath).close();
}, 30_000);
it.runIf(process.platform === 'win32')('reports a database another program holds open on Windows', async () => {
const { root, dbPath } = await indexedProject();
holdExclusively(dbPath);
let err: unknown;
try {
(await CodeGraph.open(root)).close();
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(DatabaseOpenError);
expect((err as Error).message).toContain(`Could not open the CodeGraph database at ${dbPath}: unable to open database file (SQLITE_CANTOPEN).`);
expect((err as Error).message).toContain('holds the file open');
expect((err as Error).message).not.toContain('node:sqlite');
}, 30_000);
});
/** Run the built CLI; output with colour codes removed. */
function cli(args: string[], nodeFlags: string[] = []): { status: number | null; output: string } {
const r = spawnSync(process.execPath, [...nodeFlags, '--liftoff-only', '--disable-warning=ExperimentalWarning', BIN, ...args], {
encoding: 'utf8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_UPDATE_CHECK: '1', NO_COLOR: '1' },
timeout: 120_000,
});
return { status: r.status, output: `${r.stdout}${r.stderr}`.replace(/\x1b\[[0-9;]*m/g, '') };
}
describe('the CLI', () => {
it.runIf(process.platform === 'win32')('names the SQLite failure in status, sync and query', async () => {
const { root, dbPath } = await indexedProject();
holdExclusively(dbPath);
for (const args of [['status', root], ['sync', root], ['query', 'alpha', '--path', root]]) {
const { status, output } = cli(args);
expect(status, output).not.toBe(0);
expect(output).toContain(`Could not open the CodeGraph database at ${dbPath}`);
expect(output).toContain('(SQLITE_CANTOPEN)');
expect(output).not.toContain('requires node:sqlite');
}
}, 120_000);
it('explains a runtime without node:sqlite, without an unhandled rejection', async () => {
const { root } = await indexedProject();
for (const args of [['status', root], ['sync', root]]) {
const { status, output } = cli(args, ['--no-experimental-sqlite']);
expect(status, output).not.toBe(0);
expect(output).toContain('CodeGraph requires node:sqlite (Node.js 22.5+)');
expect(output).not.toContain('Unhandled rejection');
}
}, 120_000);
});
describe('the MCP server', () => {
const engines: Array<{ stop(): Promise<void> }> = [];
afterEach(async () => {
for (const engine of engines.splice(0)) await engine.stop();
});
const text = (r: { content: Array<{ text: string }> }): string => r.content.map((c) => c.text).join('\n');
it('answers a call on the default project with why its index could not be opened', async () => {
const { root, dbPath } = await indexedProject();
lockDatabase(dbPath);
const engine = new MCPEngine({ watch: false });
engines.push(engine);
await engine.ensureInitialized(root);
expect(engine.hasDefaultCodeGraph()).toBe(false);
const result = await engine.getToolHandler().execute('codegraph_search', { query: 'alpha' });
// A malfunction: isError, with the retry-once note, saying what failed.
expect(result.isError).toBe(true);
expect(text(result)).toContain(`Could not open the CodeGraph database at ${dbPath}`);
expect(text(result)).toContain('(SQLITE_BUSY)');
expect(text(result)).toContain('Retry the call once');
expect(text(result)).not.toContain('No CodeGraph project is loaded');
expect(text(result)).not.toContain('internal codegraph error');
}, 60_000);
it('answers a projectPath call with why that index could not be opened', async () => {
const { root, dbPath } = await indexedProject();
lockDatabase(dbPath);
const engine = new MCPEngine({ watch: false });
engines.push(engine);
const result = await engine.getToolHandler().execute('codegraph_search', { query: 'alpha', projectPath: root });
expect(result.isError).toBe(true);
expect(text(result)).toContain(`Could not open the CodeGraph database at ${dbPath}`);
expect(text(result)).toContain('(SQLITE_BUSY)');
expect(text(result)).toContain('Retry the call once');
expect(text(result)).not.toContain('node:sqlite');
}, 60_000);
it('still answers an unindexed default project with the not-indexed guidance', async () => {
const root = tempDir();
const engine = new MCPEngine({ watch: false });
engines.push(engine);
await engine.ensureInitialized(root);
const result = await engine.getToolHandler().execute('codegraph_search', { query: 'alpha' });
expect(result.isError).toBeFalsy();
expect(text(result)).toContain('No CodeGraph project is loaded');
}, 30_000);
});
+6 -4
View File
@@ -25,7 +25,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { DatabaseConnection } from '../src/db';
import { DatabaseConnection, DatabaseOpenError } from '../src/db';
import { WslSharedIndexError, isSqliteIoError, toWslSharedIndexError } from '../src/db/wsl-shared-index';
import { codeGraphDirNameFor, findNearestCodeGraphRoot, getCodeGraphDir, isInitialized } from '../src/directory';
import { __setWslWindowsDriveForTests, isWslWindowsDrive } from '../src/sync/watch-policy';
@@ -252,7 +252,7 @@ describe.runIf(process.platform !== 'win32')('a real SQLite I/O error on open',
expect((thrown as Error).message).toContain('CODEGRAPH_DIR=.codegraph-wsl');
});
it('stays the raw SQLite error everywhere else', () => {
it('stays the SQLite error everywhere else, named and placed', () => {
__setWslWindowsDriveForTests(() => false);
let thrown: unknown;
try {
@@ -260,9 +260,11 @@ describe.runIf(process.platform !== 'win32')('a real SQLite I/O error on open',
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown).toBeInstanceOf(DatabaseOpenError);
expect(thrown).not.toBeInstanceOf(WslSharedIndexError);
expect((thrown as Error).message).toBe('disk I/O error');
// SQLite's own failure with the path, not the WSL guidance.
expect((thrown as Error).message).toContain(`${dbPath}: disk I/O error (SQLITE_IOERR).`);
expect((thrown as Error).message).not.toContain('CODEGRAPH_DIR');
});
describe('through the MCP engine', () => {
+32 -1
View File
@@ -10,8 +10,11 @@ import * as path from 'path';
import { SchemaVersion } from '../types';
import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
import { getCodeGraphDir, statInode } from '../directory';
import { toDatabaseOpenError } from './open-error';
export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
export { DatabaseOpenError } from './open-error';
export { NodeSqliteUnavailableError } from './node-sqlite';
/**
* Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
@@ -44,6 +47,22 @@ function configureConnection(db: SqliteDatabase, readOnly = false): void {
db.pragma(`journal_size_limit = ${WAL_HEAL_THRESHOLD_BYTES}`);
}
/**
* Run the rest of an open on the connection `db`. Its first statements read
* the file, so a database SQLite cannot use (locked, damaged, not a database,
* an I/O error) fails here as well as when the connection is made. Such a
* failure names SQLite's code and the path ({@link DatabaseOpenError}), and
* leaves no connection open.
*/
function withOpenFailures<T>(db: SqliteDatabase, dbPath: string, finish: () => T): T {
try {
return finish();
} catch (err) {
try { db.close(); } catch { /* the open's failure is the one to report */ }
throw toDatabaseOpenError(err, dbPath);
}
}
/**
* WAL size past which `healOversizedWal` (run at every `open`) checkpoints and
* truncates the file, and to which `journal_size_limit` clips the WAL after any
@@ -107,9 +126,12 @@ export class DatabaseConnection {
fs.mkdirSync(dir, { recursive: true });
}
// Create and configure database
const { db, backend } = createDatabase(dbPath);
return withOpenFailures(db, dbPath, () => DatabaseConnection.initializeOn(db, backend, dbPath));
}
/** The rest of {@link initialize}: configure the connection and create the schema. */
private static initializeOn(db: SqliteDatabase, backend: SqliteBackend, dbPath: string): DatabaseConnection {
configureConnection(db);
// Run schema initialization, splitting FTS5 from the rest so
@@ -175,7 +197,16 @@ export class DatabaseConnection {
}
const { db, backend } = createDatabase(dbPath, options);
return withOpenFailures(db, dbPath, () => DatabaseConnection.openOn(db, backend, dbPath, options));
}
/** The rest of {@link open}: configure the connection, then migrate and repair a writable one. */
private static openOn(
db: SqliteDatabase,
backend: SqliteBackend,
dbPath: string,
options: { readOnly?: boolean; deferIndexRecovery?: boolean },
): DatabaseConnection {
configureConnection(db, options.readOnly);
// Detect FTS5 availability for search fallback (#1532)
+36
View File
@@ -0,0 +1,36 @@
/**
* Node's built-in `node:sqlite`, loaded in one place, so that a runtime
* without it fails with the one explanation that fits it: CodeGraph needs
* Node.js 22.5+, or its own bundled runtime. Any other failure to open a
* database is SQLite's own, and is reported as such (see ./open-error.ts).
*/
/** The parts of `node:sqlite` CodeGraph uses. */
export interface NodeSqliteModule {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => any;
}
/** `node:sqlite` is missing or could not be loaded in this Node.js runtime. */
export class NodeSqliteUnavailableError extends Error {
constructor(cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
super(
`Node.js ${process.version} could not load its built-in SQLite module (${reason}).\n` +
'CodeGraph requires node:sqlite (Node.js 22.5+). Install the self-contained CodeGraph ' +
'release (it bundles a compatible Node), or run on Node 22.5+.',
{ cause }
);
this.name = 'NodeSqliteUnavailableError';
}
}
/** Load `node:sqlite`, or throw {@link NodeSqliteUnavailableError}. */
export function loadNodeSqlite(): NodeSqliteModule {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('node:sqlite') as NodeSqliteModule;
} catch (err) {
throw new NodeSqliteUnavailableError(err);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Opening a CodeGraph database failed in SQLite itself: report what SQLite
* said, with the database's path and a short hint at the usual cause. Before,
* every such failure came out as "CodeGraph requires node:sqlite (Node.js
* 22.5+)", which sent users after their Node version when the file was locked,
* unreadable, or on a network share. That hint is now only for a runtime that
* cannot load `node:sqlite` at all (./node-sqlite.ts); an index Windows and WSL
* share keeps its own guidance (./wsl-shared-index.ts).
*/
import { NodeSqliteUnavailableError } from './node-sqlite';
import { toWslSharedIndexError, WslSharedIndexError } from './wsl-shared-index';
/** SQLite's primary result codes, by number. node:sqlite reports the extended code; its low byte is the primary one. */
const SQLITE_CODE_NAMES: readonly string[] = [
'SQLITE_OK', 'SQLITE_ERROR', 'SQLITE_INTERNAL', 'SQLITE_PERM', 'SQLITE_ABORT', 'SQLITE_BUSY',
'SQLITE_LOCKED', 'SQLITE_NOMEM', 'SQLITE_READONLY', 'SQLITE_INTERRUPT', 'SQLITE_IOERR',
'SQLITE_CORRUPT', 'SQLITE_NOTFOUND', 'SQLITE_FULL', 'SQLITE_CANTOPEN', 'SQLITE_PROTOCOL',
'SQLITE_EMPTY', 'SQLITE_SCHEMA', 'SQLITE_TOOBIG', 'SQLITE_CONSTRAINT', 'SQLITE_MISMATCH',
'SQLITE_MISUSE', 'SQLITE_NOLFS', 'SQLITE_AUTH', 'SQLITE_FORMAT', 'SQLITE_RANGE', 'SQLITE_NOTADB',
];
/** What to check, for the failures an open meets in practice. */
const HINTS: Readonly<Record<string, string>> = {
SQLITE_BUSY: 'Another process has the database locked; retry when it is done.',
SQLITE_PERM: 'Check that you can read and write the file and its folder.',
SQLITE_READONLY: 'The database or its folder is read-only, and CodeGraph has to write its index there.',
SQLITE_IOERR:
'SQLite could not read or write the file. On a network share, or another filesystem without ' +
'reliable file locking, keep the project and its index on a local disk.',
SQLITE_CORRUPT: 'The database is damaged; run "codegraph index" to rebuild it.',
SQLITE_FULL: 'The disk is full.',
SQLITE_CANTOPEN:
'Check that the folder exists and that you can read and write it, and that no other program ' +
'(a backup or antivirus scan, say) holds the file open.',
SQLITE_NOTADB: 'The file is not a SQLite database; move it aside and run "codegraph init" to build a new index.',
};
/** A failure SQLite reported while opening the database at `dbPath`. */
export class DatabaseOpenError extends Error {
/** node:sqlite's error code ("ERR_SQLITE_ERROR"), kept from the original. */
readonly code: unknown;
/** SQLite's extended result code, kept from the original. */
readonly errcode: unknown;
readonly errstr: unknown;
/** SQLite's name for the primary result code, e.g. "SQLITE_CANTOPEN". */
readonly sqliteCode: string;
readonly dbPath: string;
constructor(original: Error, dbPath: string) {
const codes = original as Error & { code?: unknown; errcode?: unknown; errstr?: unknown };
const primary = typeof codes.errcode === 'number' ? codes.errcode & 0xff : -1;
const sqliteCode = SQLITE_CODE_NAMES[primary] ?? `SQLite error ${String(codes.errcode)}`;
const hint = HINTS[sqliteCode];
super(
`Could not open the CodeGraph database at ${dbPath}: ${original.message} (${sqliteCode}).${hint ? ` ${hint}` : ''}`,
{ cause: original }
);
this.name = 'DatabaseOpenError';
this.code = codes.code;
this.errcode = codes.errcode;
this.errstr = codes.errstr;
this.sqliteCode = sqliteCode;
this.dbPath = dbPath;
}
}
/**
* The error to throw for `err`, raised while opening the database at
* `dbPath`: the Windows/WSL guidance for an index the two share, a
* {@link DatabaseOpenError} for any other failure SQLite reported (it carries
* a result code), and anything else unchanged, the missing-`node:sqlite`
* explanation included.
*/
export function toDatabaseOpenError(err: unknown, dbPath: string): unknown {
if (err instanceof DatabaseOpenError || err instanceof WslSharedIndexError || err instanceof NodeSqliteUnavailableError) {
return err;
}
const shared = toWslSharedIndexError(err, dbPath);
if (shared) return shared;
if (err instanceof Error && typeof (err as { errcode?: unknown }).errcode === 'number') {
return new DatabaseOpenError(err, dbPath);
}
return err;
}
/**
* A project's index exists but could not be opened: what a caller reports as
* that, not as "no index here". The Windows/WSL case has its own guidance.
*/
export function isDatabaseOpenFailure(err: unknown): err is DatabaseOpenError | NodeSqliteUnavailableError {
return err instanceof DatabaseOpenError || err instanceof NodeSqliteUnavailableError;
}
+6 -12
View File
@@ -10,6 +10,8 @@
* wasm fallback. When run from source instead, it requires Node >= 22.5.
*/
import { loadNodeSqlite } from './node-sqlite';
import { toDatabaseOpenError } from './open-error';
import { toWslSharedIndexError } from './wsl-shared-index';
export interface SqliteStatement {
@@ -57,8 +59,7 @@ class NodeSqliteAdapter implements SqliteDatabase {
private readonly _dbPath: string;
constructor(dbPath: string, opts?: { readOnly?: boolean }) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
const { DatabaseSync } = loadNodeSqlite();
this._dbPath = dbPath;
this._db = opts?.readOnly ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
}
@@ -200,15 +201,8 @@ export function createDatabase(dbPath: string, opts?: { readOnly?: boolean }): {
try {
return { db: new NodeSqliteAdapter(dbPath, opts), backend: 'node-sqlite' };
} catch (error) {
// node:sqlite loaded and SQLite itself failed — not a missing-module case.
const shared = toWslSharedIndexError(error, dbPath);
if (shared) throw shared;
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
'Failed to open SQLite via the built-in node:sqlite module.\n' +
'CodeGraph requires node:sqlite (Node.js 22.5+). Install the self-contained\n' +
'CodeGraph release (it bundles a compatible Node), or run on Node 22.5+.\n' +
`Underlying error: ${msg}`
);
// A runtime without node:sqlite says so (NodeSqliteUnavailableError);
// anything SQLite itself reported names its code and the path.
throw toDatabaseOpenError(error, dbPath);
}
}
+4 -2
View File
@@ -8,6 +8,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isWslWindowsDrive } from './sync/watch-policy';
import { loadNodeSqlite } from './db/node-sqlite';
/** The default per-project data directory name. */
export const DEFAULT_CODEGRAPH_DIR = '.codegraph';
@@ -214,10 +215,11 @@ function hasCodeGraphSchema(dbPath: string, st: fs.Stats): boolean {
* second connection opened and closed here leaves the first one's locks alone.
*/
function probeSchema(dbPath: string): 'schema' | 'no-schema' | 'not-sqlite' | 'unknown' {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
let db: any = null;
try {
// A runtime without node:sqlite can't inspect the file: `unknown`, so the
// command goes on to open it and reports why that is impossible.
const { DatabaseSync } = loadNodeSqlite();
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get();
return row !== undefined ? 'schema' : 'no-schema';
+13 -2
View File
@@ -16,6 +16,8 @@ import type CodeGraph from '../index';
import { resolveServerRoot } from '../directory';
import { ToolHandler } from './tools';
import { WslSharedIndexError } from '../db/wsl-shared-index';
import { DatabaseOpenError, isDatabaseOpenFailure } from '../db/open-error';
import type { NodeSqliteUnavailableError } from '../db/node-sqlite';
import { assertNoRebuild, releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
import { QueryPool, resolvePoolSize } from './query-pool';
import { endFreshnessMeasurements } from './index-freshness';
@@ -33,6 +35,15 @@ const loadCodeGraph = (): typeof import('../index').default =>
/** How often the per-tool-call retry may re-run the sub-project down-scan. */
const RETRY_SUBSCAN_TTL_MS = 5_000;
/**
* Why the default project failed to open, when a tool call should say so
* rather than "no project loaded": the Windows/WSL shared index (#995), or an
* index that exists but SQLite could not open, or no node:sqlite at all.
*/
function reportableOpenFailure(err: unknown): WslSharedIndexError | DatabaseOpenError | NodeSqliteUnavailableError | null {
return err instanceof WslSharedIndexError || isDatabaseOpenFailure(err) ? err : null;
}
export interface MCPEngineOptions {
/** Serve existing index contents without syncing, watching, or claiming a writer slot. */
readOnly?: boolean;
@@ -265,7 +276,7 @@ export class MCPEngine {
}
} catch (err) {
// Still failing — caller will try again on the next tool call.
this.toolHandler.setDefaultOpenFailure(err instanceof WslSharedIndexError ? err : null);
this.toolHandler.setDefaultOpenFailure(reportableOpenFailure(err));
}
}
@@ -396,7 +407,7 @@ export class MCPEngine {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[CodeGraph MCP] Failed to open project at ${resolvedRoot}: ${msg}\n`);
// The agent otherwise hears only "no project loaded" (#995).
this.toolHandler.setDefaultOpenFailure(err instanceof WslSharedIndexError ? err : null);
this.toolHandler.setDefaultOpenFailure(reportableOpenFailure(err));
}
}
+28 -4
View File
@@ -8,6 +8,8 @@ import type CodeGraph from '../index';
import type { QueryPool } from './query-pool';
import { findNearestCodeGraphRoot, isSameIndexRoot } from '../directory';
import { WslSharedIndexError } from '../db/wsl-shared-index';
import { DatabaseOpenError, isDatabaseOpenFailure } from '../db/open-error';
import type { NodeSqliteUnavailableError } from '../db/node-sqlite';
// Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same
// helper in engine.ts. ToolHandler must load to answer tools/list (static
// schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
@@ -105,6 +107,20 @@ export class NotIndexedError extends Error {}
* {@link NotIndexedError} it answers SUCCESS-shaped — never `isError`, which
* would teach the agent to abandon codegraph for projects that work fine.
*/
/**
* A project whose index exists but could not be opened: SQLite's own failure,
* with the path and what to check, or a runtime without node:sqlite. A real
* malfunction, so it stays `isError` with the retry-once note, but it says
* what failed instead of calling it an internal error.
*/
function databaseOpenFailureText(err: DatabaseOpenError | NodeSqliteUnavailableError): string {
return (
`${err.message}\n` +
'Retry the call once; if it persists, continue without codegraph for this task and pass ' +
'the message above on to the user.'
);
}
function wslSharedIndexGuidance(err: WslSharedIndexError): string {
return (
`${err.message}\n\n` +
@@ -2021,9 +2037,10 @@ export class ToolHandler {
private knownSubprojects: string[] = [];
private knownSubprojectsBase: string | null = null;
// Why the default project failed to open, when that is worth telling the
// agent instead of "no project loaded" — today only the Windows/WSL
// shared-index error (#995). Engine-maintained; cleared by a successful open.
private defaultOpenFailure: WslSharedIndexError | null = null;
// agent instead of "no project loaded": the Windows/WSL shared-index error
// (#995), or an index that exists but could not be opened (SQLite's own
// failure, or no node:sqlite). Engine-maintained; cleared by a successful open.
private defaultOpenFailure: WslSharedIndexError | DatabaseOpenError | NodeSqliteUnavailableError | null = null;
// Per-start-path cache of the git worktree/index mismatch (issue #155). The
// mismatch is a fixed property of (where the request came from → which
// .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -2083,7 +2100,7 @@ export class ToolHandler {
* call that needs it answers with that fix rather than "no project loaded".
* `null` clears it.
*/
setDefaultOpenFailure(err: WslSharedIndexError | null): void {
setDefaultOpenFailure(err: WslSharedIndexError | DatabaseOpenError | NodeSqliteUnavailableError | null): void {
this.defaultOpenFailure = err;
}
@@ -3021,6 +3038,10 @@ export class ToolHandler {
if (err instanceof PathRefusalError) {
return this.errorResult(err.message);
}
// An index that exists but could not be opened: a malfunction, said as what it is.
if (isDatabaseOpenFailure(err)) {
return this.errorResult(databaseOpenFailureText(err));
}
return this.errorResult(
`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
'This is an internal codegraph error — retry the call once; if it persists, ' +
@@ -3109,6 +3130,9 @@ export class ToolHandler {
if (err instanceof PathRefusalError) {
return this.errorResult(err.message);
}
if (isDatabaseOpenFailure(err)) {
return this.errorResult(databaseOpenFailureText(err));
}
return this.errorResult(
`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
'This is an internal codegraph error — retry the call once; if it persists, ' +