Skip to content

Commit dadb4f7

Browse files
committed
fix(driver-sqlite-wasm): atomic flushes, corrupt-image self-heal, undefined→null bindings
The wasm SQLite driver is the dev fallback used whenever native better-sqlite3 has an ABI mismatch, so its failure modes surface as `pnpm dev` startup noise. Three root causes are fixed: - Persist flushes atomically (temp file + fsync + rename) so a process killed mid-write no longer leaves a torn file that reads back as "database disk image is malformed" on the next boot. - Detect a corrupt on-disk image at open via PRAGMA quick_check, quarantine it to `<db>.corrupt-<timestamp>`, and boot on a fresh database instead of failing every query forever. - Coerce `undefined` bindings to SQL NULL. sql.js throws a raw string for an undefined bind ("Wrong API use : ... unknown type (undefined)"), which aborted the write and logged as a garbled char-indexed object; NULL matches the useNullAsDefault semantics and the native better-sqlite3 path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111PrLebaDQAsjWmYrrcZmE
1 parent e46169c commit dadb4f7

4 files changed

Lines changed: 260 additions & 3 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/driver-sqlite-wasm": patch
3+
---
4+
5+
Harden the wasm SQLite driver (the dev fallback used when native `better-sqlite3` has an ABI mismatch) against three failure modes that spammed `pnpm dev`:
6+
7+
- **Atomic flushes.** The database was persisted with a plain `writeFile` that truncates-then-streams in place, so a process killed mid-write (a dev-server restart, Ctrl-C, or crash — likely under `on-write`, where every dispatcher tick flushes) left a torn file that sql.js rejected on the next boot with `database disk image is malformed`. Flushes now write to a sibling temp file, `fsync`, and atomically `rename()` over the target, so a reader always sees a complete image.
8+
- **Corruption self-heal.** When an on-disk image is already corrupt, the driver now detects it at open (via `PRAGMA quick_check`), quarantines the bad file to `<db>.corrupt-<timestamp>`, and boots on a fresh database — instead of failing every query forever with no path to recovery.
9+
- **`undefined` bindings.** A raw `undefined` binding made sql.js `throw` a plain string (`Wrong API use : tried to bind a value of an unknown type (undefined).`), which aborted the write and logged as a garbled char-indexed object. `undefined` is now coerced to SQL `NULL`, matching the driver's `useNullAsDefault` semantics and the native better-sqlite3 path.

