Skip to content

Commit 7f99616

Browse files
fix(driver-sql): a SQLite file that is not a database fails connect() at once, naming it, and a dev boot no longer renames and replaces it (#25006)
Fixes #24960 Clause-②: no (narrowing) **Narrowing face:** a `SqlDriver.connect()` that resolved now refuses. On a SQLite file whose bytes are not a SQLite database, `connect()` used to log and swallow `SQLITE_NOTADB` and resolve; it now rejects with `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), in development and production alike. On a development boot, `resolveSqliteDriver` re-throws that refusal instead of stepping down to wasm SQLite, so the operator's file is no longer renamed to `FILE.corrupt-TIMESTAMP` and replaced with an empty database. Nothing exported changes shape. Route A, the card's seat ruling, as dispatched: the producer refuses the file, and the step-down classifies the code. B (consumer-only sniffing) and C (keep the wasm quarantine as dev recovery) are not taken. ## What changed - `packages/drivers/driver-sql/src/sql-driver.ts`, the `applyAutoVacuumIncremental` region only. The first-statement codes that are a verdict on the file now sit in one table, `SQLITE_FILE_REFUSALS`: `SQLITE_CANTOPEN` (as #23696 shipped it, same words) and `SQLITE_NOTADB` (new, "is not a SQLite database, and was left as it is"). Either throws `SqliteDatabaseFileError` before anything is written. Every other PRAGMA failure keeps today's warning and `return false`. - `packages/drivers/driver-sql/src/sqlite-database-file.ts`: the class doc names the new code. Doc only. - `packages/services/service-datasource/src/sqlite-driver-fallback.ts`: `isSqliteFileOpenFailure` adds `SQLITE_NOTADB`, with its doc list and one sentence in the module note. This is the cross-lane line for `domain:services` that the dispatching seat declared. `resolveSqliteAbsentFileTarget`, `knexConfigFor` and the probe body are untouched, because #24961 is changing those in parallel. A local `git merge-tree` of this head with that branch's head `28d5dd67e` is clean. - `.changeset/24960-sqlite-notadb-connect.md`: `patch` for `@objectstack/driver-sql` and `@objectstack/service-datasource`. It carries the `Clause-②: no (narrowing)` line, the BREAKING banner with the operator's fix, and an ADR-0087 `not-required (no-migration-prescription)` disposition. - Two new pin files: `packages/drivers/driver-sql/src/sql-driver-24960-sqlite-not-a-database.test.ts` and `packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-not-a-database.test.ts`. ## Premise, measured (better-sqlite3 13.0.3, Node v22.22.0) - **H1 holds.** On a plain-text file, better-sqlite3's `new Database(file)` succeeds, and the first statement, `PRAGMA auto_vacuum`, throws `code: 'SQLITE_NOTADB'` ("file is not a database"). So do `PRAGMA journal_mode` and `SELECT 1`. A file whose 100-byte header was overwritten past the magic string answers the same code. On base, `applyAutoVacuumIncremental` matched only `SQLITE_CANTOPEN`, logged "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and returned `false`, so `connect()` resolved. - **H2 holds.** With the code in `isSqliteFileOpenFailure`, the development probe re-throws at the native rung, and wasm's `quarantineCorruptFile` is never reached for a declared datasource whose addon loads. The pins assert that the file's bytes are unchanged and that the directory holds only that file: no `.corrupt-*` sibling, no journal, no WAL. - **H3 holds.** The card's "native better-sqlite3 unavailable (ABI mismatch or not built)" line is the step-down's own `NATIVE_SQLITE_WASM_FALLBACK_WARNING` ("dev using wasm SQLite"). The driver's similar warning fires only for a native load failure, which `SQLITE_NOTADB` is not. With this change the step-down never runs on this path, so the warning no longer prints: the pin asserts `warn` was never called, and the public-door run below prints neither line. - **Does any caller rely on `connect()` resolving over a file that is not a database?** I found none. I grepped the tree for `NOTADB` / "not a database" / `SQLITE_CORRUPT` / "disk image is malformed", for tests that write arbitrary bytes into a `.db` file, and read every `resolveSqliteDriver` caller. - `os db clean`: it called `resolveSqliteDriver({ dev: true })` and then VACUUMed. On base, a garbage file was moved aside, and the "clean" reported the empty replacement's size. Now it refuses the file, which is the better answer. - The development telemetry sibling (`telemetry-datasource.ts`): it already drops its separation on any throw, `catch` then `undefined`, the same as for #24950's codes. The boot continues. - `dev-organization-ownership-upgrade.test.ts` writes text into `dev.db` but drives a fake CLI, and no driver opens it. - `TursoDriver` local and replica modes open the file through this `SqlDriver.connect()`. Measured: a file written by `@libsql/client` with an `encryptionKey` answers better-sqlite3 with `SQLITE_NOTADB`. Such a datasource is now refused at connect, naming the file. Before, connect resolved and every read through Knex failed with the same code, so nothing that worked stops working. ## Evidence **Pins** (all at head `6d3dd6247`, under `os-verify-lock`): - driver-sql, `sql-driver-24960-sqlite-not-a-database.test.ts`: a plain-text file is refused with `SqliteDatabaseFileError`. The pin checks `code` = `SQLITE_NOTADB`, `filename`, a message naming the file and carrying the cause, the same bytes after, and the directory listing equal to the one file. It runs under both `NODE_ENV=development` and `NODE_ENV=production`. Controls: a real database reconnects and answers, and an empty file connects. Result: 8/8 with the #23696 file. - service-datasource, `sqlite-driver-fallback-not-a-database.test.ts`: - development `resolveSqliteDriver` rejects with the driver's refusal, warns nothing, and leaves the file untouched; - production returns native unprobed, and its `connect()` refuses the file; - `DatasourceConnectionService.connectDeclared` reports `availability: 'failed'` with a reason naming the file and registers no driver, in development and production, and the file is untouched. Result: 24/24 with the #24950 file and `sqlite-driver-fallback.test.ts`. - Whole packages at `c5b499cf0`, before merging `main`: driver-sql "Test Files 226 passed | 12 skipped (238) · Tests 3696 passed | 209 skipped (3905)" (the skips are the live PG/MySQL cells); service-datasource "Test Files 51 passed (51) · Tests 912 passed (912)". `tsc --noEmit` exit 0 for both. `tsc --listFiles` includes each new test file (1 hit each). - The merge of `origin/main` at `4fe3af9d7` brought #24778, #24970 and #24990. They touch spec, objectql, ui and docs, none of these packages, and the targeted pins above were re-run on the merge head. **Ablation** (`scripts/ablation-replace.mjs` WRAP with a trap restore, at `c5b499cf0`): - **Leg A, the driver.** The `SQLITE_NOTADB` table row was mutated to `ABLATION_24960_NOTADB`: anchor 1 to 0, blob `b99ad91eeb7e` to `3eb1e25b29ac`. driver-sql was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 2 built files. - Predicted and observed: driver-sql "2 failed | 6 passed". Red: the development and production refusal pins (`connect() resolved for a file that is not a SQLite database`). Green: the controls and the #23696 file. - Predicted and observed: service-datasource "4 failed | 9 passed". Red: development resolved to engine `sqlite-wasm`; the production refusal was `undefined`; both `DatasourceConnectionService` cases read `'available'`. - Restore proved: blob == HEAD, `git diff HEAD` empty. After the rebuild, preflight `--absent` found the marker in none of the 6 built files, and the tree was clean. - **Leg B, the step-down.** `|| code === 'SQLITE_NOTADB'` was mutated to `ABLATION_24960_SD`: blob `165d52e2d851` to `bda6c0c91769`. The subject is a relative `src` import, so this leg needs no dist step. - Predicted and observed: "2 failed | 11 passed". Red: the two development cases. Green: the two production cases, which the driver alone carries, and the 9 cases of #24950. - Restore proved: blob == HEAD, `git diff HEAD` empty. **Public door, the card's own:** `bin/run-dev.js migrate apply --yes --database-url file:main.db` over a `dist/objectstack.json` that declares `archive` (`driver: sqlite`, `autoConnect: true`) on a plain-text file, at head `6d3dd6247` after a `turbo run build --filter=@objectstack/cli^...` (60/60). - **Object bound to `archive`:** the run prints `datasource 'archive': connect failed — SQLite database file '…/data/archive.db' is not a SQLite database, and was left as it is: PRAGMA auto_vacuum - file is not a database. (1 object(s) bind to it explicitly … fail-fast per ADR-0062 D5)` and exits 1. The md5 is unchanged and `data/` holds `archive.db main.db`. - **autoConnect only:** the run prints `WARN datasource 'archive': connect failed — … is not a SQLite database … — degrading (datasource left unconnected)`, then `Applied 0 change(s)`, and exits 0. The md5 is unchanged, there is no `.corrupt-*` sibling, and no step-down warning prints. - The card measured the base on the same door: the file was renamed, an empty 16384-byte database was written, and the run read `connected`. **Driver ledger** (`pnpm check:driver-conformance`): - Before, at `4b8f597ba`: "55 covered cell(s), 0 in the DEBT ledger, 0 exempt"; dialect axis 9 suites, 8 matrix and 1 named, 0 in the DIALECT ledger. - After, at `6d3dd6247`: the same counts. The ledger did not move. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at `6d3dd6247` derived 72 commands: the seat's 55 plus 17 for the changeset and the new test files. I ran each one once, sequentially, with its exit captured before any pipe, and all 72 exit 0. Two answered exit 3 (`PREREQUISITE NOT MET`) on the first pass and were re-run after their prerequisite: - `check-plugin-teardown-shape.mjs --self-test` needed its pinned fixture commit, fetched at depth 1; - `check:dual-build-cjs-loads` needed every package's `dist/`, from a `turbo run build` that was 75 of 76 cache hits. `--ran` reconciliation: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because all 72 recorded an exit code. CI owns the full farm and `test:repo`. ## Acceptance notes - **`SQLITE_CORRUPT`, the same defect one code over, is not in this PR.** It is reported to the seat; this PR leaves it alone. A SQLite database truncated inside its 100-byte header answers the first `PRAGMA` with `SQLITE_CORRUPT` ("database disk image is malformed"), which `connect()` still swallows. I ran the same door at this head with an object bound to `archive`: `archive.db` was moved to `archive.db.corrupt-1792539192447`, an empty database was written in its place, the run read `datasource 'archive': connected` under the "native better-sqlite3 unavailable" warning, and it exited 0. The fix would be one more row in `SQLITE_FILE_REFUSALS` and one more code in `isSqliteFileOpenFailure`. It stays out because the ruling and the claim name `SQLITE_NOTADB`, and `sqlite-driver-fallback.ts` is also held by the #24961 claim, so the in-place exemption does not hold. - **A residual rename this route cannot reach.** When the native addon cannot LOAD (an ABI mismatch) and the declared file is not a database, the native rung cannot read the file to classify it. The step-down still goes to wasm, which renames the file. I measured this with the #24950 suite's own load-failure seam (`acquireRawConnection` answering a real `ERR_DLOPEN_FAILED`): the run resolved to `sqlite-wasm`, and `data/` held `archive.db` and `archive.db.corrupt-TIMESTAMP`. It lives in the wasm quarantine, which the claim scopes out. - The probe body's comments in `sqlite-driver-fallback.ts` ("refuses a file it cannot open or create") still name only the open-failure half. I left them unedited to stay off the #24961 region. - After this branch's last merge, `main` gained #24992 (plugin-auth), which this diff does not touch. I did not merge it, so the gate union stays measured on the head it names. --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a1f5c3 commit 7f99616

6 files changed

Lines changed: 302 additions & 22 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/service-datasource': minor
4+
---
5+
6+
fix(driver-sql): a SQLite file whose bytes are not a SQLite database fails `connect()` at once, naming the file, and a development boot no longer moves it aside and replaces it with an empty database (#24960)
7+
8+
`SqlDriver.connect()` runs `PRAGMA auto_vacuum` as the connection's first statement. On a file that is not a SQLite database (plain text, another format, a file encrypted by another engine) SQLite answers `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and swallowed. It resolved over a file it could not read. In production the first query failed later. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) answered the failed probe by stepping down to wasm SQLite, whose open renamed the file to `<file>.corrupt-<timestamp>`, wrote an empty database in its place and read `connected`, under a warning that blamed the native addon.
9+
10+
`connect()` now refuses such a file with a `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), as it already did for `SQLITE_CANTOPEN`, in every mode and before anything is written. A declared datasource's boot prints `datasource '<name>': connect failed — <reason>`. `resolveSqliteDriver` classifies `SQLITE_NOTADB` as a file failure and re-throws it instead of stepping down, so the operator's file is left exactly as it was found. A real database file, an empty file and an absent file connect as before, and a native addon that cannot load still steps down to wasm SQLite.
11+
12+
Clause-②: no (narrowing)
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes it is raised with grows by one), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` gives for a SQLite file that is not a database: a rejection where it resolved. The remedy is the datasource's file, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
15+
16+
**BREAKING** (`SqlDriver.connect()`'s accept set narrows): a connect that resolved over a SQLite file that is not a database now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
17+
18+
- **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot the file was renamed to `<file>.corrupt-<timestamp>` and an empty database was written in its place.
19+
- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
20+
- **Fix:** point the datasource at a SQLite database file, or move the file aside yourself (an absent file is created empty on the next connect).
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A declared SQLite file whose bytes are not a SQLite database fails
5+
* `connect()` at once, naming the file, and the file is left exactly as it was
6+
* found (#24960).
7+
*
8+
* Before: the connection's first statement (`PRAGMA auto_vacuum`) answered
9+
* `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA
10+
* auto_vacuum=INCREMENTAL" and swallowed, so it resolved over a file it could
11+
* not read. In production the first query failed later; on a development boot
12+
* the step-down (`resolveSqliteDriver` in `@objectstack/service-datasource`)
13+
* answered with wasm SQLite, whose open moved the operator's file aside to
14+
* `<file>.corrupt-<timestamp>` and wrote an empty database in its place, and
15+
* the datasource read `connected`. `SQLITE_CANTOPEN` was already refused this
16+
* way (#23696); `SQLITE_NOTADB` was left out.
17+
*
18+
* The refusal is the driver's own, so it holds whatever `NODE_ENV` says: both
19+
* modes are pinned.
20+
*/
21+
22+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
23+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24+
import { tmpdir } from 'node:os';
25+
import { join } from 'node:path';
26+
import { SqlDriver } from './sql-driver.js';
27+
import { SqliteDatabaseFileError } from './sqlite-database-file.js';
28+
29+
const PLAIN_TEXT = 'archive notes — this file holds plain text, not a SQLite database\n';
30+
31+
const drivers: SqlDriver[] = [];
32+
function make(filename: string): SqlDriver {
33+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true });
34+
drivers.push(d);
35+
return d;
36+
}
37+
38+
/** `connect()`'s rejection, or a failure saying it resolved. */
39+
async function refusalOf(driver: SqlDriver): Promise<unknown> {
40+
try {
41+
await driver.connect();
42+
} catch (error) {
43+
return error;
44+
}
45+
throw new Error('connect() resolved for a file that is not a SQLite database');
46+
}
47+
48+
let dir: string;
49+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-24960-')); });
50+
afterEach(async () => {
51+
vi.unstubAllEnvs();
52+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
53+
rmSync(dir, { recursive: true, force: true });
54+
});
55+
56+
describe('SqlDriver.connect — a declared SQLite file that is not a database (#24960)', () => {
57+
for (const nodeEnv of ['development', 'production'] as const) {
58+
it(`refuses a plain-text file with SQLITE_NOTADB, naming it, and leaves its bytes untouched (NODE_ENV=${nodeEnv})`, async () => {
59+
vi.stubEnv('NODE_ENV', nodeEnv);
60+
const filename = join(dir, 'archive.db');
61+
writeFileSync(filename, PLAIN_TEXT);
62+
const before = readFileSync(filename);
63+
64+
const error = await refusalOf(make(filename));
65+
66+
expect(error).toBeInstanceOf(SqliteDatabaseFileError);
67+
const e = error as SqliteDatabaseFileError;
68+
expect(e.code).toBe('SQLITE_NOTADB');
69+
expect(e.filename).toBe(filename);
70+
expect(e.message).toContain(filename);
71+
expect(e.message).toContain((e.cause as Error).message);
72+
// Nothing was written: the same bytes, and no sibling (journal, WAL,
73+
// `.corrupt-*`) beside it.
74+
expect(readFileSync(filename).equals(before)).toBe(true);
75+
expect(readdirSync(dir)).toEqual(['archive.db']);
76+
});
77+
}
78+
79+
it('CONTROL: a real database file still connects and answers, and is not moved', async () => {
80+
const filename = join(dir, 'real.db');
81+
const seed = make(filename);
82+
await seed.connect();
83+
await seed.execute('CREATE TABLE t (a INTEGER)');
84+
await seed.execute('INSERT INTO t (a) VALUES (7)');
85+
await seed.disconnect();
86+
87+
const driver = make(filename);
88+
await driver.connect();
89+
expect(await driver.execute('SELECT a FROM t')).toEqual([{ a: 7 }]);
90+
expect(readdirSync(dir).filter((name) => name.includes('.corrupt-'))).toEqual([]);
91+
});
92+
93+
it('CONTROL: an empty file is an empty SQLite database, and connects', async () => {
94+
const filename = join(dir, 'empty.db');
95+
writeFileSync(filename, '');
96+
const driver = make(filename);
97+
await driver.connect();
98+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
99+
});
100+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7059,6 +7059,9 @@ export class SqlDriver implements IDataDriver {
70597059
* @returns whether the connection answered. The read is the first statement
70607060
* any connection runs, so `false` says the connection itself is not answering —
70617061
* not merely that space hygiene is unavailable.
7062+
* @throws {SqliteDatabaseFileError} when that first statement's answer is a
7063+
* verdict on the file itself ({@link SQLITE_FILE_REFUSALS}): it cannot be
7064+
* opened, or it is not a SQLite database.
70627065
*/
70637066
private async applyAutoVacuumIncremental(): Promise<boolean> {
70647067
try {
@@ -7082,15 +7085,24 @@ export class SqlDriver implements IDataDriver {
70827085
// let the step-down message that follows explain the outcome. Any OTHER
70837086
// PRAGMA failure keeps the full warning (with stack) as before.
70847087
const code = (e as { code?: string } | null | undefined)?.code;
7085-
// The file itself cannot be opened or created (#23696): a directory that
7086-
// exists but refuses the file, a path SQLite cannot open. Every later
7087-
// statement would answer the same, so the connect fails here, naming the
7088-
// file — before, it logged and resolved, and the datasource counted as
7089-
// connected while it could not answer one query.
7090-
if (code === 'SQLITE_CANTOPEN') {
7088+
// The file itself is refused, and every later statement would answer the
7089+
// same, so the connect fails here, naming the file. Before, each of these
7090+
// logged and resolved, and the datasource counted as connected while it
7091+
// could not answer one query:
7092+
// • SQLITE_CANTOPEN (#23696) — a directory that exists but refuses the
7093+
// file, a path SQLite cannot open;
7094+
// • SQLITE_NOTADB (#24960) — the file exists and opens, but its bytes
7095+
// are not a SQLite database (plain text, a foreign format, a file
7096+
// encrypted by another engine). On a development boot the step-down
7097+
// used to answer it with wasm SQLite, whose open moved the
7098+
// operator's file aside and wrote an empty database in its place.
7099+
// The refusal is issued before anything is written: the file is left
7100+
// exactly as it was found.
7101+
const refusal = code === undefined ? undefined : SqlDriver.SQLITE_FILE_REFUSALS.get(code);
7102+
if (refusal !== undefined) {
70917103
const declared = (this.config as any).connection;
70927104
const filename = this.sqliteFilename() ?? String(typeof declared === 'string' ? declared : declared?.filename);
7093-
throw new SqliteDatabaseFileError(filename, 'cannot be opened or created', e);
7105+
throw new SqliteDatabaseFileError(filename, refusal, e);
70947106
}
70957107
const msg = e instanceof Error ? e.message : String(e);
70967108
const isNativeLoadFailure =
@@ -7109,6 +7121,19 @@ export class SqlDriver implements IDataDriver {
71097121
}
71107122
}
71117123

7124+
/**
7125+
* The SQLite codes the connection's first statement can answer that are a
7126+
* verdict on the FILE, each with the words {@link SqliteDatabaseFileError}
7127+
* names it with. Only a loaded addon answers with a SQLite code, so none of
7128+
* these is a native-addon load failure; and every rung of a step-down opens
7129+
* the same file, so no other engine can cure one (`resolveSqliteDriver` in
7130+
* `@objectstack/service-datasource` re-throws them).
7131+
*/
7132+
private static readonly SQLITE_FILE_REFUSALS: ReadonlyMap<string, string> = new Map([
7133+
['SQLITE_CANTOPEN', 'cannot be opened or created'],
7134+
['SQLITE_NOTADB', 'is not a SQLite database, and was left as it is'],
7135+
]);
7136+
71127137
/** What `PRAGMA auto_vacuum` answers for INCREMENTAL (0 is NONE, 1 is FULL). */
71137138
private static readonly SQLITE_AUTO_VACUUM_INCREMENTAL = 2;
71147139

‎packages/drivers/driver-sql/src/sqlite-database-file.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { mkdir, stat } from 'node:fs/promises';
55
import { dirname, isAbsolute, resolve } from 'node:path';
66

77
/**
8-
* A SQLite database file this driver cannot open or create (#23696).
8+
* A SQLite database file this driver cannot open or create (#23696), or one
9+
* whose bytes are not a SQLite database (#24960).
910
*
1011
* Thrown by `SqlDriver.connect()`, so the boot's connect refusal (ADR-0062 D5,
1112
* `datasource '<name>': connect failed — <reason>`) carries the path and the
1213
* operating system's own answer instead of waiting for a plugin start timeout.
1314
* `code` is the underlying error's (`ENOENT`, `EACCES`, `ENOTDIR`,
14-
* `SQLITE_CANTOPEN`, …) and `cause` the error itself.
15+
* `SQLITE_CANTOPEN`, `SQLITE_NOTADB`, …) and `cause` the error itself.
1516
*/
1617
export class SqliteDatabaseFileError extends Error {
1718
readonly code: string | undefined;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A declared SQLite datasource whose file is not a SQLite database is refused
5+
* at connect, naming the file, in development as in production. On a
6+
* development boot the operator's file is neither renamed nor replaced, and
7+
* nothing steps down to wasm SQLite or the in-memory driver (#24960).
8+
*
9+
* Before: `SqlDriver.connect()` swallowed the file's `SQLITE_NOTADB` and
10+
* resolved, the probe's `SELECT 1` then failed as a `DATABASE_ERROR`, and in
11+
* development `resolveSqliteDriver` stepped down to wasm SQLite. wasm's open
12+
* moved the file aside to `<file>.corrupt-<timestamp>` and wrote an empty
13+
* database in its place, under the warning "native better-sqlite3 unavailable
14+
* (ABI mismatch or not built)", which named the wrong cause; the datasource
15+
* read `connected`. Measured through `os migrate apply --yes` on the card.
16+
*
17+
* Real `better-sqlite3`, real wasm SQLite and real files in a scratch
18+
* directory, as in `./sqlite-driver-fallback-file-open-failure.test.ts`.
19+
*/
20+
21+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23+
import { tmpdir } from 'node:os';
24+
import { join } from 'node:path';
25+
import { resolveSqliteDriver, type ResolvedSqliteDriver } from '../sqlite-driver-fallback.js';
26+
import { DatasourceConnectionService } from '../datasource-connection-service.js';
27+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
28+
29+
const PLAIN_TEXT = 'archive notes — this file holds plain text, not a SQLite database\n';
30+
31+
let dir: string;
32+
let filename: string;
33+
let bytes: Buffer;
34+
const opened: ResolvedSqliteDriver[] = [];
35+
36+
beforeEach(() => {
37+
dir = mkdtempSync(join(tmpdir(), 'os-24960-'));
38+
filename = join(dir, 'archive.db');
39+
writeFileSync(filename, PLAIN_TEXT);
40+
bytes = readFileSync(filename);
41+
});
42+
43+
afterEach(async () => {
44+
vi.restoreAllMocks();
45+
vi.unstubAllEnvs();
46+
await Promise.all(opened.splice(0).map((r) => r.driver?.disconnect?.().catch(() => {})));
47+
rmSync(dir, { recursive: true, force: true });
48+
});
49+
50+
/** `promise`'s rejection; a failure naming the engine when it resolves instead. */
51+
async function rejectionOf(promise: Promise<ResolvedSqliteDriver>): Promise<any> {
52+
let resolved: ResolvedSqliteDriver;
53+
try {
54+
resolved = await promise;
55+
} catch (error) {
56+
return error;
57+
}
58+
opened.push(resolved);
59+
throw new Error(`resolved to engine '${resolved.engine}' for a file that is not a SQLite database`);
60+
}
61+
62+
/** The driver's refusal of the file: its own error name, SQLite's code, and the file. */
63+
function expectNotADatabaseRefusal(error: any): void {
64+
expect(error?.name).toBe('SqliteDatabaseFileError');
65+
expect(error?.code).toBe('SQLITE_NOTADB');
66+
expect(error?.filename).toBe(filename);
67+
expect(String(error?.message)).toContain(filename);
68+
}
69+
70+
/** The operator's file is exactly as it was, and nothing was written beside it. */
71+
function expectFileUntouched(): void {
72+
expect(readFileSync(filename).equals(bytes)).toBe(true);
73+
expect(readdirSync(dir)).toEqual(['archive.db']);
74+
}
75+
76+
describe('resolveSqliteDriver in development: a file that is not a SQLite database (#24960)', () => {
77+
it('rejects with the driver\'s refusal, never wasm or memory, and leaves the file as it was', async () => {
78+
const warn = vi.fn();
79+
expectNotADatabaseRefusal(await rejectionOf(resolveSqliteDriver({ filename, dev: true, warn })));
80+
// No step-down notice: the "native better-sqlite3 unavailable" warning
81+
// named the wrong cause, and no rung was tried.
82+
expect(warn).not.toHaveBeenCalled();
83+
expectFileUntouched();
84+
});
85+
});
86+
87+
describe('resolveSqliteDriver in production: a file that is not a SQLite database (#24960)', () => {
88+
it('returns native unprobed, and its connect() refuses the file', async () => {
89+
const warn = vi.fn();
90+
const resolved = await resolveSqliteDriver({ filename, dev: false, warn });
91+
opened.push(resolved);
92+
expect(resolved.engine).toBe('better-sqlite3');
93+
let refusal: unknown;
94+
await resolved.driver.connect().catch((e: unknown) => { refusal = e; });
95+
expectNotADatabaseRefusal(refusal);
96+
expect(warn).not.toHaveBeenCalled();
97+
expectFileUntouched();
98+
});
99+
});
100+
101+
describe('DatasourceConnectionService: a declared SQLite datasource on a file that is not a database (#24960)', () => {
102+
for (const mode of ['development', 'production'] as const) {
103+
it(`${mode}: reports a failed connection state naming the file, registers no driver, and leaves the file as it was`, async () => {
104+
vi.stubEnv('NODE_ENV', mode);
105+
const registered: string[] = [];
106+
const engine = {
107+
registerDriver: (driver: { name?: string }) => { registered.push(String(driver?.name)); },
108+
getDriverByName: (name: string) => (registered.includes(name) ? {} : undefined),
109+
};
110+
const service = new DatasourceConnectionService({
111+
factory: () => createDefaultDatasourceDriverFactory({ dev: mode === 'development' }),
112+
engine: () => engine as any,
113+
});
114+
115+
await service.connectDeclared({
116+
datasources: [{ name: 'archive', driver: 'sqlite', config: { filename }, autoConnect: true }],
117+
});
118+
119+
const state = service.getConnectionState('archive');
120+
expect(state?.availability).toBe('failed');
121+
expect(state?.reason).toContain(filename);
122+
expect(registered).toEqual([]);
123+
expectFileUntouched();
124+
});
125+
}
126+
});

0 commit comments

Comments
 (0)