Skip to content

Commit 27dc53b

Browse files
fix(driver-sqlite-wasm)!: a file wasm SQLite cannot read is refused at connect, naming the file, instead of being renamed and replaced with an empty database (#25200)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgS2d2sPzGdbRqTa2fsR14Ze)_ Fixes #25019 Clause-②: no (narrowing) Metadata-protocol: none **Narrowing face:** a connect that resolved over a file wasm SQLite cannot read now rejects, naming the file. `WasmSqliteConnection.open()` no longer renames such a file to `FILE.corrupt-TIMESTAMP` and starts an empty database in its place; `resolveSqliteDriver` re-throws the refusal at the wasm rung instead of trying the in-memory driver. Nothing exported changes shape. It owes a `CONTRACT_REVIEW_TIER` review before the queue (the claim's line). Member 3 of the SQLite file-failure family (the wasm quarantine), the card's last member, under the `domain:engine` seat 2's ruling 6311031589 (route A, inherited from #24960's route A). Members 1 and 2 landed in PR #25080 (`d6a5fa3ba`, `Part of #25019`). Dev session `session_015pbbff7SHh7EEEWiasBdDe` (a project card thread of seat session `session_01FaVyM6YvQLg9CsjPVCYc5K`), claim 6335885549; the cross-lane line in `service-datasource` is declared on #21118 (6335909713). ## What changed - `packages/drivers/driver-sqlite-wasm/src/wasm-connection.ts` - `open()`: a file whose bytes sql.js cannot open or read (`new SQL.Database(bytes)` or `PRAGMA quick_check(1)` throws, or `quick_check` does not answer `ok`) is refused with `unreadableDatabaseFileRefusal`: an `Error` carrying `code` (`SQLITE_NOTADB` when SQLite says "file is not a database", `SQLITE_CORRUPT` for every other read failure), `filename`, `cause` (sql.js's own throw), and a message that says how to move the file aside. sql.js throws a bare `Error` with SQLite's message and no result code, so the producer maps SQLite's own errstr for `SQLITE_NOTADB`; nothing downstream reads message text. - `quarantineCorruptFile` retires (it was private). The refused file is left byte-for-byte as found and nothing is written beside it. - `quarantineOrphanedWal` now runs only after the image is accepted, so a stale write-ahead log beside a refused file stays where it is too. - `packages/services/service-datasource/src/sqlite-driver-fallback.ts` (the one cross-lane file): the wasm rung's `catch` re-throws when `isSqliteFileOpenFailure(err)` holds, exactly as the native rung does. The predicate needed no new code: the wasm refusal reaches it as `SqlDriver`'s own `SqliteDatabaseFileError` with `SQLITE_NOTADB` / `SQLITE_CORRUPT` (below). Every other wasm failure, sql.js itself not loading first among them, still steps down to the in-memory driver. Module note and predicate doc updated. - `packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts`: the self-heal case is now a refusal pin (ruling item 3). - Pins: `packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-25019-unreadable-file-refused.test.ts` (relative `src` import) and `packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-wasm-unreadable-file.test.ts` (resolves `@objectstack/driver-sqlite-wasm` through `dist/`). - Changeset `.changeset/25019-wasm-unreadable-file-refused.md`: `@objectstack/driver-sqlite-wasm` and `@objectstack/service-datasource` `patch`, `Clause-②: no (narrowing)`, `Metadata-protocol: none`, BREAKING banner (FROM, TO, Unchanged, fix) and ADR-0087 disposition `not-required (no-migration-prescription)`. ## One vocabulary with the native driver, measured `SqliteWasmDriver` is a `SqlDriver`, and wasm SQLite's connection is opened by knex's pool on the first statement `SqlDriver.connect()` runs (`PRAGMA auto_vacuum`). The pool propagates the create error (`propagateCreateError: true`), so `applyAutoVacuumIncremental` sees the refusal's `code` and answers it through the same private `SQLITE_FILE_REFUSALS` table the native driver uses, wrapping it in `SqliteDatabaseFileError`. So a wasm refusal carries the native driver's class, code and words ("is not a SQLite database, and was left as it is" / "is damaged (SQLite cannot read its header or its schema), and was left as it is"), with no second phrase table and no edit to `driver-sql`. The prescription is wasm's own cause text, since the rename it replaces was wasm's: the native refusal carries none (the dispatch's assumption that the native family has a move-aside prescription to import did not hold; measured by `grep`, zero hits in `driver-sql/src`). The line an operator sees (public door below): ```text datasource 'archive': connect failed — SQLite database file './data/archive.db' (resolved: '…/door/data/archive.db') is not a SQLite database, and was left as it is: wasm SQLite cannot read it (file is not a database). To start from an empty database, move the file aside yourself (mv './data/archive.db' './data/archive.db.unreadable') and start again; to keep its data, restore a copy that opens. ``` ## Premise (base `f7d740376`) `quarantineCorruptFile` was at `wasm-connection.ts`, called from `open()` on a read failure, renaming to `FILE.corrupt-` plus `Date.now()`; the wasm rung of `resolveSqliteDriver` was a bare `catch` stepping down to memory. Both doors were measured by the dev report 6310958104 (open question 1): (a) a declared `sqlite` datasource whose native addon cannot load, over a plain-text or header-truncated file, renamed on a development boot; (b) a datasource declared `driver: 'sqlite-wasm'` over a plain-text file, renamed and `available` in development and production. sql.js 1.14 answers, measured: plain text and magic-header junk throw "file is not a database" at `quick_check`; a real database truncated to 50 bytes or to half throws "database disk image is malformed"; a real database and an empty file answer `ok`. ## Tests - New pins: `driver-sqlite-wasm` 11/11 across the two files; `service-datasource` 10/10: (a) the native-addon-cannot-load step-down (a real `ERR_DLOPEN_FAILED` at the #24950 suite's seam) over a plain-text and a header-truncated file rejects with the driver's refusal, no wasm or memory warning, file bytes unchanged, directory holds the file alone; the same through `DatasourceConnectionService` in development reads `failed`, no driver registered; (b) `driver: 'sqlite-wasm'` over both files in development and production reads `failed` with the code's words naming the file, not `available`, nothing moved; (c) controls: a real database opens and answers (wasm, and `sqlite-wasm` declared, both modes `available`), an absent file is created, an empty file opens, a native addon that cannot load over an absent file still steps down to wasm. - Whole packages (pre-merge head `73dc5b1a`, under the verify lock): `driver-sqlite-wasm` "Test Files 39 passed (39) · Tests 703 passed (703)"; `service-datasource` "Test Files 55 passed (55) · Tests 955 passed (955)"; `typecheck` for both, exit 0. - Ablation (committed fix at `f8ffdfc0`, `scripts/ablation-replace.mjs`): the refusal's `throw` replaced by the old quarantine (rename to `.corrupt-`, empty database, plus a marker), blob `07f7cb3e0fa3` to `15743567f704`; rebuilt; `ablation-dist-preflight` marker present in 2 built files. Predicted and observed: `driver-sqlite-wasm` "6 failed | 5 passed" (the 5 refusal pins and the durability refusal case red; 3 controls and 2 durability cases green); `service-datasource` "7 failed | 3 passed" (all of (a) and (b) red, the 3 controls green). Restore: blob back to `07f7cb3e0fa3` == HEAD, `git diff HEAD` empty, rebuilt, preflight `--absent` in 0 of 6 built files, tree clean; both pins green again (11/11, 10/10). ## Public door, measured `bin/run-dev.js migrate apply --yes` (tsx, built closure), a project declaring `archive` with `driver: 'sqlite-wasm'`, `config.filename: './data/archive.db'`, one object bound, over a 25-byte plain-text file: - exit 1; the line above, from `✗ Plugin plugin.app.door_25019 failed to start`; md5 `bcbdbe7ff2cba060ed588d3fca48b30c` before and after; `data/` holds `archive.db` alone. - Following the prescription (`mv './data/archive.db' './data/archive.db.unreadable'`) and running the same command again: exit 0, `datasource 'archive': connected (driver=sqlite-wasm, schemaMode=managed)`, the moved file's md5 unchanged. ## Gates and battery - Derived set: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `e39b18e5` derived 68; each run once with its exit captured to a file before any pipe. 65 exit 0 on the first pass. Three did not: - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 3: this shallow clone lacked its pinned fixture commit; fetched it, re-run exit 0 (48 cases). - `pnpm check:dispatcher-error-vocabulary` exit 1: its `objlit` pattern matched the refusal's code TYPE (`code: 'SQLITE_NOTADB' | 'SQLITE_CORRUPT'` in a type literal). The gate's own self-test declares a type annotation is not a stamp and passes a named type, so the type was named (`UnreadableDatabaseFileCode`, `cda55e654`); re-run exit 0 ("56 unregistered code-stamping site(s), all classified"). Disclosed below. - `pnpm check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET (eight packages outside this closure have no `dist/`): NOT MEASURED locally, CI's `Lint & Repo Gates` runs it. - `--ran` at `cda55e654`: "67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED". - `check:driver-conformance` before (`f7d740376`) and after: "55 covered cell(s), 0 in the DEBT ledger, 0 exempt" both; dialect axis unchanged. - `check-platform-checklist` not owed (no exported symbol renamed or removed; `quarantineCorruptFile` was private). `test:repo` and the CLI integration layer not run locally (no such file touched); CI owns them. - Full dogfood battery at `e39b18e5` (`pnpm install --frozen-lockfile`, the `@objectstack/dogfood^...` closure build, `pnpm --filter @objectstack/dogfood test`, no shard, no path): lock VERDICT command-exit 1, "Test Files 1 failed | 286 passed | 3 skipped (290) · Tests 2395 passed | 21 skipped (2416)". The one failure is a suite-level `beforeAll` timeout (10 s, `bootShowcase()`) in `test/showcase-single-platform-insert-default-organization.dogfood.test.ts`, before any of its 3 tests. Re-run alone it times out the same way, and the CONTROL (the same file with `wasm-connection.ts` restored to base `f7d740376` and rebuilt, `quarantineCorruptFile` present in `dist/`) times out identically, so it is not this change: the showcase boots a fresh wasm database (no file to refuse) and runs out of hook time on this shared box. Tree restored and rebuilt after the control (`dist/index.mjs` md5 back to `11790b18…`). CI's full battery is the reading of record. - The only commit after the battery is `cda55e654` (a type alias): rebuilt `dist/index.mjs` and `dist/index.js` are byte-identical before and after it (md5 `11790b18…`, `f3177051…`). ## Acceptance notes - **Level `patch`, not the ruling's `minor`.** The ruling's item 4 names `minor`; the level now follows the metadata-protocol kind alone (AGENTS.md Post-Task Checklist step 3; `check-changeset-no-major.mjs`, maintainer ruling A on #25105), and nothing a metadata author writes changes here, so the kind is `none`, which the gate grades `patch` and refuses at `minor`. BREAKING-ness is carried by the `Clause-②` arm, the banner and the ADR-0087 disposition, as `24837-managed-datasource-sync-refused.md` does. The seat may re-grade. - **The ruling's named cost:** a development server whose own wasm image is damaged no longer starts itself clean; the refusal's prescription says how to move the file aside, and the public door above shows that step working. - knex logs `Acquire connection error:` with the refusal's stack at `warn` once per acquire attempt (its own logger, `knex/lib/client.js`). The native refusal never fails an acquire, so it does not print this; every other dialect's acquire failure (a refused PG connection) does. Noted, not filed. - Named only, per family note 6305864303: the development telemetry sibling (`packages/cli/src/utils/telemetry-datasource.ts`) still catches every throw, so a damaged telemetry sibling is now neither renamed nor reported. Pre-existing best-effort shape, out of the claim. - #25084 (the refused-connect handle) is not addressed here; a wasm refusal holds no connection (the open failed before the pool kept one). - `check:dispatcher-error-vocabulary`: the refusal stamps SQLite's own result-code names (`SQLITE_NOTADB` / `SQLITE_CORRUPT`) through a ternary assignment, a position outside that gate's declared population, as `driver-sql` carries the same names (copied from better-sqlite3's error, and as `SQLITE_FILE_REFUSALS` keys). They are foreign vocabulary, not ADR-0112 codes. A `foreign-vocabulary` row in `packages/runtime/src/dispatcher-error-vocabulary.ts` would make that explicit; it is outside this claim's file surface, so it is left to the seat. - Every read failure other than SQLite's "file is not a database" is classified `SQLITE_CORRUPT`: the bytes were read off disk and sql.js could not read them as a database, which is the file's verdict. An operating-system failure reading the file (`EACCES`) is not this path and is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_015pbbff7SHh7EEEWiasBdDe)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5f194b commit 27dc53b

6 files changed

Lines changed: 527 additions & 63 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/driver-sqlite-wasm': patch
3+
'@objectstack/service-datasource': patch
4+
---
5+
6+
fix(driver-sqlite-wasm)!: a file wasm SQLite cannot read is refused at connect, naming the file, and left as it was found, instead of being renamed and replaced with an empty database (#25019)
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- 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 (`WasmSqliteConnection` and `SqliteWasmDriver` keep their shapes; the private quarantine method is gone), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer wasm SQLite's connect gives for a database file it cannot read: a rejection naming the file where it renamed the file and resolved over an empty database. The remedy is the datasource's file, never a rewrite of a package source or a stored row. -->
12+
13+
**BREAKING** (wasm SQLite's connect accept set narrows). `WasmSqliteConnection.open()` used to answer a file sql.js could not read by renaming it to `<file>.corrupt-<timestamp>` and starting an empty database in its place, with one warning. That self-heal was written for wasm SQLite's own torn writes, which the atomic flush has since closed; what still reached it was an operator's file on a declared datasource. A datasource declared `driver: 'sqlite-wasm'` over such a file read `available` over the empty replacement, in development and production alike, and a declared `sqlite` datasource on a development boot whose native addon cannot load stepped down to wasm SQLite and met the same rename. The native driver already refuses the same files at connect.
14+
15+
- **FROM:** a file sql.js cannot read (plain text, another format, a database truncated inside its header or short of the pages its schema names) was moved to `<file>.corrupt-<timestamp>`; `connect()` resolved over an empty database, and the datasource read `available`. On a development boot whose native addon cannot load, `resolveSqliteDriver` (`@objectstack/service-datasource`) returned that wasm driver, and when wasm SQLite failed for any reason it went on to the in-memory driver.
16+
- **TO:** `SqliteWasmDriver.connect()` rejects with the native driver's `SqliteDatabaseFileError`: `code: 'SQLITE_NOTADB'` for bytes that are not a database, `code: 'SQLITE_CORRUPT'` for a database it cannot read, `filename`, and a message naming the file and how to move it aside. Nothing is renamed, nothing is written beside it, and a stale write-ahead log beside a refused file stays where it is. `WasmSqliteConnection.open()` rejects with the same code and `filename`. `resolveSqliteDriver` re-throws that refusal at the wasm rung instead of trying the in-memory driver, so the datasource's connect fails and its connection state says so. Any other wasm failure, sql.js itself not loading first among them, still goes on to the in-memory driver.
17+
- **Unchanged:** a real database opens and answers; an absent file is created; an empty file opens as an empty database; a native addon that cannot load over a real or absent file still steps down to wasm SQLite.
18+
- **The fix for an operator:** restore the database from a backup, or move the unreadable file aside yourself (`mv '<file>' '<file>.unreadable'`) and start again: an absent file is created empty on the next connect. A development server whose own wasm image is damaged no longer starts itself clean; this is that step, done by hand.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A file wasm SQLite cannot read is refused at connect, naming the file, with
5+
* the code the native driver answers for the same file, and is left exactly as
6+
* it was found (#25019): nothing renamed, nothing written beside it, no empty
7+
* database in its place.
8+
*
9+
* Before, `WasmSqliteConnection.open()` moved such a file to
10+
* `<file>.corrupt-<timestamp>` and started an empty database, so a declared
11+
* `sqlite-wasm` datasource over an operator's file read as connected, in
12+
* development and production alike.
13+
*
14+
* Real sql.js and real files in a scratch directory.
15+
*/
16+
17+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18+
import { mkdtempSync, readdirSync, readFileSync, rmSync, truncateSync, writeFileSync, statSync, existsSync } from 'node:fs';
19+
import { tmpdir } from 'node:os';
20+
import { join } from 'node:path';
21+
22+
import { SqliteWasmDriver, WasmSqliteConnection } from './index.js';
23+
24+
let dir: string;
25+
const drivers: SqliteWasmDriver[] = [];
26+
27+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'wasm-25019-')); });
28+
29+
afterEach(async () => {
30+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
31+
rmSync(dir, { recursive: true, force: true });
32+
});
33+
34+
function driverFor(filename: string): SqliteWasmDriver {
35+
const driver = new SqliteWasmDriver({ filename, persist: 'on-write', logger: { warn: () => {} } });
36+
drivers.push(driver);
37+
return driver;
38+
}
39+
40+
/** A real database written by this driver, holding one row in `t`. Its size in bytes. */
41+
async function seedDatabase(filename: string): Promise<number> {
42+
const seed = new SqliteWasmDriver({ filename, persist: 'on-disconnect' });
43+
await seed.connect();
44+
await seed.execute('CREATE TABLE t (a INTEGER)');
45+
await seed.execute('INSERT INTO t (a) VALUES (7)');
46+
await seed.disconnect();
47+
return statSync(filename).size;
48+
}
49+
50+
/** `connect()`'s rejection; a failure when it resolves instead. */
51+
async function connectRefusal(driver: SqliteWasmDriver): Promise<any> {
52+
try {
53+
await driver.connect();
54+
} catch (error) {
55+
return error;
56+
}
57+
throw new Error('connect() resolved over a file wasm SQLite cannot read');
58+
}
59+
60+
/** The driver's refusal: the native driver's class and code, the file named, and how to move it aside. */
61+
function expectFileRefusal(error: any, filename: string, code: 'SQLITE_NOTADB' | 'SQLITE_CORRUPT'): void {
62+
expect(error?.name).toBe('SqliteDatabaseFileError');
63+
expect(error?.code).toBe(code);
64+
expect(error?.filename).toBe(filename);
65+
expect(String(error?.message)).toContain(`SQLite database file '${filename}'`);
66+
expect(String(error?.message)).toContain(
67+
`To start from an empty database, move the file aside yourself (mv '${filename}' '${filename}.unreadable') and start again; to keep its data, restore a copy that opens`,
68+
);
69+
}
70+
71+
describe('a file wasm SQLite cannot read is refused at connect and left as it was found (#25019)', () => {
72+
it('plain text: SQLITE_NOTADB, naming the file', async () => {
73+
const filename = join(dir, 'archive.db');
74+
const bytes = Buffer.from('id,name\n1,not a database\n'.repeat(20));
75+
writeFileSync(filename, bytes);
76+
77+
const refusal = await connectRefusal(driverFor(filename));
78+
expectFileRefusal(refusal, filename, 'SQLITE_NOTADB');
79+
expect(String(refusal.message)).toContain('was left as it is');
80+
81+
expect(readdirSync(dir)).toEqual(['archive.db']);
82+
expect(readFileSync(filename).equals(bytes)).toBe(true);
83+
});
84+
85+
it('a database truncated inside its 100-byte header: SQLITE_CORRUPT, naming the file', async () => {
86+
const filename = join(dir, 'archive.db');
87+
await seedDatabase(filename);
88+
truncateSync(filename, 50);
89+
const bytes = readFileSync(filename);
90+
91+
expectFileRefusal(await connectRefusal(driverFor(filename)), filename, 'SQLITE_CORRUPT');
92+
93+
expect(readdirSync(dir)).toEqual(['archive.db']);
94+
expect(readFileSync(filename).equals(bytes)).toBe(true);
95+
});
96+
97+
it('a database short of the pages its schema names: SQLITE_CORRUPT, naming the file', async () => {
98+
const filename = join(dir, 'archive.db');
99+
const size = await seedDatabase(filename);
100+
truncateSync(filename, Math.floor(size / 2));
101+
const bytes = readFileSync(filename);
102+
103+
expectFileRefusal(await connectRefusal(driverFor(filename)), filename, 'SQLITE_CORRUPT');
104+
105+
expect(readdirSync(dir)).toEqual(['archive.db']);
106+
expect(readFileSync(filename).equals(bytes)).toBe(true);
107+
});
108+
109+
it('a refused file keeps the write-ahead log beside it: nothing is parked', async () => {
110+
const filename = join(dir, 'archive.db');
111+
writeFileSync(filename, 'not a database');
112+
const wal = Buffer.from('a stale write-ahead log');
113+
writeFileSync(`${filename}-wal`, wal);
114+
115+
expectFileRefusal(await connectRefusal(driverFor(filename)), filename, 'SQLITE_NOTADB');
116+
117+
expect(readdirSync(dir).sort()).toEqual(['archive.db', 'archive.db-wal']);
118+
expect(readFileSync(`${filename}-wal`).equals(wal)).toBe(true);
119+
});
120+
121+
it('WasmSqliteConnection.open() itself rejects with the code and the file, and writes nothing', async () => {
122+
const filename = join(dir, 'archive.db');
123+
writeFileSync(filename, 'not a database');
124+
125+
const conn = new WasmSqliteConnection({ filename, logger: { warn: () => {} } });
126+
let refusal: any;
127+
await conn.open().catch((e: unknown) => { refusal = e; });
128+
expect(refusal?.code).toBe('SQLITE_NOTADB');
129+
expect(refusal?.filename).toBe(filename);
130+
expect(String(refusal?.message)).toContain(`mv '${filename}' '${filename}.unreadable'`);
131+
expect(String((refusal?.cause as Error)?.message)).toBe('file is not a database');
132+
133+
expect(readdirSync(dir)).toEqual(['archive.db']);
134+
expect(readFileSync(filename, 'utf8')).toBe('not a database');
135+
});
136+
});
137+
138+
describe('CONTROLS: what keeps today\'s behaviour (#25019)', () => {
139+
it('a real database opens and answers', async () => {
140+
const filename = join(dir, 'archive.db');
141+
await seedDatabase(filename);
142+
const driver = driverFor(filename);
143+
await driver.connect();
144+
expect(await driver.execute('SELECT a FROM t')).toEqual([{ a: 7 }]);
145+
});
146+
147+
it('an absent file is created', async () => {
148+
const filename = join(dir, 'new', 'archive.db');
149+
const driver = driverFor(filename);
150+
await driver.connect();
151+
await driver.execute('CREATE TABLE t (a INTEGER)');
152+
await driver.flush();
153+
expect(existsSync(filename)).toBe(true);
154+
});
155+
156+
it('an empty file opens as an empty database', async () => {
157+
const filename = join(dir, 'archive.db');
158+
writeFileSync(filename, '');
159+
const driver = driverFor(filename);
160+
await driver.connect();
161+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
162+
expect(readdirSync(dir).filter((name) => name.includes('.corrupt-'))).toEqual([]);
163+
});
164+
});

