diff --git a/docs/2026-06-19-sqlite-axi-design.md b/docs/2026-06-19-sqlite-axi-design.md index fb407b9..ad7e0b5 100644 --- a/docs/2026-06-19-sqlite-axi-design.md +++ b/docs/2026-06-19-sqlite-axi-design.md @@ -33,9 +33,11 @@ if (!statSync(path, { throwIfNoEntry: false })?.isFile()) throw notFound(path); const db = new Database(path, { readonly: true }); ``` -A failed `new Database(...)` (corrupt/non-SQLite file) is translated to an AxiError, never a raw -throw. `node:sqlite` is intentionally avoided for v1 (Stability 1.2 release-candidate; raises the -engine floor above the Node 18-era baseline). +Open failures are translated to an AxiError, never a raw throw. Native addon load failures retain +their cause as `SQLITE_RUNTIME_ERROR`; unreadable, locked, or otherwise unavailable databases use +`DB_OPEN_ERROR`; and SQLite format or corruption errors use `INVALID_DB`. `node:sqlite` is +intentionally avoided for v1 (Stability 1.2 release-candidate; raises the engine floor above the +Node 18-era baseline). ## Architecture @@ -206,7 +208,10 @@ implementation. Tests cover weird column names and aliases (`select 1 as "a,b"`, | --- | --- | --- | | No database discovered | `NO_DATABASE` | 1 | | Multiple discovered, none chosen | `DB_AMBIGUOUS` | 2 | -| Path is not a file / not valid SQLite | `NOT_FOUND` / `INVALID_DB` | 1 | +| Path is not a file | `NOT_FOUND` | 1 | +| Invalid or corrupt SQLite file | `INVALID_DB` | 1 | +| Native SQLite addon cannot load | `SQLITE_RUNTIME_ERROR` | 1 | +| Database is unreadable, locked, or otherwise unavailable | `DB_OPEN_ERROR` | 1 | | Unknown table/view (schema/sample) | `NOT_FOUND` | 1 | | Non-read-only or multi-statement SQL | `READ_ONLY` | 2 | | SQL syntax / execution error | `QUERY_ERROR` | 1 | diff --git a/src/db.ts b/src/db.ts index b7d87c5..3575c57 100644 --- a/src/db.ts +++ b/src/db.ts @@ -17,8 +17,28 @@ export function openDb(path: string): DB { db = new Database(path, { readonly: true }); // better-sqlite3 opens lazily; force a header read so a non-SQLite file fails here. db.prepare("SELECT name FROM sqlite_master LIMIT 1").get(); - } catch { + } catch (error) { try { db?.close(); } catch { /* ignore close error */ } + const code = + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : ""; + const message = error instanceof Error ? error.message : String(error); + if (code === "ERR_DLOPEN_FAILED") { + throw new AxiError(`failed to load SQLite runtime: ${message}`, "SQLITE_RUNTIME_ERROR", [ + "Reinstall sqlite-axi after changing Node.js versions", + ]); + } + const invalidDatabase = + code === "SQLITE_NOTADB" || + code === "SQLITE_FORMAT" || + code === "SQLITE_CORRUPT" || + code.startsWith("SQLITE_CORRUPT_"); + if (!invalidDatabase) { + throw new AxiError(`failed to open SQLite database: ${path}: ${message}`, "DB_OPEN_ERROR", [ + "Check that the database is readable and not locked", + ]); + } throw new AxiError(`not a valid SQLite database: ${path}`, "INVALID_DB", [ "Confirm the file is a SQLite database", ]); diff --git a/test/db-open-errors.test.ts b/test/db-open-errors.test.ts new file mode 100644 index 0000000..2bba777 --- /dev/null +++ b/test/db-open-errors.test.ts @@ -0,0 +1,64 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +const { databaseError } = vi.hoisted(() => ({ + databaseError: { current: undefined as Error | undefined }, +})); + +vi.mock("better-sqlite3", () => ({ + default: class { + constructor() { + throw databaseError.current; + } + }, +})); + +import { openDb } from "../src/db.js"; + +function captureOpenError(): unknown { + try { + openDb(fileURLToPath(import.meta.url)); + } catch (error) { + return error; + } + throw new Error("expected openDb to throw"); +} + +describe("database open errors", () => { + it("reports native binding failures as SQLite runtime errors", () => { + databaseError.current = Object.assign( + new Error("The module was compiled against a different Node.js version"), + { code: "ERR_DLOPEN_FAILED" }, + ); + + expect(captureOpenError()).toMatchObject({ + code: "SQLITE_RUNTIME_ERROR", + message: expect.stringContaining("different Node.js version"), + suggestions: ["Reinstall sqlite-axi after changing Node.js versions"], + }); + }); + + it("reports database availability failures without blaming the file format", () => { + databaseError.current = Object.assign(new Error("database is locked"), { + code: "SQLITE_BUSY", + }); + + expect(captureOpenError()).toMatchObject({ + code: "DB_OPEN_ERROR", + message: expect.stringContaining("database is locked"), + suggestions: ["Check that the database is readable and not locked"], + }); + }); + + it.each(["SQLITE_CORRUPT", "SQLITE_CORRUPT_INDEX", "SQLITE_FORMAT"])( + "retains INVALID_DB for %s database errors", + (code) => { + databaseError.current = Object.assign(new Error("database format is invalid"), { code }); + + expect(captureOpenError()).toMatchObject({ + code: "INVALID_DB", + suggestions: ["Confirm the file is a SQLite database"], + }); + }, + ); +});