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
13 changes: 9 additions & 4 deletions docs/2026-06-19-sqlite-axi-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
22 changes: 21 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Expand Down
64 changes: 64 additions & 0 deletions test/db-open-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
});
},
);
});