‎packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-durability.test.ts‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import { SqliteWasmDriver } from '../src/index.js';
2121
*
2222
* 1. Flushes must be ATOMIC. A non-atomic overwrite torn by a mid-write kill
2323
* left "database disk image is malformed" on the next boot.
24-
* 2. An already-corrupt on-disk image must SELF-HEAL: quarantine the bad file
25-
* and boot on a fresh database instead of failing every query forever.
24+
* 2. An already-corrupt on-disk image is REFUSED at connect, naming the file,
25+
* and left exactly as it was found (#25019). It used to self-heal: the open
26+
* renamed it to `<file>.corrupt-<timestamp>` and booted on an empty
27+
* database, which read as connected over an operator's file it replaced.
2628
* 3. `undefined` bindings must persist as SQL NULL rather than throwing sql.js's
2729
* raw-string "Wrong API use : tried to bind a value of an unknown type".
2830
*/
@@ -70,11 +72,11 @@ describe('SqliteWasmDriver durability & resilience', () => {
7072
expect(rows[0].name).toBe('A');
7173
});
7274

73-
it('self-heals a corrupt database image by quarantining it and booting fresh', async () => {
75+
it('refuses a corrupt database image at connect, naming the file, and leaves it as it was found', async () => {
7476
const dir = newDir();
7577
const file = join(dir, 'corrupt.db');
7678
// A file that starts with the SQLite magic header but is otherwise garbage:
77-
// sql.js opens it, then blows up on the first page read ("malformed").
79+
// sql.js opens it, then fails the first page read.
7880
const junk = Buffer.concat([
7981
Buffer.from('SQLite format 3\0', 'binary'),
8082
Buffer.alloc(4096, 0xab),
@@ -90,18 +92,21 @@ describe('SqliteWasmDriver durability & resilience', () => {
9092
}),
9193
);
9294

93-
// Boot must succeed and queries must work on the fresh database.
94-
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
95-
await (driver as any).knex('acct').insert({ id: 'a1', name: 'healed' });
96-
const rows = await (driver as any).knex('acct');
97-
expect(rows.length).toBe(1);
95+
let refusal: any;
96+
await driver.connect().catch((e: unknown) => { refusal = e; });
97+
expect(refusal?.name).toBe('SqliteDatabaseFileError');
98+
expect(refusal?.code).toBe('SQLITE_NOTADB');
99+
expect(refusal?.filename).toBe(file);
100+
expect(String(refusal?.message)).toContain(`'${file}'`);
101+
// The prescription: the operator moves the file aside, since the open no longer does.
102+
expect(String(refusal?.message)).toContain(
103+
`move the file aside yourself (mv '${file}' '${file}.unreadable')`,
104+
);
98105

99-
// The corrupt bytes were preserved in a sibling quarantine file, and a
100-
// warning was surfaced.
101-
const quarantined = readdirSync(dir).filter((f) => f.includes('.corrupt-'));
102-
expect(quarantined.length).toBe(1);
103-
expect(readFileSync(join(dir, quarantined[0]))).toEqual(junk);
104-
expect(warnings.some((w) => w.includes('corrupt'))).toBe(true);
106+
// Nothing renamed, nothing written beside it, the bytes untouched.
107+
expect(readdirSync(dir)).toEqual(['corrupt.db']);
108+
expect(readFileSync(file)).toEqual(junk);
109+
expect(warnings.some((w) => w.includes('Quarantined'))).toBe(false);
105110
});
106111

107112
it('persists an undefined binding as SQL NULL instead of throwing', async () => {

‎packages/drivers/driver-sqlite-wasm/src/wasm-connection.ts‎

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,55 @@ async function loadSqlJs(
8686
return cachedSqlJs;
8787
}
8888

89+
/**
90+
* SQLite's own text for `SQLITE_NOTADB` (`sqlite3_errstr(26)`). sql.js throws
91+
* a bare `Error` carrying SQLite's message and no result code, so this text is
92+
* the only form the code reaches this file in.
93+
*/
94+
const SQLITE_NOTADB_ERRSTR = 'file is not a database';
95+
96+
/**
97+
* The refusal {@link WasmSqliteConnection.open} throws for a file sql.js cannot
98+
* read (#25019), with the SQLite code the native driver answers for the same
99+
* file: `SQLITE_NOTADB` when SQLite says the bytes are not a database, and
100+
* `SQLITE_CORRUPT` for every other reason a read of the image failed ("database
101+
* disk image is malformed", a `quick_check` that does not answer `ok`).
102+
*
103+
* The code is what makes it one refusal with the native driver's:
104+
* `SqlDriver.connect()` names the file with its own words for that code, and
105+
* `resolveSqliteDriver` (`@objectstack/service-datasource`) re-throws it rather
106+
* than step down. The message is the rest of the answer, and the one thing only
107+
* this engine can say: it no longer moves the file aside for the operator, so
108+
* it says how to do that.
109+
*/
110+
function unreadableDatabaseFileRefusal(
111+
filename: string,
112+
cause: unknown,
113+
): UnreadableDatabaseFileRefusal {
114+
const reason =
115+
typeof cause === 'string' ? cause : (cause as Error | undefined)?.message ?? String(cause);
116+
// `cause` is attached by hand: the `ErrorOptions` overload is ES2022 and this
117+
// package compiles against ES2020's lib.
118+
const refusal = new Error(
119+
`wasm SQLite cannot read it (${reason}). To start from an empty database, move the ` +
120+
`file aside yourself (mv '${filename}' '${filename}.unreadable') and start again; ` +
121+
`to keep its data, restore a copy that opens`,
122+
) as UnreadableDatabaseFileRefusal;
123+
refusal.code = reason === SQLITE_NOTADB_ERRSTR ? 'SQLITE_NOTADB' : 'SQLITE_CORRUPT';
124+
refusal.filename = filename;
125+
refusal.cause = cause;
126+
return refusal;
127+
}
128+
129+
/** SQLite's own result-code names for a file it cannot read, as the native driver carries them. */
130+
type UnreadableDatabaseFileCode = 'SQLITE_NOTADB' | 'SQLITE_CORRUPT';
131+
132+
type UnreadableDatabaseFileRefusal = Error & {
133+
code: UnreadableDatabaseFileCode;
134+
filename: string;
135+
cause: unknown;
136+
};
137+
89138
/**
90139
* A sql.js-backed connection that exposes the `prepare`/`exec`/`close`
91140
* subset used by Knex's SQLite dialect. Mutations are queued through a
@@ -198,24 +247,37 @@ export class WasmSqliteConnection {
198247
return;
199248
}
200249

201-
await this.quarantineOrphanedWal();
202-
203-
// Open the on-disk bytes, but guard against a corrupt image. A torn write
204-
// (process killed mid-flush before atomic writes existed) or otherwise
205-
// damaged file makes `new SQL.Database(bytes)` either throw ("file is not a
206-
// database") or open a handle whose every query fails with "database disk
207-
// image is malformed" — which, for a background dispatcher on a tick loop,
208-
// means the same error spammed forever with no path to recovery. Detect it
209-
// once at open, quarantine the bad file, and start fresh so the dev server
210-
// becomes usable again instead of wedging.
250+
// Open the on-disk bytes, and refuse a file sql.js cannot read (#25019).
251+
// A file that is not a database makes the first read throw "file is not a
252+
// database"; a damaged one throws "database disk image is malformed" or
253+
// fails `quick_check`. Either way every later query would answer the same,
254+
// so the open fails here, naming the file, with the code the native driver
255+
// answers for the same file (see `unreadableDatabaseFileRefusal`).
256+
//
257+
// The file is left exactly as it was found. This open used to move it to
258+
// `<file>.corrupt-<timestamp>` and start an empty database in its place: a
259+
// self-heal written for torn wasm images, which `atomicWriteFile` has since
260+
// closed. What still reached it was an operator's file on a declared
261+
// datasource — an explicit `sqlite-wasm` one in any mode, and the
262+
// development step-down from a native addon that cannot load — which then
263+
// read as connected over an empty database.
264+
let candidate: Database | undefined;
211265
try {
212-
const candidate = new SQL.Database(bytes);
266+
candidate = new SQL.Database(bytes);
213267
this.assertReadable(candidate);
214-
this.db = candidate;
215268
} catch (err) {
216-
await this.quarantineCorruptFile(err);
217-
this.db = new SQL.Database();
269+
try {
270+
candidate?.close();
271+
} catch {
272+
/* the refusal below is the answer */
273+
}
274+
throw unreadableDatabaseFileRefusal(this.filename, err);
218275
}
276+
277+
// Only a file this open accepts gets its stale log parked: a refused file
278+
// keeps everything beside it as well.
279+
await this.quarantineOrphanedWal();
280+
this.db = candidate;
219281
}
220282

221283
/**
@@ -233,35 +295,6 @@ export class WasmSqliteConnection {
233295
}
234296
}
235297

236-
/**
237-
* Move a corrupt database file aside so its bytes are preserved for
238-
* post-mortem while a fresh, empty database takes its place. Best-effort:
239-
* failures here must not prevent the server from booting on a clean DB.
240-
*/
241-
private async quarantineCorruptFile(cause: unknown): Promise<void> {
242-
if (!this.fs) return;
243-
const reason =
244-
typeof cause === 'string' ? cause : (cause as Error)?.message ?? String(cause);
245-
const backup = `${this.filename}.corrupt-${Date.now()}`;
246-
try {
247-
await this.fs.rename(this.filename, backup);
248-
this.logger.warn(
249-
`[driver-sqlite-wasm] Database image at ${this.filename} is corrupt ` +
250-
`(${reason}). Quarantined to ${backup} and starting from an empty ` +
251-
`database so the server can boot.`,
252-
);
253-
} catch (renameErr) {
254-
// Could not move it aside (e.g. permissions) — overwrite is still better
255-
// than looping forever on a malformed image. Warn loudly and continue.
256-
this.logger.warn(
257-
`[driver-sqlite-wasm] Database image at ${this.filename} is corrupt ` +
258-
`(${reason}) and could not be quarantined (${String(renameErr)}). ` +
259-
`Starting from an empty database; the corrupt file will be overwritten ` +
260-
`on the next flush.`,
261-
);
262-
}
263-
}
264-
265298
/**
266299
* Move a write-ahead log left behind by a *real* SQLite aside (#3941).
267300
*

0 commit comments

Comments
 (0)