Skip to content

Commit 24915d2

Browse files
os-zhuangclaude
andauthored
fix(driver-sqlite-wasm): persist RETURNING writes — unblock cold-boot e2e (#4518) (#4564)
A file-backed sqlite-wasm database flushed its schema at boot and then recorded nothing else: every table on disk, every subsequent row only in the WASM heap. `bootStack({ databaseFile })` therefore could not cold-boot, which blocked #4470's third minimal form. Root cause is in the Knex dialect, not the harness. `_query` picked its execution branch from "does this statement return rows" and then set the dirty flag only on the other, row-less branch. `INSERT ... RETURNING *` returns rows — and that is the shape ObjectQL writes with — so it executed on the row-returning branch and never marked the database dirty. The `on-disconnect` flush is gated on the same flag, so both persist strategies dropped the write; `knex.raw('INSERT ...')` (no Knex `method`) was lost the same way. "Does this statement change the database?" is now one exported predicate, `statementMutatesDatabase(sql, method)`, classifying by method AND SQL text and applied at a single funnel after execution — independent of which branch ran it. Transaction control still routes to `noteTransactionControl` so flushes stay deferred until a transaction closes (#1494); mutating PRAGMA assignments now count as writes. `WasmSqliteConnection.markDirty()` loses its method argument: re-filtering there made the same decision in two places that could disagree, which is precisely how the branches diverged. Tests: a new driver-level suite pins every execution branch (all six fail when the fix is reverted), and `flow-durable-suspend.dogfood.test.ts` loses its KNOWN GAP — it now suspends, shuts the kernel down, cold-boots a second kernel over the same file, resumes there, and proves the result survives a third boot, plus the plain-record assertion that identified this as a driver defect rather than a suspended-run one. Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6bd5a1 commit 24915d2

6 files changed

Lines changed: 471 additions & 61 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/driver-sqlite-wasm": patch
3+
---
4+
5+
fix(driver-sqlite-wasm): a `RETURNING` write is a write — persist it (#4518)
6+
7+
A file-backed `sqlite-wasm` database flushed its schema at boot and then
8+
recorded nothing else. Every table was on disk; every row written after schema
9+
sync lived only in the WASM heap and died with the process. Reopening the file
10+
found a complete, empty database.
11+
12+
**Cause.** The Knex dialect picked its execution branch from *"does this
13+
statement return rows"* — and then marked the database dirty only on the other,
14+
row-less branch. `INSERT … RETURNING *` returns rows, so it executed on the
15+
row-returning branch and never set the flag. Since the `on-disconnect` flush is
16+
gated on the same flag, nothing rescued it afterwards either: **both** persist
17+
strategies dropped the write. ObjectQL writes through `RETURNING *` (it hands
18+
the stored row back to the caller), so this covered essentially all business
19+
data, along with `knex.raw('INSERT …')` and any other mutation arriving without
20+
a Knex `method`.
21+
22+
**Fix.** "Does this statement change the database?" is now one exported
23+
predicate — `statementMutatesDatabase(sql, method)` — classifying by Knex method
24+
*and* SQL text, applied at a single funnel after execution. It is independent of
25+
which branch executed the statement, so a mutation can no longer slip through by
26+
returning rows, by arriving without a method, or by taking a branch that forgot
27+
to say so. Transaction control still routes to `noteTransactionControl`, which
28+
keeps deferring flushes until the transaction closes (#1494), and mutating
29+
`PRAGMA` assignments (`auto_vacuum`, `user_version`) now count as writes too.
30+
31+
**What changes for you.** Nothing to author. File-backed wasm SQLite now
32+
actually persists under `on-write` / `debounced:*`, and `disconnect()` is a real
33+
durability boundary: when it returns, committed data is on disk. This is what
34+
`bootStack({ databaseFile })` in `@objectstack/verify` needed to make `stop()`
35+
second `bootStack` a genuine cold boot — the suspended-run restart proof
36+
ADR-0019 promises is now asserted end to end in the dogfood gate. Expect more
37+
disk writes than before on a file-backed dev database, because previously there
38+
were almost none.
39+
40+
**One internal signature moved.** `WasmSqliteConnection.markDirty(method?)` is
41+
now `markDirty()`. It used to re-filter the caller's Knex method against its own
42+
allowlist, which made "did this mutate?" a decision taken in two places that
43+
could — and did — disagree. If you call it directly, drop the argument; the
44+
dialect classifies, the connection obeys.

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

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,69 @@ function formatBindings(bindings: unknown[] | undefined): unknown[] {
9292
* Everything else — `select`, `first`, `pluck`, `columnInfo`, raw PRAGMA,
9393
* DDL with no `method` — is read with `all`/row iteration so Knex sees the
9494
* same response shape it would from better-sqlite3.
95+
*
96+
* ⚠️ This answers "how do I EXECUTE this statement", never "does this statement
97+
* change the database" — an `INSERT … RETURNING *` is executed down the
98+
* row-returning branch and mutates. Persistence is classified separately by
99+
* {@link statementMutatesDatabase}; conflating the two is #4518.
95100
*/
96-
function isReadMethod(method?: string, returning?: unknown): boolean {
101+
function isRowReturningExecution(method?: string, returning?: unknown): boolean {
97102
if (method === 'insert' || method === 'update') return !!returning ? true : false;
98103
if (method === 'counter' || method === 'del') return false;
99104
return true;
100105
}
101106

107+
/** Knex `method` values that always denote a mutation. */
108+
const MUTATING_METHODS = new Set(['insert', 'update', 'del', 'counter']);
109+
110+
/** Statement-control forms whose persistence is owned by the transaction lifecycle. */
111+
const TRANSACTION_CONTROL_RE = /^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i;
112+
113+
/**
114+
* DDL / schema statements. `BEGIN…RELEASE` share this prefix set in SQLite's
115+
* grammar but are transaction control, so they are matched (and routed) first.
116+
*/
117+
const DDL_RE =
118+
/^\s*(CREATE|ALTER|DROP|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|REINDEX|VACUUM|ATTACH|DETACH|TRUNCATE)\b/i;
119+
120+
/** DML that changes rows, whatever execution branch it happens to run down. */
121+
const MUTATING_DML_RE = /^\s*(INSERT|UPDATE|DELETE|REPLACE|UPSERT)\b/i;
122+
123+
/**
124+
* PRAGMA forms that change bytes in the database file: any assignment
125+
* (`PRAGMA auto_vacuum = INCREMENTAL`, `PRAGMA user_version = 3` — both
126+
* persistent header state) and `incremental_vacuum`, which actually moves
127+
* pages. Introspection PRAGMAs (`table_info`, `index_list`, …) are reads.
128+
*/
129+
const MUTATING_PRAGMA_RE = /^\s*PRAGMA\b(?:[^;]*=|\s+incremental_vacuum\b)/i;
130+
131+
/**
132+
* THE single answer to "did this statement change the database, so that the
133+
* in-memory image must eventually be written back to disk?"
134+
*
135+
* It is deliberately independent of which execution branch {@link
136+
* isRowReturningExecution} picks, because those are different questions and
137+
* answering them with one predicate is what broke persistence in #4518: the
138+
* ObjectQL engine writes through `INSERT … RETURNING *` / `UPDATE … RETURNING *`
139+
* (it needs the stored row back), those run down the row-returning branch, and
140+
* the dirty flag was only ever set on the other branch. The result was a
141+
* file-backed database that flushed its schema and then silently stopped
142+
* recording anything — a cold boot found every table present and every row
143+
* gone, and `on-disconnect` did not save it either, because the final flush
144+
* also keys off the same flag.
145+
*
146+
* Classifying by BOTH the Knex `method` and the SQL text means a mutation
147+
* cannot slip through by arriving without a method (`knex.raw('INSERT …')`,
148+
* seed/migration SQL) or by taking an unexpected branch.
149+
*/
150+
export function statementMutatesDatabase(sql: string, method?: string): boolean {
151+
if (TRANSACTION_CONTROL_RE.test(sql)) return false; // owned by noteTransactionControl
152+
if (method && MUTATING_METHODS.has(method)) return true;
153+
if (DDL_RE.test(sql)) return true;
154+
if (MUTATING_DML_RE.test(sql)) return true;
155+
return MUTATING_PRAGMA_RE.test(sql);
156+
}
157+
102158
/**
103159
* Resolve the upstream `knex/lib/dialects/sqlite3` class at runtime.
104160
*
@@ -179,35 +235,27 @@ export function getClient_WasmSqlite(): any {
179235
const db = connection.raw;
180236
const bindings = formatBindings(obj.bindings);
181237

182-
// DDL / transactional control statements have no Knex `method`. sql.js's
238+
// ── 1. EXECUTE ────────────────────────────────────────────────────────
239+
// Three execution shapes. None of them decides persistence: that is
240+
// settled once, below, so a statement cannot mutate the database on a
241+
// branch that forgot to say so (#4518).
242+
243+
// DDL / transaction control have no Knex `method`. sql.js's
183244
// `prepare`+`step` silently no-ops on many of these (e.g. CREATE TABLE),
184245
// so route them through `run` which is implemented via `exec` and
185246
// actually mutates the database. PRAGMA is intentionally excluded — many
186247
// PRAGMA forms (e.g. `PRAGMA table_info(...)`, `foreign_key_list(...)`)
187248
// return rows used by Knex's schema introspection/columnInfo, and
188249
// `db.run` discards those rows.
189-
const isDdl =
190-
/^\s*(CREATE|ALTER|DROP|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|REINDEX|VACUUM|ATTACH|DETACH|TRUNCATE)\b/i.test(
191-
obj.sql,
192-
);
193-
if (isDdl) {
250+
if (DDL_RE.test(obj.sql)) {
194251
db.run(obj.sql, bindings as any);
195252
obj.response = [];
196-
// Transaction-control statements are routed through
197-
// `noteTransactionControl`, which owns flushing for the transaction
198-
// lifecycle: it suppresses flushes while a transaction is open (sql.js
199-
// `export()` closes+reopens the db, which would abort the txn) and
200-
// performs a single flush once the transaction fully closes. Routing
201-
// them away from `markDirty` avoids a second, racing flush on COMMIT.
202-
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(obj.sql)) {
203-
connection.noteTransactionControl(obj.sql);
204-
} else {
205-
connection.markDirty('run');
206-
}
207-
return obj;
208-
}
209-
210-
if (isReadMethod(obj.method, obj.returning) || /^\s*PRAGMA\b/i.test(obj.sql)) {
253+
} else if (
254+
isRowReturningExecution(obj.method, obj.returning) ||
255+
/^\s*PRAGMA\b/i.test(obj.sql)
256+
) {
257+
// Row-returning branch. NOTE this is also where `INSERT … RETURNING *`
258+
// and `UPDATE … RETURNING *` land — statements that very much write.
211259
const stmt = db.prepare(obj.sql);
212260
try {
213261
if (bindings.length) stmt.bind(bindings as any);
@@ -219,21 +267,34 @@ export function getClient_WasmSqlite(): any {
219267
} finally {
220268
stmt.free();
221269
}
222-
return obj;
270+
} else {
271+
// Row-less write path: execute via `run` and capture SQLite's
272+
// per-connection lastID / changes counters.
273+
db.run(obj.sql, bindings as any);
274+
const changes = db.getRowsModified();
275+
let lastID: number | bigint = 0;
276+
if (obj.method === 'insert') {
277+
const r = db.exec('SELECT last_insert_rowid() AS id');
278+
lastID = (r?.[0]?.values?.[0]?.[0] as number) ?? 0;
279+
}
280+
obj.response = [];
281+
obj.context = { lastID, changes };
223282
}
224283

225-
// Write path: execute via `run` (no row iteration needed) and capture
226-
// SQLite's per-connection lastID / changes counters.
227-
db.run(obj.sql, bindings as any);
228-
const changes = db.getRowsModified();
229-
let lastID: number | bigint = 0;
230-
if (obj.method === 'insert') {
231-
const r = db.exec('SELECT last_insert_rowid() AS id');
232-
lastID = (r?.[0]?.values?.[0]?.[0] as number) ?? 0;
284+
// ── 2. PERSIST ────────────────────────────────────────────────────────
285+
// Exactly one place decides whether the on-disk image is now stale.
286+
//
287+
// Transaction-control statements are routed to `noteTransactionControl`,
288+
// which owns flushing across the transaction lifecycle: it suppresses
289+
// flushes while a transaction is open (sql.js `export()` closes+reopens
290+
// the db, which would abort the txn) and performs a single flush once the
291+
// transaction fully closes. Routing them away from `markDirty` avoids a
292+
// second, racing flush on COMMIT.
293+
if (TRANSACTION_CONTROL_RE.test(obj.sql)) {
294+
connection.noteTransactionControl(obj.sql);
295+
} else if (statementMutatesDatabase(obj.sql, obj.method)) {
296+
connection.markDirty();
233297
}
234-
obj.response = [];
235-
obj.context = { lastID, changes };
236-
connection.markDirty(obj.method);
237298
return obj;
238299
}
239300
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterEach } from 'vitest';
4+
import { mkdtempSync, rmSync } from 'node:fs';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
7+
8+
import { SqliteWasmDriver } from '../src/index.js';
9+
import { statementMutatesDatabase } from './knex-wasm-dialect.js';
10+
11+
/**
12+
* #4518 — a mutation must mark the database dirty on EVERY execution branch.
13+
*
14+
* The dialect picks its execution branch from "does this statement return
15+
* rows", and `INSERT … RETURNING *` returns rows — so it ran down the branch
16+
* that never called `markDirty`. Since the final `on-disconnect` flush keys off
17+
* the same flag, nothing rescued it either: a file-backed database flushed its
18+
* schema at boot and then recorded nothing for the rest of its life. Every
19+
* table present, every row missing.
20+
*
21+
* That is not a corner case for this stack. ObjectQL writes through
22+
* `RETURNING *` because it hands the stored row back to the caller, so the
23+
* defect covered essentially all business data — which is why it surfaced as
24+
* "`bootStack({ databaseFile })` cannot cold-boot" rather than as a driver bug.
25+
*
26+
* These tests read the database back through a SECOND driver over the same
27+
* file, so they assert what is on disk rather than what the live WASM heap
28+
* still remembers.
29+
*/
30+
describe('SqliteWasmDriver persists mutations from every execution branch (#4518)', () => {
31+
const dirs: string[] = [];
32+
const drivers: SqliteWasmDriver[] = [];
33+
34+
function newFile(): string {
35+
const dir = mkdtempSync(join(tmpdir(), 'wasm-returning-'));
36+
dirs.push(dir);
37+
return join(dir, 'db.sqlite');
38+
}
39+
40+
function track(d: SqliteWasmDriver): SqliteWasmDriver {
41+
drivers.push(d);
42+
return d;
43+
}
44+
45+
/** Reopen the on-disk image in a fresh driver — a cold read, in miniature. */
46+
async function readBack(file: string): Promise<Record<string, any>[]> {
47+
const reopened = track(new SqliteWasmDriver({ filename: file, persist: 'on-disconnect' }));
48+
return (reopened as any).knex('acct').orderBy('id');
49+
}
50+
51+
afterEach(async () => {
52+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
53+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
54+
});
55+
56+
it('classifies a statement by what it DOES, not by which branch executes it', () => {
57+
// The row-returning branch and the mutating set overlap; that overlap is
58+
// the whole defect, so it is pinned directly.
59+
expect(statementMutatesDatabase('insert into "acct" ("id") values (?) returning *', 'insert')).toBe(true);
60+
expect(statementMutatesDatabase('update "acct" set "name" = ? returning *', 'update')).toBe(true);
61+
expect(statementMutatesDatabase("INSERT INTO acct (id) VALUES ('x')")).toBe(true);
62+
expect(statementMutatesDatabase('DELETE FROM acct WHERE id = ?')).toBe(true);
63+
expect(statementMutatesDatabase('CREATE TABLE acct (id text)')).toBe(true);
64+
expect(statementMutatesDatabase('PRAGMA auto_vacuum = INCREMENTAL')).toBe(true);
65+
66+
expect(statementMutatesDatabase('select * from "acct"', 'select')).toBe(false);
67+
expect(statementMutatesDatabase('PRAGMA table_info(acct)')).toBe(false);
68+
// Transaction control is owned by the transaction lifecycle, which must NOT
69+
// flush mid-transaction: sql.js `export()` closes and reopens the database,
70+
// aborting the open transaction (#1494).
71+
expect(statementMutatesDatabase('BEGIN')).toBe(false);
72+
expect(statementMutatesDatabase('COMMIT')).toBe(false);
73+
expect(statementMutatesDatabase('SAVEPOINT sp1')).toBe(false);
74+
});
75+
76+
it('flushes an `INSERT … RETURNING` under on-write — the shape ObjectQL writes with', async () => {
77+
const file = newFile();
78+
const driver = track(new SqliteWasmDriver({ filename: file, persist: 'on-write' }));
79+
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
80+
81+
const returned = await (driver as any)
82+
.knex('acct')
83+
.insert({ id: 'a1', name: 'returning-insert' })
84+
.returning('*');
85+
expect(returned.length).toBe(1);
86+
87+
// `flush()` awaits the queued write; `on-write` schedules it fire-and-forget.
88+
await (driver as any).flush();
89+
90+
const onDisk = await readBack(file);
91+
expect(onDisk.map((r) => r.id)).toEqual(['a1']);
92+
expect(onDisk[0].name).toBe('returning-insert');
93+
});
94+
95+
it('flushes an `UPDATE … RETURNING` under on-write', async () => {
96+
const file = newFile();
97+
const driver = track(new SqliteWasmDriver({ filename: file, persist: 'on-write' }));
98+
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
99+
await (driver as any).knex('acct').insert({ id: 'a1', name: 'before' });
100+
await (driver as any).flush();
101+
102+
await (driver as any).knex('acct').where('id', 'a1').update({ name: 'after' }).returning('*');
103+
await (driver as any).flush();
104+
105+
expect((await readBack(file))[0].name).toBe('after');
106+
});
107+
108+
it('flushes a raw mutation that carries no Knex `method`', async () => {
109+
const file = newFile();
110+
const driver = track(new SqliteWasmDriver({ filename: file, persist: 'on-write' }));
111+
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
112+
113+
await (driver as any).knex.raw("INSERT INTO acct (id, name) VALUES ('r1', 'raw')");
114+
await (driver as any).flush();
115+
116+
expect((await readBack(file)).map((r) => r.id)).toEqual(['r1']);
117+
});
118+
119+
it('`on-disconnect` still saves RETURNING writes — disconnect() is the durability contract', async () => {
120+
// The second half of the same defect: the final flush is gated on the same
121+
// dirty flag, so an unmarked write was lost by BOTH persist strategies.
122+
// This is the invariant `bootStack({ databaseFile }).stop()` rests on.
123+
//
124+
// The explicit `flush()` after schema creation is what makes this test
125+
// DISCRIMINATING rather than incidentally green. Schema DDL did mark the
126+
// database dirty even before the fix, and under `on-disconnect` that flag
127+
// simply sat there until close — so the final export happened to carry the
128+
// unmarked row along with it. Clearing the flag first reproduces what
129+
// `on-write` did in production: every earlier flush reset it, and from then
130+
// on nothing set it again.
131+
const file = newFile();
132+
const driver = new SqliteWasmDriver({ filename: file, persist: 'on-disconnect' });
133+
drivers.push(driver);
134+
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
135+
await (driver as any).flush();
136+
137+
await (driver as any).knex('acct').insert({ id: 'd1', name: 'durable' }).returning('*');
138+
139+
await driver.disconnect();
140+
141+
const onDisk = await readBack(file);
142+
expect(onDisk.map((r) => r.id)).toEqual(['d1']);
143+
expect(onDisk[0].name).toBe('durable');
144+
});
145+
146+
it('persists RETURNING writes committed inside a transaction (no mid-transaction export)', async () => {
147+
const file = newFile();
148+
const driver = track(new SqliteWasmDriver({ filename: file, persist: 'on-write' }));
149+
await driver.initObjects([{ name: 'acct', fields: { name: { type: 'string' } } }]);
150+
const knex = (driver as any).knex;
151+
152+
await knex.transaction(async (trx: any) => {
153+
await trx('acct').insert({ id: 't1', name: 'in-tx' }).returning('*');
154+
await trx('acct').where('id', 't1').first();
155+
await trx('acct').insert({ id: 't2', name: 'in-tx-2' }).returning('*');
156+
});
157+
await (driver as any).flush();
158+
159+
expect((await readBack(file)).map((r) => r.id)).toEqual(['t1', 't2']);
160+
});
161+
});

0 commit comments

Comments
 (0)