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
+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, ' +