packages/plugins/driver-sqlite-wasm/src/knex-wasm-dialect.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,20 @@ export interface WasmSqliteConnectionSettings {
6565
/**
6666
* Coerce JS values that sql.js cannot bind directly. Mirrors
6767
* `Client_BetterSQLite3._formatBindings`.
68+
*
69+
* `undefined` is mapped to `null`: sql.js's binder only accepts
70+
* string/number/bigint/boolean/null (and array/blob) and `throw`s a *raw
71+
* string* — `"Wrong API use : tried to bind a value of an unknown type
72+
* (undefined)."` — for anything else. Because it throws a string rather than
73+
* an `Error`, it logs as a garbled char-indexed object and aborts the whole
74+
* write. Mapping to `null` matches the `useNullAsDefault` semantics the
75+
* dialect is configured with, so a missing/undefined value persists as SQL
76+
* `NULL` exactly as it would through better-sqlite3.
6877
*/
6978
function formatBindings(bindings: unknown[] | undefined): unknown[] {
7079
if (!bindings) return [];
7180
return bindings.map((b) => {
81+
if (b === undefined) return null;
7282
if (b instanceof Date) return b.valueOf();
7383
if (typeof b === 'boolean') return Number(b);
7484
return b;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterEach } from 'vitest';
4+
import {
5+
mkdtempSync,
6+
rmSync,
7+
writeFileSync,
8+
readFileSync,
9+
readdirSync,
10+
existsSync,
11+
statSync,
12+
} from 'node:fs';
13+
import { tmpdir } from 'node:os';
14+
import { join } from 'node:path';
15+
16+
import { SqliteWasmDriver } from '../src/index.js';
17+
18+
/**
19+
* Durability + resilience regressions for the wasm SQLite driver — the dev
20+
* fallback used whenever native better-sqlite3 has an ABI mismatch.
21+
*
22+
* 1. Flushes must be ATOMIC. A non-atomic overwrite torn by a mid-write kill
23+
* 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.
26+
* 3. `undefined` bindings must persist as SQL NULL rather than throwing sql.js's
27+
* raw-string "Wrong API use : tried to bind a value of an unknown type".
28+
*/
29+
describe('SqliteWasmDriver durability & resilience', () => {
30+
const dirs: string[] = [];
31+
const drivers: SqliteWasmDriver[] = [];
32+
33+
function newDir(): string {
34+
const dir = mkdtempSync(join(tmpdir(), 'wasm-dur-'));
35+
dirs.push(dir);
36+
return dir;
37+
}
38+
39+
function track(d: SqliteWasmDriver): SqliteWasmDriver {
40+
drivers.push(d);
41+
return d;
42+
}
43+
44+
afterEach(async () => {
45+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
46+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
47+
});
48+
49+
it('writes the database file atomically (no leftover temp files)', async () => {
50+
const dir = newDir();
51+
const file = join(dir, 'atomic.db');
52+
const driver = track(new SqliteWasmDriver({ filename: file, persist: 'on-write' }));
53+
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
54+
55+
await (driver as any).knex('acct').insert({ id: 'a1', name: 'A' });
56+
await (driver as any).flush();
57+
58+
expect(existsSync(file)).toBe(true);
59+
expect(statSync(file).size).toBeGreaterThan(0);
60+
// The temp file used for the atomic rename must not survive a clean flush.
61+
const leftovers = readdirSync(dir).filter((f) => f.includes('.tmp-'));
62+
expect(leftovers).toEqual([]);
63+
64+
// The written file is a valid, re-readable SQLite image.
65+
const reopened = track(
66+
new SqliteWasmDriver({ filename: file, persist: 'on-disconnect' }),
67+
);
68+
const rows = await (reopened as any).knex('acct');
69+
expect(rows.length).toBe(1);
70+
expect(rows[0].name).toBe('A');
71+
});
72+
73+
it('self-heals a corrupt database image by quarantining it and booting fresh', async () => {
74+
const dir = newDir();
75+
const file = join(dir, 'corrupt.db');
76+
// 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").
78+
const junk = Buffer.concat([
79+
Buffer.from('SQLite format 3\0', 'binary'),
80+
Buffer.alloc(4096, 0xab),
81+
]);
82+
writeFileSync(file, junk);
83+
84+
const warnings: string[] = [];
85+
const driver = track(
86+
new SqliteWasmDriver({
87+
filename: file,
88+
persist: 'on-write',
89+
logger: { warn: (m: string) => warnings.push(m) },
90+
}),
91+
);
92+
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);
98+
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);
105+
});
106+
107+
it('persists an undefined binding as SQL NULL instead of throwing', async () => {
108+
const driver = track(new SqliteWasmDriver({ filename: ':memory:' }));
109+
await driver.initObjects([
110+
{ name: 'acct', fields: { name: { type: 'string' }, note: { type: 'string' } } },
111+
]);
112+
const knex = (driver as any).knex;
113+
114+
// A raw binding of `undefined` is exactly what tripped sql.js's binder.
115+
await expect(
116+
knex('acct').insert({ id: 'a1', name: 'A', note: undefined }),
117+
).resolves.not.toThrow();
118+
119+
const rows = await knex('acct').where('id', 'a1');
120+
expect(rows.length).toBe(1);
121+
expect(rows[0].note).toBeNull();
122+
});
123+
});

packages/plugins/driver-sqlite-wasm/src/wasm-connection.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ async function loadSqlJs(
9797
* configurable persistence strategy so the on-disk file stays in sync.
9898
*/
9999
export class WasmSqliteConnection {
100+
/**
101+
* Process-wide counter making each atomic-write temp filename unique, so
102+
* concurrent connections (or overlapping flushes) never target the same
103+
* temp path. Combined with `process.pid` for cross-process uniqueness.
104+
*/
105+
private static tmpSeq = 0;
106+
100107
readonly filename: string;
101108
readonly persist: PersistMode;
102109
readonly isEphemeral: boolean;
@@ -177,7 +184,71 @@ export class WasmSqliteConnection {
177184
if (e?.code !== 'ENOENT') throw e;
178185
}
179186

180-
this.db = bytes ? new SQL.Database(bytes) : new SQL.Database();
187+
if (!bytes) {
188+
this.db = new SQL.Database();
189+
return;
190+
}
191+
192+
// Open the on-disk bytes, but guard against a corrupt image. A torn write
193+
// (process killed mid-flush before atomic writes existed) or otherwise
194+
// damaged file makes `new SQL.Database(bytes)` either throw ("file is not a
195+
// database") or open a handle whose every query fails with "database disk
196+
// image is malformed" — which, for a background dispatcher on a tick loop,
197+
// means the same error spammed forever with no path to recovery. Detect it
198+
// once at open, quarantine the bad file, and start fresh so the dev server
199+
// becomes usable again instead of wedging.
200+
try {
201+
const candidate = new SQL.Database(bytes);
202+
this.assertReadable(candidate);
203+
this.db = candidate;
204+
} catch (err) {
205+
await this.quarantineCorruptFile(err);
206+
this.db = new SQL.Database();
207+
}
208+
}
209+
210+
/**
211+
* Force sql.js to actually read a page so a malformed image surfaces now
212+
* rather than on the first business query. `PRAGMA quick_check` walks the
213+
* b-tree structure without the full-scan cost of `integrity_check`; a healthy
214+
* database returns a single `ok` row. Any thrown error (raw string or Error)
215+
* or a non-`ok` result is treated as corruption.
216+
*/
217+
private assertReadable(db: Database): void {
218+
const res = db.exec('PRAGMA quick_check(1)');
219+
const first = res?.[0]?.values?.[0]?.[0];
220+
if (typeof first === 'string' && first.toLowerCase() !== 'ok') {
221+
throw new Error(`sqlite quick_check failed: ${first}`);
222+
}
223+
}
224+
225+
/**
226+
* Move a corrupt database file aside so its bytes are preserved for
227+
* post-mortem while a fresh, empty database takes its place. Best-effort:
228+
* failures here must not prevent the server from booting on a clean DB.
229+
*/
230+
private async quarantineCorruptFile(cause: unknown): Promise<void> {
231+
if (!this.fs) return;
232+
const reason =
233+
typeof cause === 'string' ? cause : (cause as Error)?.message ?? String(cause);
234+
const backup = `${this.filename}.corrupt-${Date.now()}`;
235+
try {
236+
await this.fs.rename(this.filename, backup);
237+
this.logger.warn(
238+
`[driver-sqlite-wasm] Database image at ${this.filename} is corrupt ` +
239+
`(${reason}). Quarantined to ${backup} and starting from an empty ` +
240+
`database so the server can boot.`,
241+
);
242+
} catch (renameErr) {
243+
// Could not move it aside (e.g. permissions) — overwrite is still better
244+
// than looping forever on a malformed image. Warn loudly and continue.
245+
this.logger.warn(
246+
`[driver-sqlite-wasm] Database image at ${this.filename} is corrupt ` +
247+
`(${reason}) and could not be quarantined (${String(renameErr)}). ` +
248+
`Starting from an empty database; the corrupt file will be overwritten ` +
249+
`on the next flush.`,
250+
);
251+
}
181252
}
182253

183254
/**
@@ -271,8 +342,8 @@ export class WasmSqliteConnection {
271342
try {
272343
const exported = this.db.export();
273344
// sql.js returns a Uint8Array; Buffer.from on it shares memory but
274-
// works fine for the synchronous writeFile enqueue below.
275-
await this.fs!.writeFile(this.filename, Buffer.from(exported));
345+
// works fine for the atomic write below.
346+
await this.atomicWriteFile(Buffer.from(exported));
276347
} catch (err) {
277348
this.dirty = true; // let a later flush retry
278349
throw err;
@@ -284,6 +355,50 @@ export class WasmSqliteConnection {
284355
await step;
285356
}
286357

358+
/**
359+
* Write the database bytes to disk atomically: write to a sibling temp file,
360+
* fsync it, then `rename()` it over the target.
361+
*
362+
* A plain `writeFile(this.filename, …)` truncates the target and streams the
363+
* new bytes in place, so a process killed mid-write (a dev-server restart,
364+
* Ctrl-C, or crash — likely under `on-write`, where every dispatcher tick
365+
* flushes) leaves a half-written file. sql.js then rejects that file on the
366+
* next boot with "database disk image is malformed". `rename(2)` is atomic
367+
* within a filesystem, so a reader always sees either the complete old file
368+
* or the complete new one — never a torn image. The temp file lives in the
369+
* same directory as the target so the rename stays intra-filesystem.
370+
*/
371+
private async atomicWriteFile(data: Buffer): Promise<void> {
372+
if (!this.fs) return;
373+
const tmp = `${this.filename}.tmp-${process.pid}-${(WasmSqliteConnection.tmpSeq += 1)}`;
374+
let handle: import('node:fs/promises').FileHandle | undefined;
375+
try {
376+
handle = await this.fs.open(tmp, 'w');
377+
await handle.writeFile(data);
378+
// Flush the bytes to the platter before the rename so a crash can't leave
379+
// a renamed-but-empty file behind on filesystems that reorder the two.
380+
await handle.sync();
381+
await handle.close();
382+
handle = undefined;
383+
await this.fs.rename(tmp, this.filename);
384+
} catch (err) {
385+
if (handle) {
386+
try {
387+
await handle.close();
388+
} catch {
389+
/* ignore */
390+
}
391+
}
392+
// Clean up the temp file so a failed flush doesn't litter the data dir.
393+
try {
394+
await this.fs.unlink(tmp);
395+
} catch {
396+
/* ignore */
397+
}
398+
throw err;
399+
}
400+
}
401+
287402
/** Close the database, flushing any pending writes first. */
288403
async close(): Promise<void> {
289404
if (this.destroyed) return;

0 commit comments

Comments
 (0)