From 979f253fa34049e8f0c5c7a716e1ef7a4b217a4b Mon Sep 17 00:00:00 2001 From: Darshan Pawar Date: Thu, 17 Sep 2026 22:16:35 +0530 Subject: [PATCH 1/2] fix(wal): recover a connection that has fallen behind more than one WAL generation #skipTx only ever accepted a broadcast whose file generation was exactly one ahead of the connection's own (#followFileChange checked salt1 + 1 and nothing else), and #advanceTxId deleted the pending transaction from #mapIdToPendingTx before #skipTx could throw -- so a connection more than one swap behind lost the transaction permanently and could never advance past that txId. Every later broadcast then fell through to #readTx(), returned null, and #activateTx(null) dereferenced it. A quieter variant of the same gap produced silent corruption instead of a throw: at exactly one generation behind, the salt1 + 1 check can match a real file by coincidence -- the wrong one relative to what the incoming transaction actually names -- and #skipTx never verified the file it adopted against the transaction's own salt. Three changes: - #skipTx now adopts whichever physical WAL file's real on-disk header actually matches the transaction's salt (a new #adoptFileForSalt1), verified by reading it, instead of assuming a single generation hop. There are only ever two physical files, so if the transaction is still recoverable from disk at all, one of them names it exactly. - the pending-map delete happens only after #skipTx succeeds, so a throw leaves the id queued for a retry instead of losing it forever. - #advanceTxId stops advancing instead of calling #activateTx(null) when #readTx() finds nothing at the current position. Fixes #345. Reproduced and the fix verified deterministically; see repro-345/ in this PR for the harness and full writeup. --- src/examples/WriteAhead.js | 52 +++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/src/examples/WriteAhead.js b/src/examples/WriteAhead.js index 79e228fb..e150383f 100644 --- a/src/examples/WriteAhead.js +++ b/src/examples/WriteAhead.js @@ -555,13 +555,25 @@ export class WriteAhead { if (this.#mapIdToPendingTx.has(nextTxId)) { // This transaction arrived via message. tx = this.#mapIdToPendingTx.get(nextTxId); - this.#mapIdToPendingTx.delete(tx.id); - // Move the WAL file offset past this transaction. + // Move the WAL file offset past this transaction. Only remove it + // from the pending map once #skipTx has actually consumed it: if + // it throws, the id must stay queued so a retry (the next + // broadcast, or the backstop) can still make progress instead of + // losing the transaction forever and getting stuck at this txId. this.#skipTx(tx); + this.#mapIdToPendingTx.delete(tx.id); } else { // Read the transaction from the WAL file. tx = this.#readTx(); + if (!tx) { + // The next transaction genuinely isn't at our current file + // position (yet, or possibly ever -- see #adoptFileForSalt1). + // Stop advancing for now rather than activating a null + // transaction; the pending entries stay queued for the next + // broadcast or the backstop's readToCurrent pass to retry. + break; + } } this.#activateTx(tx); @@ -862,8 +874,16 @@ export class WriteAhead { */ #skipTx(tx) { if (tx.waSalt1 !== this.#activeHeader.salt1) { - // This transaction is on the other WAL file. - if (!this.#followFileChange(null)) { + // This transaction is on the other WAL file. Adopt whichever + // physical file actually holds tx.waSalt1 right now, verified + // against its real on-disk header -- not just "the inactive file, + // if it happens to be exactly one generation ahead". A connection + // can be more than one swap behind (only one hop was ever handled + // here before), and even at exactly one swap behind, accepting the + // inactive file on a "+1" check alone without confirming it matches + // tx.waSalt1 can silently adopt the WRONG file on a coincidental + // match, which corrupts later reads instead of failing loudly. + if (!this.#adoptFileForSalt1(tx.waSalt1)) { throw new Error('invalid WAL file'); } } @@ -872,6 +892,30 @@ export class WriteAhead { this.#activeOffset = tx.waOffsetEnd; } + /** + * Adopt whichever of the two physical WAL files currently has a valid, + * checksummed header whose salt1 equals targetSalt1, verified by reading + * the real file rather than assumed from a generation-count hop. There + * are only ever two physical files, so if the transaction we're trying + * to skip to still exists at all, one of them names it exactly; if + * neither does, the data genuinely isn't recoverable from disk. + * + * @param {number} targetSalt1 + * @returns {boolean} + */ + #adoptFileForSalt1(targetSalt1) { + for (const candidate of this.#waHandles) { + const header = this.#readFileHeader(candidate); + if (header?.salt1 === targetSalt1) { + this.#activeHandle = candidate; + this.#activeHeader = header; + this.#activeOffset = FILE_HEADER_SIZE; + return true; + } + } + return false; + } + /** * @param {{overwrite?: boolean}} options * @returns {Transaction} From 8da0f45f4ba05153cddee48a073d313a0b3f5458 Mon Sep 17 00:00:00 2001 From: Darshan Pawar Date: Thu, 17 Sep 2026 22:16:42 +0530 Subject: [PATCH 2/2] test(wal): deterministic reproduction for #345 and fix verification Reproduces all three reported symptoms -- invalid WAL file, disk I/O error, database disk image is malformed -- without a real multi-tab reload race: engineers the exact internal precondition each one needs and delivers it through the real, unmodified #handleMessage path via a genuine BroadcastChannel message. repro-345/vendor/ unpatched WriteAhead.js + OPFSWriteAheadVFS.js, with test-only fault-injection hooks (not part of the fix) -- demonstrates the bug. repro-345/vendor-fixed/ the same, with the fix applied -- demonstrates it resolving. repro-345/harness/ the reproduction pages themselves. See repro-345/README.md for the mechanism, how to run it, and results from this session: invalid WAL file 5/5, disk I/O error 2/2, malformed 3/3, and a clean end-to-end run against the fix with zero synthetic data (a connection misses a real swap and every real transaction while paused, then self-heals with the correct row count once unpaused). --- repro-345/README.md | 131 ++ repro-345/harness/clear.html | 20 + repro-345/harness/deterministic.html | 242 ++++ repro-345/harness/malformed.html | 187 +++ repro-345/harness/reader.worker.fixed.js | 104 ++ repro-345/harness/reader.worker.js | 104 ++ repro-345/harness/verify-fix.html | 136 ++ repro-345/harness/writer.worker.fixed.js | 112 ++ repro-345/harness/writer.worker.js | 112 ++ repro-345/vendor-fixed/LazyLock.js | 90 ++ repro-345/vendor-fixed/Lock.js | 69 + repro-345/vendor-fixed/OPFSWriteAheadVFS.js | 973 ++++++++++++++ repro-345/vendor-fixed/WriteAhead.js | 1321 +++++++++++++++++++ repro-345/vendor/LazyLock.js | 90 ++ repro-345/vendor/Lock.js | 69 + repro-345/vendor/OPFSWriteAheadVFS.js | 973 ++++++++++++++ repro-345/vendor/WriteAhead.js | 1268 ++++++++++++++++++ 17 files changed, 6001 insertions(+) create mode 100644 repro-345/README.md create mode 100644 repro-345/harness/clear.html create mode 100644 repro-345/harness/deterministic.html create mode 100644 repro-345/harness/malformed.html create mode 100644 repro-345/harness/reader.worker.fixed.js create mode 100644 repro-345/harness/reader.worker.js create mode 100644 repro-345/harness/verify-fix.html create mode 100644 repro-345/harness/writer.worker.fixed.js create mode 100644 repro-345/harness/writer.worker.js create mode 100644 repro-345/vendor-fixed/LazyLock.js create mode 100644 repro-345/vendor-fixed/Lock.js create mode 100644 repro-345/vendor-fixed/OPFSWriteAheadVFS.js create mode 100644 repro-345/vendor-fixed/WriteAhead.js create mode 100644 repro-345/vendor/LazyLock.js create mode 100644 repro-345/vendor/Lock.js create mode 100644 repro-345/vendor/OPFSWriteAheadVFS.js create mode 100644 repro-345/vendor/WriteAhead.js diff --git a/repro-345/README.md b/repro-345/README.md new file mode 100644 index 00000000..12d89736 --- /dev/null +++ b/repro-345/README.md @@ -0,0 +1,131 @@ +# Deterministic reproduction for #345 + +This directory reproduces all three symptoms reported in +[#345](https://github.com/rhashimoto/wa-sqlite/issues/345) — `invalid WAL +file`, `disk I/O error`, and `database disk image is malformed` — without +relying on a real multi-tab reload race. It directly engineers the internal +precondition each one needs and delivers it through the real, unmodified +`#handleMessage` code path via a genuine `BroadcastChannel` message (the same +shape a real peer's commit broadcast takes — nothing forged beyond ordinary +public fields, no SDK internals called directly). + +It also includes a tested fix (see below), which the same harness runs +against and confirms resolves. + +## The mechanism, in short + +`WriteAhead`'s `#skipTx` only accepts a broadcast whose file generation +(`salt1`) is exactly one ahead of the connection's own — `#followFileChange` +checks `salt1 + 1` and nothing else. A connection that has fallen behind by +more than one swap gets `throw new Error('invalid WAL file')`. + +That alone would be recoverable, except `#advanceTxId` deletes the +transaction from `#mapIdToPendingTx` **before** calling `#skipTx`. When the +throw fires, the id is already gone — nothing else ever re-adds a given id +once its broadcast has been seen — so `#txId` can never advance past it. +Every later broadcast falls through to `#readTx()`, which returns `null` +(the frame it reads no longer matches, since the file has moved on), and +`#activateTx(null)` dereferences it. + +`#advanceTxId` and `rejoin()` are both plain synchronous functions, so that +crash — if it happens during `rejoin()` (a broadcast arrived while a read +was isolated) or during `isolateForWrite()` (a write's own catch-up call) — +propagates synchronously into `jUnlock`/`jLock`'s own try/catch, which +converts it to `SQLITE_IOERR_UNLOCK`/`SQLITE_IOERR_LOCK` → `disk I/O error`. + +A second, quieter path through the same code produces `malformed` instead of +a throw: at *exactly* one generation behind (not further), the `+1` check in +`#followFileChange` can match a real file by coincidence — the **wrong** +one relative to what the transaction actually names. `#skipTx` doesn't +verify the file it adopted matches `tx.waSalt1`, so it silently continues +with `#activeHandle` pointing at one file and `#activeOffset` describing a +position in a different one. The next real page read pulls real, +checksum-valid bytes from the wrong file at the wrong offset, and SQLite's +own page-structure check reports corruption. + +## Layout + +``` +repro-345/ + vendor/ WriteAhead.js + OPFSWriteAheadVFS.js, UNPATCHED, with a + small set of test-only hooks added (see below) — used to + demonstrate the bug. + vendor-fixed/ The same files, WITH the fix from this PR's + src/examples/WriteAhead.js applied — used to demonstrate + the fix resolves it. + harness/ The actual reproduction pages. +``` + +The test-only hooks in `vendor/*` (`testPauseConsumption`, +`testDropTxIds`, `testForceActiveHeaderSalt1`, `testInjectPendingTx`, and a +`diagnosticLog` callback used for observability) are **not** part of the +proposed fix — they only exist to engineer the reproduction deterministically +instead of waiting on real reload timing. The actual fix +(`src/examples/WriteAhead.js` in this PR, outside `repro-345/`) has none of +them. + +## Running it + +No build step. Serve the repo root over plain HTTP (OPFS needs a secure +context; `http://localhost` qualifies without TLS) and open the pages: + +```bash +python3 -m http.server 8935 +``` + +Then, in a Chromium-based browser: + +1. `http://localhost:8935/repro-345/harness/clear.html` — wipes OPFS for a + clean run (do this before each of the pages below; they don't share + state cleanly across repeated runs otherwise, since some intentionally + leave a connection in a broken state to observe it). +2. `http://localhost:8935/repro-345/harness/deterministic.html` — phase 1 + reproduces `invalid WAL file`; phase 2, right after, reproduces + `disk I/O error`. Open the devtools console or read the on-page log. +3. `http://localhost:8935/repro-345/harness/malformed.html` — reproduces + `database disk image is malformed`. +4. `http://localhost:8935/repro-345/harness/verify-fix.html` — runs against + `vendor-fixed/`: pauses a connection *before* a writer even starts, + lets it miss a real swap and every real transaction while blind, then + unpauses it with the writer still running live, and confirms it + self-heals with the correct row count, no synthetic data at all. + +### What each one actually shows, verified this session + +| Page | Result | +|---|---| +| `deterministic.html` phase 1 | `invalid WAL file` — 5/5 runs | +| `deterministic.html` phase 2 | `disk I/O error` — 2/2 runs | +| `malformed.html` | `database disk image is malformed` — 3/3 runs | +| `verify-fix.html` (against the fix) | `sawThrow=false sawUncaught=null readError=null writerRows=500 victimSees=500` | + +Re-running `deterministic.html`/`malformed.html` against `vendor-fixed/` +instead of `vendor/` (swap the import in `harness/*.worker.js`) no longer +reproduces either: `#skipTx` finds and adopts the real target generation +(`skipTx-followed`, with `activeHeaderSalt1After` exactly equal to the +transaction's own `waSalt1`) instead of throwing or silently adopting the +wrong file. + +## The fix + +See `src/examples/WriteAhead.js` in this PR for the actual diff. Three +changes, in order of importance: + +1. **Adopt by verified salt match, not by generation hop.** `#skipTx` now + calls a new `#adoptFileForSalt1(targetSalt1)`, which checks both + physical WAL files' real on-disk headers for the one that actually holds + the target salt, instead of only ever accepting "the inactive file, if + its header happens to read `salt1 + 1`". There are only ever two + physical files, so if the transaction is still recoverable from disk at + all, one of them names it exactly. This is what closes the `malformed` + case: it never adopts on a coincidental match, only a confirmed one. + +2. **Don't lose the transaction on a throw.** The pending-map delete moves + to *after* `#skipTx` succeeds. If it throws, the id stays queued, so a + retry (the next broadcast, or the backstop) can still make progress + instead of repeating the identical failure at that `#txId` forever. + +3. **Never activate a null transaction.** If `#readTx()` returns `null`, + `#advanceTxId` now stops advancing for that call instead of falling + through to `#activateTx(null)`. The pending entries stay queued for the + next broadcast or the backstop's `readToCurrent` pass. diff --git a/repro-345/harness/clear.html b/repro-345/harness/clear.html new file mode 100644 index 00000000..cc5910bd --- /dev/null +++ b/repro-345/harness/clear.html @@ -0,0 +1,20 @@ + + +clearing + diff --git a/repro-345/harness/deterministic.html b/repro-345/harness/deterministic.html new file mode 100644 index 00000000..0578e0bb --- /dev/null +++ b/repro-345/harness/deterministic.html @@ -0,0 +1,242 @@ + + +wa-sqlite deterministic repro — forced 3-generation gap +

+
diff --git a/repro-345/harness/malformed.html b/repro-345/harness/malformed.html
new file mode 100644
index 00000000..8addf414
--- /dev/null
+++ b/repro-345/harness/malformed.html
@@ -0,0 +1,187 @@
+
+
+wa-sqlite deterministic repro — database disk image is malformed
+

+
diff --git a/repro-345/harness/reader.worker.fixed.js b/repro-345/harness/reader.worker.fixed.js
new file mode 100644
index 00000000..81097fec
--- /dev/null
+++ b/repro-345/harness/reader.worker.fixed.js
@@ -0,0 +1,104 @@
+import * as SQLite from '../../src/sqlite-api.js';
+import { OPFSWriteAheadVFS } from '../vendor-fixed/OPFSWriteAheadVFS.js';
+
+const DB_NAME = 'repro.db';
+let sqlite3, db, vfs;
+
+function diag(record) {
+  postMessage({ kind: 'diag', record });
+}
+
+async function init(myTag) {
+  const { default: moduleFactory } = await import('../../dist/wa-sqlite-async.mjs');
+  const module = await moduleFactory();
+  sqlite3 = SQLite.Factory(module);
+
+  vfs = await OPFSWriteAheadVFS.create(DB_NAME, module, {});
+  vfs.tag = myTag || 'reader';
+  vfs.diagnosticLog = diag;
+  sqlite3.vfs_register(vfs, true);
+
+  db = await sqlite3.open_v2(DB_NAME);
+  postMessage({ kind: 'ready' });
+}
+
+async function writeOne(label) {
+  let error = null;
+  try {
+    await sqlite3.exec(db, "INSERT INTO t (id, payload) VALUES (99999999, NULL)");
+  } catch (e) {
+    error = { name: e.name, message: e.message, stack: e.stack };
+  }
+  postMessage({ kind: 'write', label, error });
+}
+
+async function count(label, table) {
+  const results = { rows: [] };
+  let error = null;
+  try {
+    await sqlite3.exec(db, `SELECT COUNT(*) FROM ${table ?? 't'}`, (row) => results.rows.push(row));
+  } catch (e) {
+    error = { name: e.name, message: e.message, stack: e.stack };
+  }
+  postMessage({ kind: 'count', label, value: results.rows[0]?.[0] ?? null, error });
+}
+
+// A synchronous busy-wait. This blocks the worker's OWN event loop for
+// `ms` milliseconds: no timers fire, no postMessage/BroadcastChannel
+// deliveries are processed, exactly like a frozen/backgrounded tab. It does
+// NOT terminate the worker or drop its already-held Web Locks.
+function freeze(ms) {
+  const until = performance.now() + ms;
+  while (performance.now() < until) {
+    // spin
+  }
+}
+
+let pollTimer = null;
+let pollN = 0;
+
+self.onmessage = async (event) => {
+  const { cmd, ms, label, ids, intervalMs, table } = event.data;
+  if (cmd === 'init') {
+    await init(event.data.tag);
+  } else if (cmd === 'count') {
+    await count(label, table);
+  } else if (cmd === 'write-one') {
+    await writeOne(label);
+  } else if (cmd === 'freeze') {
+    postMessage({ kind: 'freeze-start', ms });
+    freeze(ms);
+    postMessage({ kind: 'freeze-end', ms });
+  } else if (cmd === 'drop') {
+    // Simulate specific broadcasts genuinely never arriving (loss, not
+    // delay) while this connection stays fully responsive otherwise.
+    for (const id of ids) vfs.testDropTxIds.add(id);
+    postMessage({ kind: 'drop-armed', ids });
+  } else if (cmd === 'start-poll') {
+    // Poll repeatedly so we see exactly which query first observes trouble,
+    // without depending on the main page's own timing.
+    clearInterval(pollTimer);
+    pollN = 0;
+    pollTimer = setInterval(() => count(`poll-${pollN++}`, table), intervalMs || 200);
+  } else if (cmd === 'stop-poll') {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  } else if (cmd === 'pause-consumption') {
+    // Deterministic stand-in for "this connection's BroadcastChannel
+    // listener is not running right now" -- every 'tx' broadcast is
+    // ignored entirely while true, no busy-loop needed.
+    vfs.testPauseConsumption = true;
+    postMessage({ kind: 'paused' });
+  } else if (cmd === 'unpause-consumption') {
+    vfs.testPauseConsumption = false;
+    postMessage({ kind: 'unpaused' });
+  } else if (cmd === 'force-active-header-salt1') {
+    const file = vfs.mapPathToFile.get(DB_NAME);
+    file.writeAhead.testForceActiveHeaderSalt1(event.data.salt1);
+    postMessage({ kind: 'forced-active-header', salt1: event.data.salt1 });
+  } else if (cmd === 'inject-pending-tx') {
+    const file = vfs.mapPathToFile.get(DB_NAME);
+    file.writeAhead.testInjectPendingTx(event.data.id, event.data.waSalt1);
+    postMessage({ kind: 'injected-pending-tx', id: event.data.id });
+  }
+};
diff --git a/repro-345/harness/reader.worker.js b/repro-345/harness/reader.worker.js
new file mode 100644
index 00000000..4e1e0fd2
--- /dev/null
+++ b/repro-345/harness/reader.worker.js
@@ -0,0 +1,104 @@
+import * as SQLite from '../../src/sqlite-api.js';
+import { OPFSWriteAheadVFS } from '../vendor/OPFSWriteAheadVFS.js';
+
+const DB_NAME = 'repro.db';
+let sqlite3, db, vfs;
+
+function diag(record) {
+  postMessage({ kind: 'diag', record });
+}
+
+async function init(myTag) {
+  const { default: moduleFactory } = await import('../../dist/wa-sqlite-async.mjs');
+  const module = await moduleFactory();
+  sqlite3 = SQLite.Factory(module);
+
+  vfs = await OPFSWriteAheadVFS.create(DB_NAME, module, {});
+  vfs.tag = myTag || 'reader';
+  vfs.diagnosticLog = diag;
+  sqlite3.vfs_register(vfs, true);
+
+  db = await sqlite3.open_v2(DB_NAME);
+  postMessage({ kind: 'ready' });
+}
+
+async function writeOne(label) {
+  let error = null;
+  try {
+    await sqlite3.exec(db, "INSERT INTO t (id, payload) VALUES (99999999, NULL)");
+  } catch (e) {
+    error = { name: e.name, message: e.message, stack: e.stack };
+  }
+  postMessage({ kind: 'write', label, error });
+}
+
+async function count(label, table) {
+  const results = { rows: [] };
+  let error = null;
+  try {
+    await sqlite3.exec(db, `SELECT COUNT(*) FROM ${table ?? 't'}`, (row) => results.rows.push(row));
+  } catch (e) {
+    error = { name: e.name, message: e.message, stack: e.stack };
+  }
+  postMessage({ kind: 'count', label, value: results.rows[0]?.[0] ?? null, error });
+}
+
+// A synchronous busy-wait. This blocks the worker's OWN event loop for
+// `ms` milliseconds: no timers fire, no postMessage/BroadcastChannel
+// deliveries are processed, exactly like a frozen/backgrounded tab. It does
+// NOT terminate the worker or drop its already-held Web Locks.
+function freeze(ms) {
+  const until = performance.now() + ms;
+  while (performance.now() < until) {
+    // spin
+  }
+}
+
+let pollTimer = null;
+let pollN = 0;
+
+self.onmessage = async (event) => {
+  const { cmd, ms, label, ids, intervalMs, table } = event.data;
+  if (cmd === 'init') {
+    await init(event.data.tag);
+  } else if (cmd === 'count') {
+    await count(label, table);
+  } else if (cmd === 'write-one') {
+    await writeOne(label);
+  } else if (cmd === 'freeze') {
+    postMessage({ kind: 'freeze-start', ms });
+    freeze(ms);
+    postMessage({ kind: 'freeze-end', ms });
+  } else if (cmd === 'drop') {
+    // Simulate specific broadcasts genuinely never arriving (loss, not
+    // delay) while this connection stays fully responsive otherwise.
+    for (const id of ids) vfs.testDropTxIds.add(id);
+    postMessage({ kind: 'drop-armed', ids });
+  } else if (cmd === 'start-poll') {
+    // Poll repeatedly so we see exactly which query first observes trouble,
+    // without depending on the main page's own timing.
+    clearInterval(pollTimer);
+    pollN = 0;
+    pollTimer = setInterval(() => count(`poll-${pollN++}`, table), intervalMs || 200);
+  } else if (cmd === 'stop-poll') {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  } else if (cmd === 'pause-consumption') {
+    // Deterministic stand-in for "this connection's BroadcastChannel
+    // listener is not running right now" -- every 'tx' broadcast is
+    // ignored entirely while true, no busy-loop needed.
+    vfs.testPauseConsumption = true;
+    postMessage({ kind: 'paused' });
+  } else if (cmd === 'unpause-consumption') {
+    vfs.testPauseConsumption = false;
+    postMessage({ kind: 'unpaused' });
+  } else if (cmd === 'force-active-header-salt1') {
+    const file = vfs.mapPathToFile.get(DB_NAME);
+    file.writeAhead.testForceActiveHeaderSalt1(event.data.salt1);
+    postMessage({ kind: 'forced-active-header', salt1: event.data.salt1 });
+  } else if (cmd === 'inject-pending-tx') {
+    const file = vfs.mapPathToFile.get(DB_NAME);
+    file.writeAhead.testInjectPendingTx(event.data.id, event.data.waSalt1);
+    postMessage({ kind: 'injected-pending-tx', id: event.data.id });
+  }
+};
diff --git a/repro-345/harness/verify-fix.html b/repro-345/harness/verify-fix.html
new file mode 100644
index 00000000..61a42560
--- /dev/null
+++ b/repro-345/harness/verify-fix.html
@@ -0,0 +1,136 @@
+
+
+wa-sqlite fix verification — real broadcasts only, no synthetic data
+

+
diff --git a/repro-345/harness/writer.worker.fixed.js b/repro-345/harness/writer.worker.fixed.js
new file mode 100644
index 00000000..fc5b46b4
--- /dev/null
+++ b/repro-345/harness/writer.worker.fixed.js
@@ -0,0 +1,112 @@
+import * as SQLite from '../../src/sqlite-api.js';
+import { OPFSWriteAheadVFS } from '../vendor-fixed/OPFSWriteAheadVFS.js';
+
+const DB_NAME = 'repro.db';
+let sqlite3, db, running = false, tag = 'writer';
+
+function diag(record) {
+  postMessage({ kind: 'diag', record });
+}
+
+async function init(myTag) {
+  tag = myTag || 'writer';
+  const { default: moduleFactory } = await import('../../dist/wa-sqlite-async.mjs');
+  const module = await moduleFactory();
+  sqlite3 = SQLite.Factory(module);
+
+  const vfs = await OPFSWriteAheadVFS.create(DB_NAME, module, {});
+  vfs.tag = tag;
+  vfs.diagnosticLog = diag;
+  sqlite3.vfs_register(vfs, true);
+
+  db = await sqlite3.open_v2(DB_NAME);
+  // No journal_mode pragma: OPFSWriteAheadVFS's own user-space WAL is always
+  // active for any file it opens (there is no case for journal_mode in its
+  // pragma switch, and it throws on SQLITE_OPEN_WAL entirely).
+  await sqlite3.exec(db, 'CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, writerTag TEXT, payload BLOB)');
+  postMessage({ kind: 'ready' });
+}
+
+async function insertLoop({ count, blobSize, delayMs } = {}) {
+  running = true;
+  // No explicit id: with multiple concurrent writer connections, SQLite's
+  // own rowid autoincrement is what has to serialize correctly across them
+  // -- assigning our own ids would just mask a real collision as a
+  // different-looking failure.
+  let n = 0;
+  const start = performance.now();
+  const target = count ?? Infinity;
+  try {
+    while (running && n < target) {
+      n++;
+      const sql = blobSize
+        ? `INSERT INTO t (writerTag, payload) VALUES ('${tag}', zeroblob(${blobSize}))`
+        : `INSERT INTO t (writerTag, payload) VALUES ('${tag}', NULL)`;
+      await sqlite3.exec(db, sql);
+      if (n % 100 === 0) postMessage({ kind: 'progress', n, ms: Math.round(performance.now() - start) });
+      if (delayMs) await new Promise(r => setTimeout(r, delayMs));
+    }
+  } catch (e) {
+    postMessage({ kind: 'error', n, error: { name: e.name, message: e.message, stack: e.stack } });
+  } finally {
+    running = false;
+    postMessage({ kind: 'stopped', n });
+  }
+}
+
+// Matches the community repro's churn pattern exactly (bulk insert via a
+// recursive CTE, then trim), which is what actually drives fast, sustained
+// WAL swaps -- a one-row-per-await loop never got close to their observed
+// ~1.5s swap cadence.
+const CHURN_TABLE_SQL = 'CREATE TABLE IF NOT EXISTS churn (id INTEGER PRIMARY KEY, data TEXT)';
+const CHURN_INSERT_SQL = `
+  WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 150)
+  INSERT INTO churn (data) SELECT hex(randomblob(256)) FROM seq`;
+const CHURN_TRIM_SQL = 'DELETE FROM churn WHERE id IN (SELECT id FROM churn LIMIT 100)';
+
+async function churnLoop(tickMs) {
+  running = true;
+  await sqlite3.exec(db, CHURN_TABLE_SQL);
+  postMessage({ kind: 'churn-ready' });
+  while (running) {
+    try {
+      await sqlite3.exec(db, CHURN_INSERT_SQL);
+      await sqlite3.exec(db, CHURN_TRIM_SQL);
+    } catch (e) {
+      postMessage({ kind: 'error', error: { name: e.name, message: e.message, stack: e.stack } });
+      running = false;
+      break;
+    }
+    await new Promise(r => setTimeout(r, tickMs));
+  }
+}
+
+self.onmessage = async (event) => {
+  const { cmd } = event.data;
+  if (cmd === 'init') {
+    await init(event.data.tag);
+  } else if (cmd === 'start') {
+    insertLoop(event.data.opts || {});
+  } else if (cmd === 'churn') {
+    churnLoop(event.data.tickMs ?? 400);
+  } else if (cmd === 'stop') {
+    running = false;
+  } else if (cmd === 'count') {
+    const results = { rows: [] };
+    let error = null;
+    try {
+      await sqlite3.exec(db, 'SELECT COUNT(*) FROM t', (row) => results.rows.push(row));
+    } catch (e) {
+      error = { name: e.name, message: e.message };
+    }
+    postMessage({ kind: 'count', value: results.rows[0]?.[0], error });
+  } else if (cmd === 'raw-sql') {
+    let error = null;
+    try {
+      await sqlite3.exec(db, event.data.sql);
+    } catch (e) {
+      error = { name: e.name, message: e.message };
+    }
+    postMessage({ kind: 'raw-sql-done', sql: event.data.sql, error });
+  }
+};
diff --git a/repro-345/harness/writer.worker.js b/repro-345/harness/writer.worker.js
new file mode 100644
index 00000000..62e26e0a
--- /dev/null
+++ b/repro-345/harness/writer.worker.js
@@ -0,0 +1,112 @@
+import * as SQLite from '../../src/sqlite-api.js';
+import { OPFSWriteAheadVFS } from '../vendor/OPFSWriteAheadVFS.js';
+
+const DB_NAME = 'repro.db';
+let sqlite3, db, running = false, tag = 'writer';
+
+function diag(record) {
+  postMessage({ kind: 'diag', record });
+}
+
+async function init(myTag) {
+  tag = myTag || 'writer';
+  const { default: moduleFactory } = await import('../../dist/wa-sqlite-async.mjs');
+  const module = await moduleFactory();
+  sqlite3 = SQLite.Factory(module);
+
+  const vfs = await OPFSWriteAheadVFS.create(DB_NAME, module, {});
+  vfs.tag = tag;
+  vfs.diagnosticLog = diag;
+  sqlite3.vfs_register(vfs, true);
+
+  db = await sqlite3.open_v2(DB_NAME);
+  // No journal_mode pragma: OPFSWriteAheadVFS's own user-space WAL is always
+  // active for any file it opens (there is no case for journal_mode in its
+  // pragma switch, and it throws on SQLITE_OPEN_WAL entirely).
+  await sqlite3.exec(db, 'CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, writerTag TEXT, payload BLOB)');
+  postMessage({ kind: 'ready' });
+}
+
+async function insertLoop({ count, blobSize, delayMs } = {}) {
+  running = true;
+  // No explicit id: with multiple concurrent writer connections, SQLite's
+  // own rowid autoincrement is what has to serialize correctly across them
+  // -- assigning our own ids would just mask a real collision as a
+  // different-looking failure.
+  let n = 0;
+  const start = performance.now();
+  const target = count ?? Infinity;
+  try {
+    while (running && n < target) {
+      n++;
+      const sql = blobSize
+        ? `INSERT INTO t (writerTag, payload) VALUES ('${tag}', zeroblob(${blobSize}))`
+        : `INSERT INTO t (writerTag, payload) VALUES ('${tag}', NULL)`;
+      await sqlite3.exec(db, sql);
+      if (n % 100 === 0) postMessage({ kind: 'progress', n, ms: Math.round(performance.now() - start) });
+      if (delayMs) await new Promise(r => setTimeout(r, delayMs));
+    }
+  } catch (e) {
+    postMessage({ kind: 'error', n, error: { name: e.name, message: e.message, stack: e.stack } });
+  } finally {
+    running = false;
+    postMessage({ kind: 'stopped', n });
+  }
+}
+
+// Matches the community repro's churn pattern exactly (bulk insert via a
+// recursive CTE, then trim), which is what actually drives fast, sustained
+// WAL swaps -- a one-row-per-await loop never got close to their observed
+// ~1.5s swap cadence.
+const CHURN_TABLE_SQL = 'CREATE TABLE IF NOT EXISTS churn (id INTEGER PRIMARY KEY, data TEXT)';
+const CHURN_INSERT_SQL = `
+  WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 150)
+  INSERT INTO churn (data) SELECT hex(randomblob(256)) FROM seq`;
+const CHURN_TRIM_SQL = 'DELETE FROM churn WHERE id IN (SELECT id FROM churn LIMIT 100)';
+
+async function churnLoop(tickMs) {
+  running = true;
+  await sqlite3.exec(db, CHURN_TABLE_SQL);
+  postMessage({ kind: 'churn-ready' });
+  while (running) {
+    try {
+      await sqlite3.exec(db, CHURN_INSERT_SQL);
+      await sqlite3.exec(db, CHURN_TRIM_SQL);
+    } catch (e) {
+      postMessage({ kind: 'error', error: { name: e.name, message: e.message, stack: e.stack } });
+      running = false;
+      break;
+    }
+    await new Promise(r => setTimeout(r, tickMs));
+  }
+}
+
+self.onmessage = async (event) => {
+  const { cmd } = event.data;
+  if (cmd === 'init') {
+    await init(event.data.tag);
+  } else if (cmd === 'start') {
+    insertLoop(event.data.opts || {});
+  } else if (cmd === 'churn') {
+    churnLoop(event.data.tickMs ?? 400);
+  } else if (cmd === 'stop') {
+    running = false;
+  } else if (cmd === 'count') {
+    const results = { rows: [] };
+    let error = null;
+    try {
+      await sqlite3.exec(db, 'SELECT COUNT(*) FROM t', (row) => results.rows.push(row));
+    } catch (e) {
+      error = { name: e.name, message: e.message };
+    }
+    postMessage({ kind: 'count', value: results.rows[0]?.[0], error });
+  } else if (cmd === 'raw-sql') {
+    let error = null;
+    try {
+      await sqlite3.exec(db, event.data.sql);
+    } catch (e) {
+      error = { name: e.name, message: e.message };
+    }
+    postMessage({ kind: 'raw-sql-done', sql: event.data.sql, error });
+  }
+};
diff --git a/repro-345/vendor-fixed/LazyLock.js b/repro-345/vendor-fixed/LazyLock.js
new file mode 100644
index 00000000..6ba65588
--- /dev/null
+++ b/repro-345/vendor-fixed/LazyLock.js
@@ -0,0 +1,90 @@
+import { Lock } from './Lock.js';
+
+export class LazyLock extends Lock {
+  #channel;
+  #isBusy = false;
+  #hasReleaseRequest = false;
+
+  /**
+   * @param {string} name 
+   */
+  constructor(name) {
+    super(name);
+    this.#channel = new BroadcastChannel(name);
+    this.#channel.onmessage = (event) => {
+      if (this.#isBusy) {
+        // We're using the lock so postpone the release.
+        this.#hasReleaseRequest = true;
+      } else {
+        this.release();
+      }
+    }
+  }
+
+  close() {
+    super.close();
+    this.#channel.onmessage = null;
+    this.#channel.close();
+  }
+
+  /**
+   * @param {LockMode} mode 
+   * @param {number} timeout 
+   * @returns {Promise}
+   */
+  async acquire(mode, timeout = -1) {
+    this.#isBusy = true;
+    try {
+      if (mode === this.mode) {
+        // We never had to release the lock.
+        return true;
+      }
+
+      if (this.mode) {
+        // Release the lock to acquire it in a different mode.
+        super.release();
+      } else {
+        // Poll for the lock. This isn't necessary but if it works it avoids
+        // the BroadcastChannel traffic.
+        if (await super.acquire(mode, 0)) {
+          return true;
+        }
+      }
+
+      // Request the lock.
+      const pResult = super.acquire(mode, timeout)
+      this.#channel.postMessage({});
+
+      return await pResult;
+    } catch (e) {
+      this.release();
+      throw e;
+    }
+  }
+
+  /**
+   * @param {LockMode} mode 
+   * @returns {boolean}
+   */
+  acquireIfHeld(mode) {
+    if (mode === this.mode) {
+      this.#isBusy = true;
+      return true;
+    }
+    return false;
+  }
+
+  release() {
+    super.release();
+    this.#isBusy = false;
+    this.#hasReleaseRequest = false;
+  }
+
+  releaseLazy() {
+    // Release the lock only if someone else wants it.
+    this.#isBusy = false;
+    if (this.#hasReleaseRequest) {
+      this.release();
+    }
+  }
+}
\ No newline at end of file
diff --git a/repro-345/vendor-fixed/Lock.js b/repro-345/vendor-fixed/Lock.js
new file mode 100644
index 00000000..6199f374
--- /dev/null
+++ b/repro-345/vendor-fixed/Lock.js
@@ -0,0 +1,69 @@
+// This is a convenience wrapper for the Web Locks API.
+export class Lock {
+  #name;
+  /** @type {LockMode?} */ #mode = null;
+  /** @type {Promise} */ #releaser = Promise.resolve(null);
+  #isAcquiring = false;
+
+  /**
+   * @param {string} name 
+   */
+  constructor(name) {
+    this.#name = name;
+  }
+
+  get name() { return this.#name; }
+  get mode() { return this.#mode; }
+
+  close() {
+    this.release();
+  }
+  
+  /**
+   * @param {'shared'|'exclusive'} mode 
+   * @param {number} timeout -1 for infinite, 0 for poll, >0 for milliseconds
+   * @return {Promise} true if lock acquired, false on failed poll
+   */
+  async acquire(mode, timeout = -1) {
+    if (this.#isAcquiring) throw new Error('Lock is already being acquired');
+    this.#isAcquiring = true;
+    try {
+      if (this.#mode) {
+        throw new Error(`Lock ${this.#name} is already acquired`);
+      }
+
+      this.#releaser = new Promise((resolve, reject) => {
+        /** @type {LockOptions} */
+        const options = { mode, ifAvailable: timeout === 0 };
+        if (timeout > 0) {
+          options.signal = AbortSignal.timeout(timeout);
+        }
+
+        navigator.locks.request(this.#name, options, lock => {
+          if (lock === null) {
+            // Polling (with timeout = 0) did not acquire the lock.
+            return resolve(null);
+          }
+
+          // Lock acquired. The lock is released when this returned
+          // Promise is resolved.
+          this.#mode = mode;
+          return new Promise(releaser => {
+            resolve(releaser);
+          })
+        }).catch(e => {
+          return reject(e);
+        });
+      });
+
+      return this.#releaser.then(releaser => !!releaser)
+    } finally {
+      this.#isAcquiring = false;
+    }
+  }
+
+  release() {
+    this.#releaser.then(releaser => releaser?.(), () => {});
+    this.#mode = null;
+  }
+}
diff --git a/repro-345/vendor-fixed/OPFSWriteAheadVFS.js b/repro-345/vendor-fixed/OPFSWriteAheadVFS.js
new file mode 100644
index 00000000..80fc2a58
--- /dev/null
+++ b/repro-345/vendor-fixed/OPFSWriteAheadVFS.js
@@ -0,0 +1,973 @@
+import { FacadeVFS } from "../../src/FacadeVFS.js";
+import * as VFS from '../../src/VFS.js';
+import { LazyLock } from "./LazyLock.js";
+import { WriteAhead } from "./WriteAhead.js";
+
+const LIBRARY_FILES_ROOT = '.wa-sqlite';
+const DEFAULT_TEMP_FILES = 6;
+
+const finalizationRegistry = new FinalizationRegistry((/** @type {() => void} */ f) => f());
+
+/**
+ * @typedef FileEntry
+ * @property {string} zName
+ * @property {number} flags
+ * @property {FileSystemSyncAccessHandle} [accessHandle]
+
+ * Main database file properties:
+ * @property {*} [retryResult]
+ * @property {FileSystemSyncAccessHandle[]} [waHandles]
+ * 
+ * @property {'reserved'|'exclusive'|null} [writeHint]
+ * @property {'normal'|'exclusive'} [lockingMode]
+ * @property {number} [lockState] SQLITE_LOCK_*
+ * @property {LazyLock} [readLock]
+ * @property {LazyLock} [writeLock]
+ * @property {'none'|'read'|'write'|'readwrite'} [useLazyLock]
+ * @property {number} [timeout]
+ * @property {0|1|2|3} [synchronous]
+ * @property {number?} [pageSize]
+ * @property {boolean} [overwrite]
+ * 
+ * @property {WriteAhead} [writeAhead]
+ */
+
+/**
+ * @typedef OPFSWriteAheadOptions
+ * @property {number} [nTmpFiles]
+ * @property {number} [autoCheckpoint]
+ * @property {number} [backstopInterval]
+ */
+
+export class OPFSWriteAheadVFS extends FacadeVFS {
+  lastError = null;
+  log = null;
+  diagnosticLog = null;
+  tag = '?';
+  testDropTxIds = new Set();
+  testPauseConsumption = false;
+  
+  /** @type {Map} */ mapIdToFile = new Map();
+  /** @type {Map} */ mapPathToFile = new Map();
+
+  /** @type {Map} */ boundTempFiles = new Map();
+  /** @type {Set} */ unboundTempFiles = new Set();
+  /** @type {OPFSWriteAheadOptions} */ options = {
+    nTmpFiles: DEFAULT_TEMP_FILES
+  };
+
+  _ready;
+
+  static async create(name, module, options) {
+    const vfs = new OPFSWriteAheadVFS(name, module);
+    Object.assign(vfs.options, options);
+    await vfs.isReady();
+    return vfs;
+  }
+
+  constructor(name, module) {
+    super(name, module);
+    this._ready = (async () => {
+      // Ensure the library files root directory exists.
+      let dirHandle = await navigator.storage.getDirectory();
+      dirHandle = await dirHandle.getDirectoryHandle(LIBRARY_FILES_ROOT, { create: true });
+
+      // Clean up any stale session directories.
+      // @ts-ignore
+      for await (const name of dirHandle.keys()) {
+        if (name.startsWith('.session-')) {
+          // Acquire a lock on the session directory to ensure it is not in use.
+          await navigator.locks.request(name, { ifAvailable: true }, async lock => {
+            if (lock) {
+              // This directory is not in use.
+              try {
+                await dirHandle.removeEntry(name, { recursive: true });
+              } catch (e) {
+                // Ignore errors, will try again next time.
+              }
+            }
+          });
+        }
+      }
+
+      // Create our session directory.
+      const dirName = `.session-${Math.random().toString(16).slice(2)}`;
+      await new Promise(resolve => {
+        navigator.locks.request(dirName, () => {
+          // @ts-ignore
+          resolve();
+          return new Promise(release => {
+            // @ts-ignore
+            finalizationRegistry.register(this, release);
+          });
+        });
+      });
+      dirHandle = await dirHandle.getDirectoryHandle(dirName, { create: true });
+
+      // Create temporary files.
+      for (let i = 0; i < this.options.nTmpFiles; i++) {
+        const fileHandle= await dirHandle.getFileHandle(i.toString(), { create: true });
+        const accessHandle = await fileHandle.createSyncAccessHandle();
+        finalizationRegistry.register(this, () => accessHandle.close());
+        this.unboundTempFiles.add(accessHandle);
+      }
+    })();
+  }
+
+  isReady() {
+    return Promise.all([super.isReady(), this._ready]).then(() => true);
+  }
+
+ /**
+   * @param {string?} zName 
+   * @param {number} fileId 
+   * @param {number} flags 
+   * @param {DataView} pOutFlags 
+   * @returns {number}
+   */
+  jOpen(zName, fileId, flags, pOutFlags) {
+    try {
+      if (zName === null) {
+        // Generate a temporary filename. This will only be used as a
+        // key to map to a pre-opened temporary file access handle.
+        zName = Math.random().toString(16).slice(2);
+      }
+
+      const file = this.mapPathToFile.get(zName) ?? {
+        zName,
+        flags,
+        retryResult: null,
+      };
+      this.mapPathToFile.set(zName, file);
+
+      if (flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        // Open database and journal files with a retry operation.
+        if (file.retryResult === null) {
+          // This is the initial open attempt. Start the asynchronous task
+          // and return SQLITE_BUSY to force a retry.
+          this._module.retryOps.push(this.#retryOpen(zName, flags, fileId, pOutFlags));
+          return VFS.SQLITE_BUSY;
+        } else if (file.retryResult instanceof Error) {
+          const e = file.retryResult;
+          file.retryResult = null;
+          throw e;
+        }
+
+        // Initialize database file state.
+        file.accessHandle = file.retryResult.accessHandle;
+        file.waHandles = file.retryResult.waHandles;
+        file.writeAhead = file.retryResult.writeAhead;
+        file.retryResult = null;
+
+        file.lockState = VFS.SQLITE_LOCK_NONE;
+        file.lockingMode = 'normal';
+        file.readLock = new LazyLock(`${zName}#read`);
+        file.writeLock = new LazyLock(`${zName}#write`);
+        file.useLazyLock = 'readwrite';
+        file.timeout = -1;
+        file.synchronous = 1; // NORMAL
+        file.writeHint = null;
+        file.pageSize = null;
+        file.overwrite = false;
+      } else if (flags & (VFS.SQLITE_OPEN_WAL | VFS.SQLITE_OPEN_SUPER_JOURNAL)) {
+        throw new Error('WAL and super-journal files are not supported');
+      } else if (file.accessHandle) {
+        // This temporary file already has an access handle, which happens
+        // only for tests. Just use it as is.
+      } else {
+        // This is a temporary file. Use an unbound pre-opened accessHandle.
+        if (!(flags & VFS.SQLITE_OPEN_CREATE)) throw new Error('file not found');
+        file.accessHandle = this.#openTemporaryFile(zName);
+      }
+
+      this.mapIdToFile.set(fileId, file);
+      pOutFlags.setInt32(0, flags, true);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      this.mapPathToFile.delete(zName);
+      return VFS.SQLITE_CANTOPEN;
+    }
+  }
+
+  /**
+   * @param {string} zName 
+   * @param {number} syncDir 
+   * @returns {number}
+   */
+  jDelete(zName, syncDir) {
+    try {
+      if (this.boundTempFiles.has(zName)) {
+        const file = this.mapPathToFile.get(zName);
+        this.#deleteTemporaryFile(file);
+      } else {
+        throw new Error(`unexpected file deletion: ${zName}`);
+      }
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_DELETE;
+    }
+  }
+
+  /**
+   * @param {string} zName 
+   * @param {number} flags 
+   * @param {DataView} pResOut 
+   * @returns {number}
+   */
+  jAccess(zName, flags, pResOut) {
+    try {
+      const file = this.mapPathToFile.get(zName);
+      pResOut.setInt32(0, file ? 1 : 0, true);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_ACCESS;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @returns {number}
+   */
+  jClose(fileId) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file?.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        file.writeAhead.close();
+        file.accessHandle.close();
+        file.waHandles.forEach(handle => handle.close());
+        this.mapPathToFile.delete(file?.zName);
+
+        file.readLock.close();
+        file.writeLock.close();
+      } else if (file?.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
+        this.#deleteTemporaryFile(file);
+      }
+
+      // Disassociate fileId from file entry.
+      this.mapIdToFile.delete(fileId);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_CLOSE;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {Uint8Array} pData 
+   * @param {number} iOffset
+   * @returns {number}
+   */
+  jRead(fileId, pData, iOffset) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+
+      let bytesRead = null;
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        // Try reading from the write-ahead overlays first. A read on the
+        // database file is always a complete page, except when reading
+        // from the 100-byte header.
+        const pageOffset = iOffset < 100 ? iOffset : 0;
+        const page = file.writeAhead.read(iOffset - pageOffset);
+        if (page) {
+          const readData = page.subarray(pageOffset, pageOffset + pData.byteLength);
+          pData.set(readData);
+          bytesRead = readData.byteLength;
+        }
+      }
+
+      if (bytesRead === null) {
+        // Read directly from the OPFS file.
+
+        // On Chrome (at least), passing pData to accessHandle.read() is
+        // an error because pData is a Proxy of a Uint8Array. Calling
+        // subarray() produces a real Uint8Array and that works.
+        bytesRead = file.accessHandle.read(pData.subarray(), { at: iOffset });
+      }
+
+      if (bytesRead < pData.byteLength) {
+        pData.fill(0, bytesRead);
+        return VFS.SQLITE_IOERR_SHORT_READ;
+      }
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_READ;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {Uint8Array} pData 
+   * @param {number} iOffset
+   * @returns {number}
+   */
+  jWrite(fileId, pData, iOffset) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        // Write to the write-ahead overlay.
+        const isPageResize = file.overwrite && file.pageSize !== pData.byteLength;
+        file.writeAhead.write(iOffset, pData, {
+          dstPageSize: isPageResize ? file.pageSize : null
+        });
+        return VFS.SQLITE_OK;
+      }
+
+      // On Chrome (at least), passing pData to accessHandle.write() is
+      // an error because pData is a Proxy of a Uint8Array. Calling
+      // subarray() produces a real Uint8Array and that works.
+      file.accessHandle.write(pData.subarray(), { at: iOffset });
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_WRITE;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {number} iSize 
+   * @returns {number}
+   */
+  jTruncate(fileId, iSize) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        file.writeAhead.truncate(iSize);
+        return VFS.SQLITE_OK;
+      }
+      file.accessHandle.truncate(iSize);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_TRUNCATE;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {number} flags 
+   * @returns {number}
+   */
+  jSync(fileId, flags) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        const durability = file.synchronous > 1 ? 'strict' : 'relaxed';
+        file.writeAhead.sync({ durability });
+      } else {
+        // This is a temporary file so sync is not needed.
+        // Temporary journals are only used for rollback by the
+        // connection that created them, not for recovery.
+      }
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_FSYNC;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {DataView} pSize64 
+   * @returns {number}
+   */
+  jFileSize(fileId, pSize64) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+
+      let size;
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        size = file.writeAhead.getFileSize() || file.accessHandle.getSize();
+      } else {
+        size = file.accessHandle.getSize();
+      }
+      pSize64.setBigInt64(0, BigInt(size), true);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_FSTAT;
+    }
+  }
+
+  /**
+   * @param {number} pFile 
+   * @param {number} lockType 
+   * @returns {number|Promise}
+   */
+  jLock(pFile, lockType) {
+    try {
+      const file = this.mapIdToFile.get(pFile);
+      if (file.lockState === VFS.SQLITE_LOCK_NONE && lockType === VFS.SQLITE_LOCK_SHARED) {
+        // We do all our locking work in this transition.
+        if (file.retryResult === null) {
+          if (file.lockingMode === 'exclusive') {
+            // Exclusive locking mode is treated as a write, and the
+            // read lock is also acquired to block readers.
+            file.retryResult = {};
+            this._module.retryOps.push(this.#retryLockWrite(file));
+            return VFS.SQLITE_BUSY;
+          }
+
+          // With WAL, read and write transactions use separate locks. In
+          // each case if the required lock is already held then we can
+          // proceed synchronously. Otherwise we need to acquire state
+          // asynchronously and retry.
+          if (file.writeHint) {
+            // Write transaction.
+            if (!file.writeLock.acquireIfHeld('exclusive')) {
+              file.retryResult = {};
+              this._module.retryOps.push(this.#retryLockWrite(file));
+              return VFS.SQLITE_BUSY;
+            } else {
+              file.writeAhead.isolateForWrite();
+            }
+          } else {
+            // Read transaction.
+            if (!file.readLock.acquireIfHeld('shared')) {
+              file.retryResult = {};
+              this._module.retryOps.push(this.#retryLockRead(file));
+              return VFS.SQLITE_BUSY;
+            } else {
+              file.writeAhead.isolateForRead();
+            }
+          }
+        } else if (file.retryResult instanceof Error) {
+          const e = file.retryResult;
+          file.retryResult = null;
+          throw e;
+        }
+
+        // We have acquired the needed locks, either synchronously or
+        // via retry.
+        file.retryResult = null;
+      } else if (lockType >= VFS.SQLITE_LOCK_RESERVED && !file.writeLock.mode) {
+        // This is a write transaction but we don't already have the write
+        // lock. This happens when the write hint was not used, which this
+        // VFS treats as an error.
+        throw new Error('Write transaction cannot use BEGIN DEFERRED');
+      }
+      file.lockState = lockType;
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      if (e.name === 'TimeoutError') {
+        return VFS.SQLITE_BUSY;
+      }
+
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_LOCK;
+    }
+  }
+
+  /**
+   * @param {number} pFile 
+   * @param {number} lockType 
+   * @returns {number}
+   */
+  jUnlock(pFile, lockType) {
+    try {
+      const file = this.mapIdToFile.get(pFile);
+
+      // If retryResult is non-null, an asynchronous lock operation is in
+      // progress. In that case, don't change any locks.
+      if (!file.retryResult && lockType === VFS.SQLITE_LOCK_NONE) {
+        // In this VFS, this is the only unlock transition that matters.
+        // Exit write-ahead isolation.
+        file.writeAhead.rejoin();
+
+        // Release any locks.
+        switch (file.useLazyLock) {
+          case 'none':
+            file.writeLock.release();
+            file.readLock.release();
+            break;
+          case 'read':
+            file.writeLock.release();
+            file.readLock.releaseLazy();
+            break;
+          case 'write':
+            file.writeLock.releaseLazy();
+            file.readLock.release();
+            break;
+          case 'readwrite':
+            file.writeLock.releaseLazy();
+            file.readLock.releaseLazy();
+            break;
+        }
+
+        // Reset state for the next transaction.
+        file.writeHint = null;
+      }
+      file.lockState = lockType;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_UNLOCK;
+    }
+  }
+
+  /**
+   * @param {number} pFile 
+   * @param {DataView} pResOut 
+   * @returns {number}
+   */
+  jCheckReservedLock(pFile, pResOut) {
+    // A hot journal cannot exist so this method should never be called.
+    console.assert(false, 'unexpected');
+    pResOut.setInt32(0, 0, true);
+    return VFS.SQLITE_OK;
+  }
+
+  /**
+   * @param {number} pFile
+   * @param {number} op
+   * @param {DataView} pArg
+   * @returns {number}
+   */
+  jFileControl(pFile, op, pArg) {
+    try {
+      const file = this.mapIdToFile.get(pFile);
+      switch (op) {
+        case VFS.SQLITE_FCNTL_PRAGMA:
+          const key = this._module.UTF8ToString(pArg.getUint32(4, true));
+          const valueAddress = pArg.getUint32(8, true);
+          const value = valueAddress ? this._module.UTF8ToString(valueAddress) : null;
+          this.log?.(`PRAGMA ${key} ${value}`);
+          switch (key.toLowerCase()) {
+            case 'experimental_pragma_20251114':
+              // After entering the SHARED locking state on the next
+              // transaction, SQLite intends to immediately transition to
+              // RESERVED if value is '1', or EXCLUSIVE if value is '2'.
+              switch (value) {
+                case '1':
+                  file.writeHint = 'reserved';
+                  break;
+                case '2':
+                  file.writeHint = 'exclusive';
+                  break;
+                default:
+                  throw new Error(`unexpected write hint value: ${value}`);
+              }
+              break;
+            case 'backstop_interval':
+              if (value !== null) {
+                const millis = parseInt(value);
+                file.writeAhead.setBackstopInterval(millis);
+              } else {
+                // Return current interval.
+                const s = file.writeAhead.options.backstopInterval.toString();
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+            case 'busy_timeout':
+              // Override SQLite's handling of busy timeouts with our
+              // blocking lock timeouts.
+              if (value !== null) {
+                file.timeout = parseInt(value);
+              } else {
+                // Return current timeout.
+                const s = file.timeout.toString();
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+            case 'journal_size_limit':
+              if (value !== null) {
+                const nPages = parseInt(value);
+                file.writeAhead.options.journalSizeLimit = nPages;
+              }
+              break;
+            case 'locking_mode':
+              // Track SQLite locking mode. Exclusive mode requires a
+              // write lock.
+              switch (value?.toLowerCase()) {
+                case 'normal':
+                  file.lockingMode = 'normal';
+                  break;
+                case 'exclusive':
+                  file.lockingMode = 'exclusive';
+                  break;
+              }
+              break;
+            case 'page_size':
+              if (value !== null) {
+                // Valid page sizes are 1 (which maps to 65536) or powers of
+                // two from 512 to 32768.
+                const n = parseInt(value);
+                if (n === 1 || (n >= 512 && n <= 32768 && (n & (n - 1)) === 0)) {
+                  file.pageSize = n === 1 ? 65536 : n;
+                }
+              }
+              break;
+            case 'synchronous':
+              // Track SQLite synchronous mode. Write-ahead transactions
+              // trade durability for performance on values 1 (NORMAL) or
+              // lower.
+              if (value !== null) {
+                switch (value.toLowerCase()) {
+                  case 'off':
+                  case '0':
+                    file.synchronous = 0;
+                    break;
+                  case 'normal':
+                  case '1':
+                    file.synchronous = 1;
+                    break;
+                  case 'full':
+                  case '2':
+                    file.synchronous = 2;
+                    break;
+                  case 'extra':
+                  case '3':
+                    file.synchronous = 3;
+                    break;
+                  default:
+                    throw new Error(`unexpected synchronous value: ${value}`);
+                }
+              }
+              break;
+            case 'vfs_trace':
+              // This is a trace feature for debugging only.
+              if (value !== null) {
+                this.log = parseInt(value) !== 0 ? console.debug : null;
+                file.writeAhead.log = this.log;
+              }
+              return VFS.SQLITE_OK;
+            case 'wal_autocheckpoint':
+              // A setting greater than zero enables automatic checkpoints
+              // with this connection (enabled by default).
+              if (value !== null) {
+                file.writeAhead.options.autoCheckpoint = parseInt(value);
+              }
+              break;
+            case 'wal_checkpoint':
+              const checkpointMode = (value ?? 'passive').toLowerCase();
+              switch (checkpointMode) {
+                case 'passive':
+                  this._module.pendingOps.push(this.#pendingCheckpoint(file, checkpointMode));
+                  break;
+                case 'full':
+                case 'restart':
+                case 'truncate':
+                  if (file.writeAhead.isTransactionPending()) {
+                    throw new Error('invalid while a transaction is in progress');
+                  }
+                  this._module.pendingOps.push(this.#pendingCheckpoint(file, checkpointMode));
+                  break;
+                case 'noop':
+                  break;
+                default:
+                  throw new Error(`unexpected wal_checkpoint mode: ${value}`);
+              }
+
+              // Return the approximate number of pages in the WAL before
+              // checkpointing. SQLite returns different information, but
+              // that is not feasible from a VFS.
+              {
+                const s = file.writeAhead.getWriteAheadSize().toString();
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+            case 'lazy_lock':
+              // Lazy locks don't actually release their Web Lock until
+              // they receive a message requesting it. Typically a setting
+              // of 'readwrite' (default) or 'read' is best.
+              if (value !== null) {
+                const useLazyLock = value.toLowerCase();
+                switch (useLazyLock) {
+                  case 'read':
+                  case 'write':
+                  case 'readwrite':
+                  case 'none':
+                    file.useLazyLock = useLazyLock;
+                    break;
+                  default:
+                    throw new Error(`unexpected value for lazy_lock: ${value}`);
+                }
+              }
+              {
+                const s = file.useLazyLock;
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+          }
+          break;
+
+        // Support SQLite batch atomic write transactions.
+        case VFS.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE:
+        case VFS.SQLITE_FCNTL_COMMIT_ATOMIC_WRITE:
+          if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+            return VFS.SQLITE_OK;
+          }
+          break;
+        case VFS.SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE:
+          if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+            file.writeAhead.rollback();
+            return VFS.SQLITE_OK;
+          }
+          break;
+
+        case VFS.SQLITE_FCNTL_SYNC:
+          if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+            file.writeAhead.commit();
+          }
+          break;
+
+        case VFS.SQLITE_FCNTL_OVERWRITE:
+          file.overwrite = true;
+          break;
+      }
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR;
+    }
+    return VFS.SQLITE_NOTFOUND;
+  }
+
+  /**
+   * @param {number} pFile
+   * @returns {number}
+   */
+  jDeviceCharacteristics(pFile) {
+    return VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN
+      | VFS.SQLITE_IOCAP_BATCH_ATOMIC;
+  }
+
+  /**
+   * @param {Uint8Array} zBuf 
+   * @returns {number}
+   */
+  jGetLastError(zBuf) {
+    if (this.lastError) {
+      console.error(this.lastError);
+      const outputArray = zBuf.subarray(0, zBuf.byteLength - 1);
+      const { written } = new TextEncoder().encodeInto(this.lastError.message, outputArray);
+      zBuf[written] = 0;
+    }
+    return VFS.SQLITE_OK
+  }
+
+  /**
+   * @param {string} zName 
+   * @returns {FileSystemSyncAccessHandle}
+   */
+  #openTemporaryFile(zName) {
+    if (this.unboundTempFiles.size === 0) {
+      throw new Error('no temporary files available');
+    }
+
+    // Bind an access handle from the temporary pool.
+    const accessHandle = this.unboundTempFiles.values().next().value;
+    this.unboundTempFiles.delete(accessHandle);
+    this.boundTempFiles.set(zName, accessHandle);
+    return accessHandle;
+  }
+
+  /**
+   * @param {FileEntry} file 
+   */
+  #deleteTemporaryFile(file) {
+    file.accessHandle.truncate(0);
+
+    // Temporary files are not actually deleted, just returned to the pool.
+    this.mapPathToFile.delete(file.zName);
+    this.unboundTempFiles.add(file.accessHandle);
+    this.boundTempFiles.delete(file.zName);
+  }
+
+  /**
+   * @param {string} dbName 
+   * @param {number} i 
+   * @returns {string}
+   */
+  #getWriteAheadNameFromDbName(dbName, i) {
+    // Our WAL file is not compatible with SQLite WAL, so use a distinct name.
+    return `${dbName}-wa${i}`;
+  }
+
+  /**
+   * Asynchronous PRAGMA operation to checkpoint the write-ahead log.
+   * @param {FileEntry} file 
+   * @param {'passive'|'full'|'restart'|'truncate'} mode 
+   */
+  async #pendingCheckpoint(file, mode) {
+    const onFinally = [];
+    try {
+      if (mode !== 'passive' && file.lockState === VFS.SQLITE_LOCK_NONE) {
+        await file.writeLock.acquire('exclusive');
+        onFinally.push(() => file.writeLock.release());
+
+        file.writeAhead.isolateForWrite();
+        onFinally.push(() => file.writeAhead.rejoin());
+      }
+      
+      await file.writeAhead.checkpoint({ isPassive: mode === 'passive' });
+    } catch (e) {
+      if (e.name === 'AbortError') {
+        e.code = VFS.SQLITE_BUSY;
+      }
+      throw e;
+    } finally {
+      while (onFinally.length) {
+        onFinally.pop()();
+      }
+    }
+  }
+
+  /**
+   * @param {FileEntry} file 
+   */
+  async #retryLockRead(file) {
+    const onError = [];
+    try {
+      await file.readLock.acquire('shared', file.timeout);
+      onError.push(() => file.readLock.release());
+
+      file.writeAhead.isolateForRead();
+      file.retryResult = {};
+    } catch (e) {
+      while (onError.length) {
+        onError.pop()();
+      }
+      file.retryResult = e;
+    }
+  }
+
+  /**
+   * @param {FileEntry} file 
+   */
+  async #retryLockWrite(file) {
+    const onError = [];
+    try {
+      // Exclusive locking mode requires both read and write locks.
+      // Otherwise, only the write lock is needed.
+      if (file.lockingMode === 'exclusive') {
+        await file.readLock.acquire('exclusive', file.timeout);
+        onError.push(() => file.readLock.release());
+      }
+
+      await file.writeLock.acquire('exclusive', file.timeout);
+      onError.push(() => file.writeLock.release());
+
+      file.writeAhead.isolateForWrite();
+      file.retryResult = {};
+    } catch (e) {
+      while (onError.length) {
+        onError.pop()();
+      }
+      file.retryResult = e;
+    }
+  }
+
+  /**
+   * Handle asynchronous jOpen() tasks.
+   * @param {string} zName 
+   * @param {number} flags 
+   * @param {number} fileId 
+   * @param {DataView} pOutFlags 
+   * @returns {Promise}
+   */
+  async #retryOpen(zName, flags, fileId, pOutFlags) {
+    /** @type {(() => void)[]} */ const onError = [];
+    const file = this.mapPathToFile.get(zName);
+    try {
+      const { accessHandle, waHandles } =
+        await navigator.locks.request(`${zName}#open`, async lock => {
+        // Parse the path components.
+        const directoryNames = zName.split('/').filter(d => d);
+        const dbName = directoryNames.pop();
+
+        // Get the OPFS directory handle.
+        let dirHandle = await navigator.storage.getDirectory();
+        const create = !!(flags & VFS.SQLITE_OPEN_CREATE);
+        for (const directoryName of directoryNames) {
+          dirHandle = await dirHandle.getDirectoryHandle(directoryName, { create });
+        }
+
+        const isNewDatabase = create && await (async function() {
+          try {
+            await dirHandle.getFileHandle(dbName);
+            return false;
+          } catch (e) {
+            if (e.name === 'NotFoundError') {
+              return true;
+            }
+            throw e;
+          }
+        })();
+
+        // Convenience function for opening access handles.
+        async function openFile(
+          /** @type {string} */ filename,
+          /** @type {FileSystemGetFileOptions} */ options) {
+          const fileHandle = await dirHandle.getFileHandle(filename, options);
+          // @ts-ignore
+          const accessHandle = await fileHandle.createSyncAccessHandle({
+            mode: 'readwrite-unsafe'
+          });
+          onError.push(() => {
+            accessHandle.close();
+            if (isNewDatabase) {
+              dirHandle.removeEntry(filename);
+            }
+          });
+          return accessHandle;
+        }
+
+        // Open the main database OPFS file.
+        const accessHandle = await openFile(dbName, { create });
+
+        // Open WAL files.
+        const waHandles = await Promise.all([0, 1].map(async i => {
+          const waName = this.#getWriteAheadNameFromDbName(dbName, i);
+          const waHandle = await openFile(waName, { create: true });
+          if (isNewDatabase) {
+            waHandle.truncate(0);
+          }
+          return waHandle;
+        }));
+        return { accessHandle, waHandles };
+      });
+
+      // Create the write-ahead manager.
+      const writeAhead = new WriteAhead(zName, accessHandle, waHandles);
+      writeAhead.diagnosticLog = this.diagnosticLog;
+      writeAhead.tag = this.tag;
+      writeAhead.testDropTxIds = this.testDropTxIds;
+      Object.defineProperty(writeAhead, 'testPauseConsumption', {
+        get: () => this.testPauseConsumption,
+        set: (v) => { this.testPauseConsumption = v; },
+      });
+      await writeAhead.ready();
+
+      file.retryResult = { accessHandle, waHandles, writeAhead };
+    } catch (e) {
+      while (onError.length) {
+        onError.pop()();
+      }
+      file.retryResult = e;
+    }
+  }
+}
diff --git a/repro-345/vendor-fixed/WriteAhead.js b/repro-345/vendor-fixed/WriteAhead.js
new file mode 100644
index 00000000..5ea33728
--- /dev/null
+++ b/repro-345/vendor-fixed/WriteAhead.js
@@ -0,0 +1,1321 @@
+import { Lock } from './Lock.js';
+
+const DEFAULT_JOURNAL_SIZE_LIMIT = 1000;
+const DEFAULT_BACKSTOP_INTERVAL = 30_000;
+
+const MAGIC = 0x377f0684;
+const FILE_HEADER_SIZE = 32;
+const FRAME_HEADER_SIZE = 32;
+const FRAME_TYPE_PAGE = 0;
+const FRAME_TYPE_COMMIT = 1;
+const FRAME_TYPE_END = 2;
+
+/**
+ * @typedef PageEntry
+ * @property {number} waOffset location in WAL file
+ * @property {number} waSalt1 WAL2 file identifier
+ * @property {number} pageSize
+ * @property {Uint8Array} [pageData]
+ */
+
+/**
+ * @typedef Transaction
+ * @property {number} id
+ * @property {Map} pages address to page data mapping
+ * @property {number} dbFileSize
+ * @property {number} [newPageSize]
+ * @property {number} waSalt1 WAL2 file identifier
+ * @property {number} waOffsetEnd
+ */
+
+/**
+ * @typedef WriteAheadOptions
+ * @property {number} [autoCheckpoint]
+ * @property {number} [backstopInterval]
+ * @property {number} [journalSizeLimit]
+ */
+
+export class WriteAhead {
+
+  log = null;
+  diagnosticLog = null;
+  tag = '?';
+  /** TEST ONLY: ids in this set are dropped as if the broadcast for them never
+   *  arrived, to reproduce genuine message loss (as opposed to delay). */
+  testDropTxIds = new Set();
+  /** TEST ONLY: while true, EVERY 'tx' broadcast is ignored entirely --
+   *  never added to #mapIdToPendingTx, #advanceTxId never called. Deterministic
+   *  stand-in for a connection whose BroadcastChannel listener is simply not
+   *  running (frozen tab, or a tab mid-reload) for a controlled span of real
+   *  swaps, without needing a busy-loop or guessing which specific ids to drop. */
+  testPauseConsumption = false;
+  /** TEST ONLY: force this connection's own view back to an arbitrary
+   *  salt1, simulating "this connection's view is N generations behind"
+   *  directly -- the state a real connection ends up in for whatever
+   *  real-world reason (missed a swap notification, reopened after a
+   *  gap), without needing to fight the checkpoint back-pressure that
+   *  blocks forcing multiple REAL swaps while another lock is stale. */
+  testForceActiveHeaderSalt1(salt1) { this.#activeHeader = { ...this.#activeHeader, salt1 }; }
+  /** TEST ONLY: directly populate #mapIdToPendingTx, bypassing
+   *  #handleMessage/BroadcastChannel entirely -- guarantees the entry is
+   *  present before a subsequent query's own isolateForRead/rejoin cycle
+   *  runs, instead of racing a real postMessage against it. */
+  testInjectPendingTx(id, waSalt1) {
+    this.#mapIdToPendingTx.set(id, { id, waSalt1, pages: new Map(), dbFileSize: 0, waOffsetEnd: 0 });
+  }
+  /** @type {WriteAheadOptions} */ options = {
+    autoCheckpoint: 1,
+    backstopInterval: DEFAULT_BACKSTOP_INTERVAL,
+    journalSizeLimit: DEFAULT_JOURNAL_SIZE_LIMIT,
+  };
+
+  #zName;
+  #dbHandle;
+
+  /** @type {FileSystemSyncAccessHandle[]} */ #waHandles;
+  /** @type {FileSystemSyncAccessHandle} */ #activeHandle;
+  /** @type {{nextTxId: number, salt1: number, salt2: number}} */ #activeHeader;
+  /** @type {number} */ #activeOffset;
+  /** @type {number} */ #txId = 0;
+  /** @type {Transaction} */ #txInProgress = null;
+
+  #dbFileSize = 0;
+
+  /** @type {Promise} */ #ready;
+  /** @type {'read'|'write'} */ #isolationState = null;
+
+  /** @type {Lock} */ #txIdLock = null;
+
+  /** @type {Map} */ #waOverlay = new Map();
+  /** @type {Map} */ #mapIdToTx = new Map();
+  /** @type {Map} */ #mapIdToPendingTx = new Map();
+
+  // This is the total number of pages in #mapIdToTx, i.e. the number
+  // of pages in transactions that have not been checkpointed. This may
+  // not exactly match the number of pages in the WAL files because a
+  // page can be written multiple times in a transaction but will only
+  // be counted once here.
+  #approxPageCount = 0;
+
+  // The sum across this array tracks the number of pages in the active
+  // WAL file. The element corresponding to the inactive WAL file will
+  // always be zero; it will *not* contain the number of pages in the
+  // inactive WAL file.
+  #activeHandlePageCounts = [0, 0];
+
+  /** @type {BroadcastChannel} */ #broadcastChannel;
+
+  /** @type {number} */ #backstopTimer;
+  /** @type {number} */ #backstopTimestamp = 0;
+
+  #abortController = new AbortController();
+
+  /**
+   * @param {string} zName
+   * @param {FileSystemSyncAccessHandle} dbHandle
+   * @param {FileSystemSyncAccessHandle[]} waHandles
+   * @param {WriteAheadOptions} options
+   */
+  constructor(zName, dbHandle, waHandles, options = {}) {
+    this.#zName = zName;
+    this.#dbHandle = dbHandle;
+    this.#waHandles = waHandles;
+    this.options = Object.assign(this.options, options);
+
+    // All the asynchronous initialization is done here.
+    this.#ready = (async () => {
+      // Acquire the checkpoint lock in case the database is newly created
+      // and we have to initialize a WAL file.
+      const { fileHeader } =
+        await navigator.locks.request(`${this.#zName}#ckpt`, async () => {
+        // Set our advertised txId to zero until we know the proper value.
+        // This will also prevent other connections from checkpointing
+        // after we release the #ckpt lock.
+        await this.#updateTxIdLock();
+
+        // Listen for transactions and checkpoints from other connections.
+        this.#broadcastChannel = new BroadcastChannel(`${zName}#wa`);
+        this.#broadcastChannel.onmessage = (event) => {
+          this.#handleMessage(event);
+        };
+
+        // Read headers from both WAL files and use the one with the
+        // lower nextTxId. If neither header is valid, create a new header.
+        const fileHeader = this.#waHandles
+          .map(handle => this.#readFileHeader(handle))
+          .filter(h => h)
+          .sort((a, b) => a.nextTxId - b.nextTxId)[0]
+          ?? this.#writeFileHeader(Math.floor(Math.random() * 0xffffffff));
+        return { fileHeader };
+      });
+
+      // The checkpoint lock has been released, but checkpointing will not
+      // happen until read the WAL files and advance our txId.
+      this.#activeHeader = fileHeader;
+      this.#activeHandle = this.#waHandles[fileHeader.salt1 & 1];
+      this.#activeOffset = FILE_HEADER_SIZE;
+      this.#txId = fileHeader.nextTxId - 1;
+
+      // Load all the transactions from the WAL.
+      for (const tx of this.#readAllTx()) {
+        this.#activateTx(tx);
+      }
+      this.#updateTxIdLock(); // doesn't need await
+
+      // Schedule backstop. The backstop is a guard against a crash in
+      // another context between persisting a transaction and broadcasting
+      // it.
+      this.#backstopTimestamp = performance.now();
+      this.#backstop();
+    })();
+  }
+
+  /**
+   * @returns {Promise}
+   */
+  ready() {
+    return this.#ready;
+  }
+
+  close() {
+    this.#abortController.abort();
+
+    // Stop asynchronous maintenance.
+    this.#broadcastChannel.onmessage = null;
+    clearTimeout(this.#backstopTimer);
+
+    this.#txIdLock?.release();
+    this.#broadcastChannel.close();
+  }
+
+  /**
+   * Freeze our view of the database.
+   * The view includes the transactions received so far but is not
+   * guaranteed to be completely up to date. Unfreeze the view with rejoin().
+   */
+  isolateForRead() {
+    if (this.#isolationState !== null) {
+      throw new Error('Already in isolated state');
+    }
+    this.#isolationState = 'read';
+
+    // Disable backstop during isolation.
+    clearTimeout(this.#backstopTimer);
+    this.#backstopTimer = null;
+  }
+
+  /**
+   * Freeze our view of the database for writing.
+   * The view includes all transactions. Unfreeze the view with rejoin().
+   */
+  isolateForWrite() {
+    if (this.#isolationState !== null) {
+      throw new Error('Already in isolated state');
+    }
+    this.#isolationState = 'write';
+
+    // Disable backstop during isolation.
+    clearTimeout(this.#backstopTimer);
+    this.#backstopTimer = null;
+
+    // A writer needs all previous transactions assimilated.
+    this.#advanceTxId({ readToCurrent: true });
+  }
+
+  rejoin() {
+    if (this.#isolationState === 'read') {
+      // Catch up on new transactions that arrived while isolated.
+      this.#advanceTxId({ autoCheckpoint: true });
+    }
+    this.#isolationState = null;
+
+    // Resume backstop after isolation.
+    this.#backstop();
+  }
+
+  /**
+   * @param {number} offset
+   * @return {Uint8Array?}
+   */
+  read(offset) {
+    // First look for the page in any write transaction in progress.
+    // If the page is not found in the transaction overlay, look in the
+    // write-ahead overlay.
+    const pageEntry = this.#txInProgress?.pages.get(offset) ?? this.#waOverlay.get(offset);
+    if (pageEntry) {
+      if (pageEntry.pageData) {
+        // Page data is cached.
+        this.log?.(`%cread page at ${offset} from WAL ${pageEntry.waSalt1 & 1}:${pageEntry.waOffset} (cached)`, 'background-color: gold;');
+        return pageEntry.pageData;
+      }
+
+      // Read the page from the WAL file.
+      this.log?.(`%cread page at ${offset} from WAL ${pageEntry.waSalt1 & 1}:${pageEntry.waOffset}`, 'background-color: gold;');
+      return this.#fetchPage(pageEntry);
+    }
+    return null;
+  }
+
+  /**
+   * @param {number} offset
+   * @param {Uint8Array} data
+   * @param {{dstPageSize: number?}} options
+   */
+  write(offset, data, options) {
+    if (this.#isolationState !== 'write') {
+      throw new Error('Not in write isolated state');
+    }
+
+    if (!this.#txInProgress) {
+      this.#beginTx();
+      if (options.dstPageSize !== data.byteLength) {
+        // This is a VACUUM to a new page size. The incoming writes are at
+        // the old page size, but we want to write to the WAL with the new
+        // size.
+        this.#txInProgress.newPageSize = options.dstPageSize;
+      }
+    }
+
+    if (this.#txInProgress.newPageSize) {
+      // The incoming data is not a single page because the page size
+      // is changing. The two cases are when the new page size is
+      // smaller or larger than the old page size.
+      const frameSize = FRAME_HEADER_SIZE + this.#txInProgress.newPageSize;
+      if (data.byteLength > this.#txInProgress.newPageSize) {
+        // New page size is smaller. Write multiple pages of the new
+        // page size.
+        for (let i = 0; i < data.byteLength; i += this.#txInProgress.newPageSize) {
+          const pageData = data.slice(i, i + this.#txInProgress.newPageSize);
+          const waOffset = this.#writePage(offset + i, pageData);
+          this.log?.(`%cwrite page at ${offset + i} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
+        }
+      } else {
+        // New page size is larger. Save the page data to the WAL file
+        // so it can be read back and rewritten as frames with the new
+        // page size.
+        const pageOffset = offset % this.#txInProgress.newPageSize;
+        const waOffset = this.#activeOffset +
+          (offset - pageOffset) / this.#txInProgress.newPageSize * frameSize +
+          FRAME_HEADER_SIZE +
+          pageOffset;
+        this.#activeHandle.write(data.subarray(), { at: waOffset });
+        this.log?.(`%cwrite page at ${offset} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
+      }
+    } else {
+      // This is the normal case without a page size change.
+      const waOffset = this.#writePage(offset, data.slice());
+      this.log?.(`%cwrite page at ${offset} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
+    }
+  }
+
+  /**
+   * @param {number} newSize
+   */
+  truncate(newSize) {
+    // Ignore truncation that happens outside of a transaction. That
+    // only happens (e.g. post-VACUUM) to ensure the file size matches
+    // the database header.
+    if (this.#txInProgress) {
+      // Remove any pages past the truncation point.
+      for (const offset of this.#txInProgress.pages.keys()) {
+        if (offset >= newSize) {
+          this.#txInProgress.pages.delete(offset);
+        }
+      }
+    }
+  }
+
+  getFileSize() {
+    return this.#txInProgress?.dbFileSize ?? this.#dbFileSize;
+  }
+
+  commit() {
+    const tx = this.#txInProgress;
+    if (tx.newPageSize && tx.pages.size === 0) {
+      // This transaction is a VACUUM with a page size increase. All
+      // the database pages have been written to the WAL file at their
+      // new size with blank frame headers. Read the page data back
+      // from the WAL file and rewrite as frames.
+      let pageCount = 1; // to be replaced on the first iteration
+      for (let i = 0; i < pageCount; i++) {
+        // Read the page data.
+        const pageData = new Uint8Array(tx.newPageSize);
+        const waOffset = this.#activeOffset +
+          i * (FRAME_HEADER_SIZE + tx.newPageSize) +
+          FRAME_HEADER_SIZE;
+        this.#activeHandle.read(pageData, { at: waOffset });
+
+        if (i === 0) {
+          // Get the actual page count from the file header.
+          const headerView = new DataView(pageData.buffer);
+          pageCount = headerView.getUint32(28);
+        }
+
+        // Write back as a frame.
+        this.#writePage(i * tx.newPageSize, pageData);
+      }
+    }
+
+    const page1 = this.#txInProgress.pages.get(0)?.pageData;
+    if (page1) {
+      const page1View = new DataView(page1.buffer, page1.byteOffset, page1.byteLength);
+      const pageCount = page1View.getUint32(28);
+      this.#txInProgress.dbFileSize = pageCount * page1.byteLength;
+    } else {
+      // The transaction doesn't include page 1, so this must be a
+      // non-batch-atomic rollback.
+      this.rollback();
+      return;
+    }
+
+    // Persist the final pending transaction page with the database size.
+    this.#commitTx();
+
+    // Incorporate the transaction locally.
+    this.#activateTx(tx);
+    this.#updateTxIdLock();
+
+    // Send the transaction to other connections.
+    const payload = { type: 'tx', tx };
+    this.#broadcastChannel.postMessage(payload);
+
+    // Check whether to move to the other WAL file. The other WAL file must
+    // be empty, and the active WAL file size (in pages) must exceed the
+    // configured threshold.
+    if (this.#isInactiveFileEmpty()) {
+      const walFilePageCount =
+        this.#activeHandlePageCounts[0] + this.#activeHandlePageCounts[1];
+      const nPageThreshold = this.options.journalSizeLimit > 0 ?
+        this.options.journalSizeLimit :
+        DEFAULT_JOURNAL_SIZE_LIMIT;
+      if (walFilePageCount >= nPageThreshold) {
+        this.log?.(`%cchange WAL file at ${walFilePageCount} pages`, 'background-color: lightskyblue;');
+        this.#swapActiveFile();
+      }
+    }
+
+    this.#autoCheckpoint();
+    this.#backstopTimestamp = performance.now();
+  }
+
+  rollback() {
+    // Discard transaction pages.
+    this.#abortTx();
+  }
+
+  /**
+   * @param {{durability: 'strict'|'relaxed'}} options
+   */
+  sync(options) {
+    if (options.durability === 'strict') {
+      this.#flushActiveFile();
+    }
+  }
+
+  /**
+   * Move pages from write-ahead to main database file.
+   *
+   * @param {{isPassive: boolean}} options
+   */
+  async checkpoint(options = { isPassive: true }) {
+    // Passive checkpointing is abandoned if another connection is
+    // already checkpointing.
+    const lockOptions = {
+      ifAvailable: options.isPassive,
+    };
+
+    await navigator.locks.request(`${this.#zName}#ckpt`, lockOptions, async lock => {
+      if (!lock) return;
+      if (this.#abortController.signal.aborted) return;
+
+      let ckptId = this.#getActiveFileStartingTxId() - 1;
+      if (options.isPassive) {
+        if (!this.#mapIdToTx.has(ckptId)) {
+          // There are no transactions to checkpoint.
+          return;
+        }
+
+        // Scan the txId locks to find the oldest txId.
+        const busyTxId = (await this.#getTxIdLocks())
+          .reduce((min, value) => Math.min(min, value.maxTxId), this.#txId);
+
+        if (busyTxId < ckptId) {
+          // The inactive WAL file is still being used.
+          return;
+        }
+      } else {
+        // Wait for all connections to reach the current txId.
+        await this.#waitForTxIdLocks(value => value.maxTxId >= this.#txId);
+        ckptId = this.#txId;
+      }
+      this.log?.(`%ccheckpoint through txId ${ckptId}`, 'background-color: lightgreen;');
+
+      // Sync the WAL file. This ensures that if there is a crash after
+      // part of the WAL has been copied, the uncopied part will still be
+      // available afterwards.
+      this.#flushInactiveFile();
+      if (!options.isPassive) {
+        this.#flushActiveFile();
+      }
+
+      // Starting at ckptId and going backwards (higher to lower txId),
+      // write transaction pages to the main database file. Do not overwrite
+      // a page written by a more recent transaction.
+      const writtenOffsets = new Set();
+      let dbFileSize = this.#dbHandle.getSize();
+      for (let tx = this.#mapIdToTx.get(ckptId); tx; tx = this.#mapIdToTx.get(tx.id - 1)) {
+        if (tx.id === ckptId && dbFileSize !== tx.dbFileSize) {
+          // Set the file size from the latest transaction.
+          dbFileSize = tx.dbFileSize;
+          this.#dbHandle.truncate(dbFileSize);
+        }
+
+        for (const [offset, pageEntry] of tx.pages) {
+          if (offset < dbFileSize && !writtenOffsets.has(offset)) {
+            // Fetch the page data from the WAL file if not cached.
+            const pageData = pageEntry.pageData ?? this.#fetchPage(pageEntry);
+
+            // Write the page to the database file.
+            const nWritten = this.#dbHandle.write(pageData, { at: offset });
+            if (nWritten !== pageData.byteLength) {
+              throw new Error('Checkpoint write failed');
+            }
+            writtenOffsets.add(offset);
+            this.log?.(`%ccheckpoint wrote txId ${tx.id} page at ${offset} to database`, 'background-color: lightgreen;');
+          }
+        }
+
+        if (tx.newPageSize) {
+          // This transaction used a new page size to overwrite the entire
+          // database file so no older pages need to be written. This is
+          // not just an optimization; it prevents incorrectly writing
+          // older smaller pages at addresses that aren't multiples of
+          // the new page size.
+          break;
+        }
+      }
+
+      // Ensure that database writes are durable.
+      this.log?.(`%ccheckpoint flush database file`, 'background-color: lightgreen;');
+      this.#dbHandle.flush();
+
+      // Notify other connections and ourselves of the checkpoint.
+      this.#broadcastChannel.postMessage({
+        type: 'ckpt',
+        ckptId,
+      });
+      this.#handleCheckpoint(ckptId);
+
+      // Wait for all connections to update their overlay.
+      this.log?.(`%ccheckpoint waiting for connection updates`, 'background-color: lightgreen;');
+      await this.#waitForTxIdLocks(value => value.minTxId > ckptId);
+
+      // Truncate the inactive WAL file. This prevents new connections from
+      // unnecessarily reading checkpointed data, and allows writers to make
+      // it active when their conditions are met.
+      this.#truncateInactiveFile();
+      this.log?.(`%ccheckpoint complete`, 'background-color: lightgreen;');
+    });
+  }
+
+  /**
+   * Return the approximate number of write-ahead pages. This is the
+   * sum of the number of unique page indices for each transaction,
+   * so it can be fewer than the number of pages if any transaction
+   * contains multiple frames for the same page.
+   * @returns {number}
+   */
+  getWriteAheadSize() {
+    return this.#approxPageCount;
+  }
+
+  isTransactionPending() {
+    return !!this.#txInProgress;
+  }
+
+  setBackstopInterval(intervalMillis) {
+    this.options.backstopInterval = intervalMillis;
+    if (intervalMillis > 0 && this.#isolationState) {
+      this.#backstop();
+    }
+  }
+
+  /**
+   * Incorporate a transaction into our view of the database.
+   * @param {Transaction} tx
+   */
+  #activateTx(tx) {
+    // Transfer to the active collection of transactions.
+    this.#mapIdToTx.set(tx.id, tx);
+
+    // Track the number of pages in the active WAL file.
+    const page1 = tx.pages.get(0);
+    const activeIndex = page1.waSalt1 & 0x1;
+    this.#activeHandlePageCounts[activeIndex] += tx.pages.size;
+    this.#activeHandlePageCounts[1 - activeIndex] = 0;
+
+    this.#approxPageCount += tx.pages.size;
+
+    // Add transaction pages to the write-ahead overlay.
+    for (const [offset, pageEntry] of tx.pages) {
+      this.#waOverlay.set(offset, pageEntry);
+    }
+    this.#dbFileSize = tx.dbFileSize;
+  }
+
+  /**
+   * Advance the local view of the database. By default, advance to the
+   * last broadcast transaction. Optionally, also advance through any
+   * additional transactions in the WAL file to be fully current.
+   *
+   * @param {{readToCurrent?: boolean, autoCheckpoint?: boolean}} options
+   */
+  #advanceTxId(options = {}) {
+    let didAdvance = false;
+    while (this.#mapIdToPendingTx.size) {
+      // Fetch the next transaction in sequence. Usually this will come
+      // from pendingTx, but if it is missing then read it from the file.
+      const nextTxId = this.#txId + 1;
+      let tx;
+      if (this.#mapIdToPendingTx.has(nextTxId)) {
+        // This transaction arrived via message.
+        tx = this.#mapIdToPendingTx.get(nextTxId);
+
+        // FIX 1: don't remove it from the pending map until #skipTx has
+        // actually consumed it. #skipTx can throw (see FIX 2's comment on
+        // when that can still happen); deleting first meant a thrown
+        // transaction was gone forever -- #txId could never advance past it,
+        // since nothing else ever re-adds an id once its broadcast has been
+        // seen. Deleting only on success makes a retry (next broadcast, or
+        // the backstop) actually able to make progress instead of repeating
+        // the identical failure forever.
+        this.#skipTx(tx);
+        this.#mapIdToPendingTx.delete(tx.id);
+      } else {
+        // Read the transaction from the WAL file.
+        tx = this.#readTx();
+        if (!tx) {
+          this.diagnosticLog?.({
+            tag: this.tag, event: 'advanceTxId-readTx-null',
+            txId: this.#txId, nextTxId,
+            pendingKeys: [...this.#mapIdToPendingTx.keys()],
+            activeOffset: this.#activeOffset,
+            activeHeaderSalt1: this.#activeHeader.salt1,
+          });
+          // FIX 3: the next transaction genuinely isn't at our current
+          // file position yet (or never will be -- see FIX 2). Either way
+          // #activateTx(null) is not a case to run into; stop advancing
+          // for now. The pending entries stay queued, so the very next
+          // broadcast -- or the backstop's readToCurrent pass -- tries
+          // again rather than crashing this call.
+          break;
+        }
+      }
+
+      this.#activateTx(tx);
+      didAdvance = true;
+    }
+
+    if (options.readToCurrent) {
+      // Read all additional transactions from the WAL file.
+      for (const tx of this.#readAllTx()) {
+        this.#activateTx(tx);
+        didAdvance = true;
+      }
+    }
+
+    if (didAdvance) {
+      // Publish our new view txId.
+      this.#updateTxIdLock();
+
+      if (options.autoCheckpoint) {
+        this.#autoCheckpoint();
+      }
+    }
+
+    if (options.readToCurrent || didAdvance) {
+      // The WAL has been accessed, so reset the backstop.
+      // Calling #backstop() here is not necessary because if we are
+      // in an isolated state then rejoin() will schedule the next call,
+      // and if we are not in an isolated state then the next call
+      // should already be scheduled.
+      this.#backstopTimestamp = performance.now();
+    }
+  }
+
+  #autoCheckpoint() {
+    if (this.options.autoCheckpoint > 0) {
+      this.checkpoint({ isPassive: true });
+    }
+  }
+
+  /**
+   * After a checkpoint, remove checkpointed pages from write-ahead.
+   * The checkpoint may be been done locally or by another connection.
+   * @param {number} ckptId
+   */
+  #handleCheckpoint(ckptId) {
+    this.log?.(`%capply checkpoint through txId ${ckptId}`, 'background-color: lightgreen;');
+
+    // Loop backwards from ckptId.
+    for (let tx = this.#mapIdToTx.get(ckptId); tx; tx = this.#mapIdToTx.get(tx.id - 1)) {
+      // Remove pages from write-ahead overlay.
+      for (const [offset, pageEntry] of tx.pages.entries()) {
+        // Be sure not to remove a newer version of the page.
+        const overlayEntry = this.#waOverlay.get(offset);
+        if (overlayEntry === pageEntry) {
+          this.log?.(`%cremove txId ${tx.id} page at offset ${offset}`, 'background-color: lightgreen;');
+          this.#waOverlay.delete(offset);
+        }
+      }
+
+      // Remove transaction.
+      this.#mapIdToTx.delete(tx.id);
+      this.#approxPageCount -= tx.pages.size;
+    }
+    this.#updateTxIdLock();
+  }
+
+  /**
+   * @param {MessageEvent} event
+   */
+  #handleMessage(event) {
+    if (event.data.type === 'tx') {
+      // New transaction from another connection. Don't use it if we
+      // already have it.
+      /** @type {Transaction} */ const tx = event.data.tx;
+      this.diagnosticLog?.({ tag: this.tag, event: 'debug-handleMessage', testPauseConsumption: this.testPauseConsumption, incomingTxId: tx.id, currentTxId: this.#txId, isolationState: this.#isolationState, pendingMapAfter: null });
+      if (this.testPauseConsumption) return;
+      if (tx.id > this.#txId) {
+        if (this.testDropTxIds.has(tx.id)) {
+          this.diagnosticLog?.({ tag: this.tag, event: 'test-dropped-broadcast', txId: tx.id });
+          this.testDropTxIds.delete(tx.id);
+          return;
+        }
+        this.#mapIdToPendingTx.set(tx.id, tx);
+        if (this.#isolationState === null) {
+          // Not in an isolated state, so advance our view of the database.
+          this.#advanceTxId({ autoCheckpoint: true });
+        }
+      }
+    } else if (event.data.type === 'ckpt') {
+      // Checkpoint notification from another connection.
+      /** @type {number} */ const ckptId = event.data.ckptId;
+      this.#handleCheckpoint(ckptId);
+    }
+  }
+
+  /**
+   * Periodic check for recovering from lost transaction broadcasts.
+   */
+  #backstop() {
+    if (this.options.backstopInterval <= 0) {
+      // Backstop is disabled.
+      return;
+    }
+
+    if (this.#isolationState) {
+      throw new Error('Backstop was invoked in an isolated state');
+    }
+
+    const now = performance.now();
+    if (now >= this.#backstopTimestamp + this.options.backstopInterval) {
+      // The time since the last WAL access (read, write, or skip) has
+      // exceeded the backstop interval. Check for transactions in the
+      // write-ahead log that have not arrived via message.
+      const oldTxId = this.#txId;
+      this.#advanceTxId({ readToCurrent: true });
+      if (this.#txId > oldTxId) {
+        this.log?.(`%cbackstop txId ${oldTxId} -> ${this.#txId}`, 'background-color: lightyellow;');
+      }
+      this.#backstopTimestamp = performance.now();
+    }
+
+    // Schedule next backstop.
+    const delay = this.#backstopTimestamp + this.options.backstopInterval - performance.now();
+    clearTimeout(this.#backstopTimer);
+    this.#backstopTimer = self.setTimeout(() => {
+      this.#backstop();
+    }, delay);
+  }
+
+  /**
+   * Update the lock that publishes our current txId.
+   */
+  async #updateTxIdLock() {
+    // Our view of the database, i.e. the txId, is encoded into the name
+    // of a lock so other connections can see it. When our txId changes,
+    // we acquire a new lock and release the old one. We must not release
+    // the old lock until the new one is in place.
+    const oldLock = this.#txIdLock;
+    const newLockName = this.#encodeTxIdLockName();
+    if (oldLock?.name !== newLockName) {
+      this.#txIdLock = new Lock(newLockName);
+      await this.#txIdLock.acquire('shared').then(() => {
+        // The new lock is acquired.
+        oldLock?.release();
+      });
+
+      if (this.log) {
+        const { minTxId, maxTxId } = this.#decodeTxIdLockName(newLockName);
+        this.log?.(`%ctxId to ${minTxId}:${maxTxId}`, 'background-color: pink;');
+      }
+    }
+  }
+
+  /**
+   * Get all txId locks for this database.
+   * @returns {Promise<{name: string, minTxId: number, maxTxId: number, encoded: string}[]>}
+   */
+  async #getTxIdLocks() {
+    const { held } = await navigator.locks.query();
+    return held
+      .map(lock => this.#decodeTxIdLockName(lock.name))
+      .filter(value => value !== null);
+  }
+
+  /**
+   * @returns {string}
+   */
+  #encodeTxIdLockName() {
+    // The maxTxId is our current view of the database. The minTxId is
+    // the lowest txId we get pages from the WAL for, which is the lowest
+    // key in mapIdToTx. If mapIdToTx is empty then we aren't reading
+    // from the WAL at all - in this case we arbitrarily set minTxId to
+    // invalid value maxTxId + 1.
+    //
+    // Use radix 36 to encode integer values to reduce the lock name length.
+    const maxTxId = this.#txId;
+    const minTxId = this.#mapIdToTx.keys().next().value ?? (maxTxId + 1);
+    return `${this.#zName}-txId<${minTxId.toString(36)}:${maxTxId.toString(36)}>`;
+  }
+
+  /**
+   * @param {string} lockName
+   * @returns {{name: string, minTxId: number, maxTxId: number, encoded: string}?}
+   */
+  #decodeTxIdLockName(lockName) {
+    const match = lockName.match(/^(.*)-txId<([0-9a-z]+):([0-9a-z]+)>$/);
+    if (match?.[1] === this.#zName) {
+      // This txId lock is for this database.
+      return {
+        name: match[1],
+        minTxId: parseInt(match[2], 36),
+        maxTxId: parseInt(match[3], 36),
+        encoded: lockName
+      };
+    }
+    return null;
+  }
+
+  /**
+   * Wait for all txId locks that fail the provided predicate.
+   * @param {(lock: {name: string, minTxId: number, maxTxId: number}) => boolean} predicate
+   */
+  async #waitForTxIdLocks(predicate) {
+    /** @type {string[]} */ let failingLockNames = [];
+    do {
+      // Wait for all connections that fail the predicate.
+      if (failingLockNames.length > 0) {
+        await Promise.all(
+          failingLockNames.map(name => navigator.locks.request(name, async () => {}))
+        );
+      }
+
+      // Refresh the list of failing locks.
+      failingLockNames = (await this.#getTxIdLocks())
+        .filter(value => !predicate(value))
+        .map(value => value.encoded);
+    } while (failingLockNames.length > 0);
+  }
+
+  /**
+   * @param {PageEntry} pageEntry
+   * @returns {Uint8Array}
+   */
+  #fetchPage(pageEntry) {
+    // Get the appropriate access handle based on salt parity.
+    const accessHandle = this.#waHandles[pageEntry.waSalt1 & 1];
+
+    // Read the page.
+    const pageData = new Uint8Array(pageEntry.pageSize);
+    const nBytesRead = accessHandle.read(pageData, { at: pageEntry.waOffset });
+
+    if (nBytesRead !== pageEntry.pageSize) {
+      throw new Error(`Short WAL read: expected ${pageEntry.pageSize} bytes, got ${nBytesRead}`);
+    }
+    return pageData;
+  }
+
+  *#readAllTx() {
+    while (true) {
+      const tx = this.#readTx();
+      if (!tx) break;
+      yield tx;
+    }
+  }
+
+  /**
+   * @returns {Transaction?}
+   */
+  #readTx() {
+    // Read the next complete transaction or return null.
+    /** @type {Transaction} */ const tx = {
+      id: 0, // placeholder
+      pages: new Map(),
+      dbFileSize: 0, // placeholder
+      waSalt1: 0, // placeholder
+      waOffsetEnd: 0, // placeholder
+    };
+
+    // The property this.#activeOffset is only advanced on a successful
+    // transition to the other WAL file or on reading a complete
+    // transaction. Use a local variable to track our progress.
+    let offset = this.#activeOffset;
+    while (true) {
+      const frame = this.#readFrame(offset);
+      if (!frame) return null;
+
+      if (frame.frameType === FRAME_TYPE_PAGE) {
+        tx.pages.set(
+          frame.pageOffset,
+          {
+            pageSize: frame.pageData.byteLength,
+            waOffset: offset + FRAME_HEADER_SIZE,
+            waSalt1: this.#activeHeader.salt1,
+          }
+        );
+      } else if (frame.frameType === FRAME_TYPE_COMMIT) {
+        // The transaction is complete. Update the instance state.
+        this.#txId += 1;
+        this.#activeOffset = offset + frame.byteLength;
+
+        // Finalize the transaction fields and return it.
+        tx.id = this.#txId;
+        tx.dbFileSize = frame.dbFileSize;
+        tx.waSalt1 = this.#activeHeader.salt1;
+        tx.newPageSize = (frame.flags & 1) ? tx.pages.get(0).pageSize : null;
+        tx.waOffsetEnd = this.#activeOffset;
+        return tx;
+      } else if (frame.frameType === FRAME_TYPE_END) {
+        // No more transactions on the current WAL file. Switch to the
+        // other file.
+        this.#followFileChange(frame.fileHeader);
+        offset = this.#activeOffset;
+        continue;
+      }
+
+      offset += frame.byteLength;
+    }
+  }
+
+  /**
+   * This method is called when transaction(s) have been received by other
+   * means than readTx(), e.g. via BroadcastChannel.
+   *
+   * @param {Transaction} tx
+   */
+  #skipTx(tx) {
+    if (tx.waSalt1 !== this.#activeHeader.salt1) {
+      // This transaction is on the other WAL file.
+      const before = this.#activeHeader.salt1;
+
+      // FIX 2: adopt whichever of the two physical files actually holds
+      // tx.waSalt1 right now, verified against its real on-disk header --
+      // not just "the inactive file, if it happens to be exactly one
+      // generation ahead". A connection can be more than one swap behind
+      // (only one hop was ever handled), and even at exactly one swap
+      // behind, the old code accepted the inactive file on the +1 check
+      // alone without confirming it actually matches tx.waSalt1 -- on a
+      // coincidental match (two swaps happen to leave the "wrong" file
+      // showing activeHeader.salt1+1) that silently adopts the wrong file,
+      // which is the "database disk image is malformed" case: #skipTx
+      // never throws, #activeOffset gets set for a different generation
+      // than the one #activeHandle now points at, and the next real page
+      // read hands SQLite bytes from the wrong place entirely.
+      if (!this.#adoptFileForSalt1(tx.waSalt1)) {
+        this.diagnosticLog?.({
+          tag: this.tag, event: 'skipTx-throw',
+          txId: this.#txId, txWaitingFor: tx.id, txWaSalt1: tx.waSalt1,
+          activeHeaderSalt1Before: before,
+        });
+        throw new Error('invalid WAL file');
+      }
+      this.diagnosticLog?.({
+        tag: this.tag, event: 'skipTx-followed',
+        txId: this.#txId, txWaitingFor: tx.id, txWaSalt1: tx.waSalt1,
+        activeHeaderSalt1Before: before, activeHeaderSalt1After: this.#activeHeader.salt1,
+      });
+    }
+
+    this.#txId = tx.id;
+    this.#activeOffset = tx.waOffsetEnd;
+  }
+
+  /**
+   * Adopt whichever of the two physical WAL files currently has a valid,
+   * checksummed header whose salt1 equals targetSalt1, verified by reading
+   * the real file -- never assumed from a generation-count hop. There are
+   * only ever two physical files, so if the transaction we're trying to
+   * skip to still exists at all, one of them names it exactly; if neither
+   * does, the data genuinely isn't recoverable from disk and this
+   * correctly reports failure rather than guessing.
+   *
+   * @param {number} targetSalt1
+   * @returns {boolean}
+   */
+  #adoptFileForSalt1(targetSalt1) {
+    for (const candidate of this.#waHandles) {
+      const header = this.#readFileHeader(candidate);
+      if (header?.salt1 === targetSalt1) {
+        this.#activeHandle = candidate;
+        this.#activeHeader = header;
+        this.#activeOffset = FILE_HEADER_SIZE;
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * @param {{overwrite?: boolean}} options
+   * @returns {Transaction}
+   */
+  #beginTx(options = {}) {
+    this.#txInProgress = {
+      id: this.#txId + 1,
+      pages: new Map(),
+      dbFileSize: this.#dbFileSize,
+      waSalt1: this.#activeHeader.salt1,
+      waOffsetEnd: this.#activeOffset,
+    };
+    return this.#txInProgress;
+  }
+
+  /**
+   * Write a page frame to the WAL file.
+   *
+   * @param {number} pageOffset
+   * @param {Uint8Array} pageData
+   */
+  #writePage(pageOffset, pageData) {
+    const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    headerView.setUint8(0, FRAME_TYPE_PAGE);
+    headerView.setUint16(2, pageData.byteLength === 65536 ? 1 : pageData.byteLength);
+    headerView.setBigUint64(8, BigInt(pageOffset));
+    headerView.setUint32(16, this.#activeHeader.salt1);
+    headerView.setUint32(20, this.#activeHeader.salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    checksum.update(pageData);
+    headerView.setUint32(24, checksum.s0);
+    headerView.setUint32(28, checksum.s1);
+
+    const bytesWritten =
+      this.#activeHandle.write(headerView, { at: this.#txInProgress.waOffsetEnd }) +
+      this.#activeHandle.write(pageData, {
+        at: this.#txInProgress.waOffsetEnd + FRAME_HEADER_SIZE,
+      });
+    if (bytesWritten !== headerView.byteLength + pageData.byteLength) {
+      throw new Error('write failed');
+    }
+
+    // Cache page 1 as a performance optimization and to exercise the
+    // cache code path.
+    const pageEntry = {
+      pageSize: pageData.byteLength,
+      waOffset: this.#txInProgress.waOffsetEnd + FRAME_HEADER_SIZE,
+      waSalt1: this.#activeHeader.salt1,
+      pageData: pageOffset === 0 ? pageData : undefined
+    };
+    this.#txInProgress.pages.set(pageOffset, pageEntry);
+    this.#txInProgress.waOffsetEnd += bytesWritten;
+
+    return pageEntry.waOffset;
+  }
+
+  /**
+   * @returns {Transaction}
+   */
+  #commitTx() {
+    // Write a commit frame - which is a special frame header with no
+    // body - to the WAL file.
+    const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    headerView.setUint8(0, FRAME_TYPE_COMMIT);
+    headerView.setUint8(1, this.#txInProgress.newPageSize ? 1 : 0);
+    headerView.setBigUint64(8, BigInt(this.#txInProgress.dbFileSize));
+    headerView.setUint32(16, this.#activeHeader.salt1);
+    headerView.setUint32(20, this.#activeHeader.salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    headerView.setUint32(24, checksum.s0);
+    headerView.setUint32(28, checksum.s1);
+
+    const bytesWritten = this.#activeHandle.write(headerView, {
+      at: this.#txInProgress.waOffsetEnd,
+    });
+    if (bytesWritten !== headerView.byteLength) {
+      throw new Error('write failed');
+    }
+    this.#txInProgress.waOffsetEnd += bytesWritten;
+
+    const tx = this.#txInProgress;
+    this.#txInProgress = null;
+    this.#activeOffset = tx.waOffsetEnd;
+    this.#txId = tx.id;
+    return tx;
+  }
+
+  #abortTx() {
+    this.#txInProgress = null;
+    this.#activeHandle.truncate(this.#activeOffset);
+  }
+
+  /**
+   * Switch the active WAL file prior to writing the next transaction.
+   */
+  #swapActiveFile() {
+    // Write an end frame to terminate the currently active WAL file.
+    const frameView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    frameView.setUint8(0, FRAME_TYPE_END);
+    frameView.setUint32(16, this.#activeHeader.salt1);
+    frameView.setUint32(20, this.#activeHeader.salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(frameView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    frameView.setUint32(24, checksum.s0);
+    frameView.setUint32(28, checksum.s1);
+
+    const bytesWritten = this.#activeHandle.write(frameView, { at: this.#activeOffset });
+    if (bytesWritten !== frameView.byteLength) {
+      throw new Error('write failed');
+    }
+
+    // Initialize the other WAL file and make it active.
+    const oldSalt1 = this.#activeHeader.salt1;
+    this.#activeHeader = this.#writeFileHeader();
+    this.#activeHandle = this.#getInactiveHandle();
+    this.#activeOffset = FILE_HEADER_SIZE;
+    this.diagnosticLog?.({ tag: this.tag, event: 'swap', oldSalt1, newSalt1: this.#activeHeader.salt1, txId: this.#txId });
+  }
+
+  #getActiveFileStartingTxId() {
+    return this.#activeHeader.nextTxId;
+  }
+
+  #flushActiveFile() {
+    this.#activeHandle.flush();
+  }
+
+  #flushInactiveFile() {
+    const accessHandle = this.#getInactiveHandle();
+    accessHandle.flush();
+  }
+
+  #isInactiveFileEmpty() {
+    if (this.#mapIdToTx.has(this.#activeHeader.nextTxId - 1)) {
+      // At least one transaction on the inactive file has not been
+      // checkpointed.
+      return false;
+    }
+
+    const inactiveHandle = this.#getInactiveHandle();
+    if (inactiveHandle.getSize() < FILE_HEADER_SIZE) {
+      // The inactive file is smaller than the minimum size for a valid
+      // WAL file.
+      return true;
+    }
+
+    // This test is sufficient by itself but the previous tests are
+    // less expensive.
+    return this.#readFileHeader(inactiveHandle) === null;
+  }
+
+  #truncateInactiveFile() {
+    const accessHandle = this.#getInactiveHandle();
+    accessHandle.truncate(0);
+  }
+
+  /**
+   * This method is called after reading an end frame to switch to the
+   * other WAL file.
+   * @param {{nextTxId: number, salt1: number, salt2: number}?} fileHeader
+   */
+  #followFileChange(fileHeader) {
+    // As an optimization, the file header can be passed as an argument
+    // if it has already been read and validated. Otherwise that is
+    // done here.
+    const accessHandle = this.#getInactiveHandle();
+    if (!fileHeader) {
+      fileHeader = this.#readFileHeader(accessHandle);
+      if (fileHeader?.salt1 !== ((this.#activeHeader.salt1 + 1) >>> 0)) return null;
+    }
+
+    this.#activeHandle = accessHandle;
+    this.#activeHeader = fileHeader;
+    this.#activeOffset = FILE_HEADER_SIZE;
+    return fileHeader;
+  }
+
+  #getInactiveHandle() {
+    return this.#activeHandle !== this.#waHandles[0] ?
+      this.#waHandles[0] :
+      this.#waHandles[1];
+  }
+
+  /**
+   * @param {FileSystemSyncAccessHandle} accessHandle
+   */
+  #readFileHeader(accessHandle) {
+    const headerView = new DataView(new ArrayBuffer(FILE_HEADER_SIZE));
+    if (accessHandle.read(headerView, { at: 0 }) !== headerView.byteLength) {
+      return null;
+    }
+
+    if (headerView.getUint32(0) !== MAGIC) return null;
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FILE_HEADER_SIZE - 8));
+    if (!checksum.matches(headerView.getUint32(24), headerView.getUint32(28))) {
+      return null;
+    }
+
+    return {
+      nextTxId: Number(headerView.getBigUint64(8)),
+      salt1: headerView.getUint32(16),
+      salt2: headerView.getUint32(20),
+    };
+  }
+
+  /**
+   * @param {number} offset
+   */
+  #readFrame(offset) {
+    const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    if (this.#activeHandle.read(headerView, { at: offset }) !== headerView.byteLength) {
+      // EOF, not an error.
+      return null;
+    }
+
+    // Verify the frame header salt values match the file header.
+    const frameSalt1 = headerView.getUint32(16);
+    const frameSalt2 = headerView.getUint32(20);
+    if (frameSalt1 !== this.#activeHeader.salt1 || frameSalt2 !== this.#activeHeader.salt2) {
+      // Not necessarily an error, could be from a restart without truncation.
+      this.diagnosticLog?.({
+        tag: this.tag, event: 'readFrame-salt-mismatch',
+        offset, txId: this.#txId,
+        frameSalt1, frameSalt2,
+        activeHeaderSalt1: this.#activeHeader.salt1, activeHeaderSalt2: this.#activeHeader.salt2,
+      });
+      return null;
+    }
+
+    const payloadSize = (size => size === 1 ? 65536 : size)(headerView.getUint16(2));
+    /** @type {Uint8Array} */ let payloadData;
+    if (payloadSize) {
+      payloadData = new Uint8Array(payloadSize);
+      const payloadBytesRead = this.#activeHandle.read(
+        payloadData,
+        { at: offset + FRAME_HEADER_SIZE }
+      );
+      if (payloadBytesRead !== payloadSize) return null;
+    }
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    if (payloadData) {
+      checksum.update(payloadData);
+    }
+    if (!checksum.matches(headerView.getUint32(24), headerView.getUint32(28))) {
+      // Not necessarily an error, could be from a restart without truncation.
+      return null;
+    }
+
+    const frameType = headerView.getUint8(0);
+    if (frameType === FRAME_TYPE_PAGE) {
+      return {
+        frameType,
+        byteLength: FRAME_HEADER_SIZE + payloadSize,
+        pageOffset: Number(headerView.getBigUint64(8)),
+        pageData: payloadData,
+      };
+    } else if (frameType === FRAME_TYPE_COMMIT) {
+      return {
+        frameType,
+        byteLength: FRAME_HEADER_SIZE,
+        flags: headerView.getUint8(1),
+        dbFileSize: Number(headerView.getBigUint64(8)),
+      };
+    } else if (frameType === FRAME_TYPE_END) {
+      // Handling the end frame and new file header must be atomic, so
+      // we validate the new file header before returning the frame.
+      // If the file header is corrupt, the end frame effectively does
+      // not exist.
+      //
+      // A corrupt file header should be repaired by the next writer
+      // that attempts to swap WAL files.
+      const fileHeader = this.#readFileHeader(this.#getInactiveHandle());
+      if (fileHeader?.salt1 !== ((this.#activeHeader.salt1 + 1) >>> 0)) return null;
+
+      return {
+        frameType,
+        byteLength: FRAME_HEADER_SIZE,
+        fileHeader,
+      };
+    }
+    throw new Error(`Invalid frame type: ${frameType}`);
+  }
+
+  #writeFileHeader(prevSalt1 = this.#activeHeader.salt1) {
+    // Derive new values from the previous values.
+    const nextTxId = this.#txId + 1;
+    const salt1 = (prevSalt1 + 1) >>> 0;
+    const salt2 = Math.floor(Math.random() * 0xffffffff) >>> 0;
+    const headerView = new DataView(new ArrayBuffer(FILE_HEADER_SIZE));
+    headerView.setUint32(0, MAGIC);
+    headerView.setBigUint64(8, BigInt(nextTxId));
+    headerView.setUint32(16, salt1);
+    headerView.setUint32(20, salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FILE_HEADER_SIZE - 8));
+    headerView.setUint32(24, checksum.s0);
+    headerView.setUint32(28, checksum.s1);
+
+    // The even/odd parity of salt1 determines which file is written to.
+    const accessHandle = this.#waHandles[salt1 & 1];
+    const bytesWritten = accessHandle.write(headerView, { at: 0 });
+    if (bytesWritten !== headerView.byteLength) {
+      throw new Error('write failed');
+    }
+
+    return { nextTxId, salt1, salt2 };
+  }
+}
+
+// https://www.sqlite.org/fileformat.html#checksum_algorithm
+class Checksum {
+  /** @type {number} */ s0 = 0;
+  /** @type {number} */ s1 = 0;
+
+  /**
+   * @param {ArrayBuffer|ArrayBufferView} data
+   */
+  update(data) {
+    if ((data.byteLength % 8) !== 0) throw new Error('Data must be a multiple of 8 bytes');
+    const words = ArrayBuffer.isView(data) ?
+      new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4) :
+      new Uint32Array(data);
+    for (let i = 0; i < words.length; i += 2) {
+      this.s0 = (this.s0 + words[i] + this.s1) >>> 0;
+      this.s1 = (this.s1 + words[i + 1] + this.s0) >>> 0;
+    }
+  }
+
+  matches(s0, s1) {
+    return this.s0 === s0 && this.s1 === s1;
+  }
+}
diff --git a/repro-345/vendor/LazyLock.js b/repro-345/vendor/LazyLock.js
new file mode 100644
index 00000000..6ba65588
--- /dev/null
+++ b/repro-345/vendor/LazyLock.js
@@ -0,0 +1,90 @@
+import { Lock } from './Lock.js';
+
+export class LazyLock extends Lock {
+  #channel;
+  #isBusy = false;
+  #hasReleaseRequest = false;
+
+  /**
+   * @param {string} name 
+   */
+  constructor(name) {
+    super(name);
+    this.#channel = new BroadcastChannel(name);
+    this.#channel.onmessage = (event) => {
+      if (this.#isBusy) {
+        // We're using the lock so postpone the release.
+        this.#hasReleaseRequest = true;
+      } else {
+        this.release();
+      }
+    }
+  }
+
+  close() {
+    super.close();
+    this.#channel.onmessage = null;
+    this.#channel.close();
+  }
+
+  /**
+   * @param {LockMode} mode 
+   * @param {number} timeout 
+   * @returns {Promise}
+   */
+  async acquire(mode, timeout = -1) {
+    this.#isBusy = true;
+    try {
+      if (mode === this.mode) {
+        // We never had to release the lock.
+        return true;
+      }
+
+      if (this.mode) {
+        // Release the lock to acquire it in a different mode.
+        super.release();
+      } else {
+        // Poll for the lock. This isn't necessary but if it works it avoids
+        // the BroadcastChannel traffic.
+        if (await super.acquire(mode, 0)) {
+          return true;
+        }
+      }
+
+      // Request the lock.
+      const pResult = super.acquire(mode, timeout)
+      this.#channel.postMessage({});
+
+      return await pResult;
+    } catch (e) {
+      this.release();
+      throw e;
+    }
+  }
+
+  /**
+   * @param {LockMode} mode 
+   * @returns {boolean}
+   */
+  acquireIfHeld(mode) {
+    if (mode === this.mode) {
+      this.#isBusy = true;
+      return true;
+    }
+    return false;
+  }
+
+  release() {
+    super.release();
+    this.#isBusy = false;
+    this.#hasReleaseRequest = false;
+  }
+
+  releaseLazy() {
+    // Release the lock only if someone else wants it.
+    this.#isBusy = false;
+    if (this.#hasReleaseRequest) {
+      this.release();
+    }
+  }
+}
\ No newline at end of file
diff --git a/repro-345/vendor/Lock.js b/repro-345/vendor/Lock.js
new file mode 100644
index 00000000..6199f374
--- /dev/null
+++ b/repro-345/vendor/Lock.js
@@ -0,0 +1,69 @@
+// This is a convenience wrapper for the Web Locks API.
+export class Lock {
+  #name;
+  /** @type {LockMode?} */ #mode = null;
+  /** @type {Promise} */ #releaser = Promise.resolve(null);
+  #isAcquiring = false;
+
+  /**
+   * @param {string} name 
+   */
+  constructor(name) {
+    this.#name = name;
+  }
+
+  get name() { return this.#name; }
+  get mode() { return this.#mode; }
+
+  close() {
+    this.release();
+  }
+  
+  /**
+   * @param {'shared'|'exclusive'} mode 
+   * @param {number} timeout -1 for infinite, 0 for poll, >0 for milliseconds
+   * @return {Promise} true if lock acquired, false on failed poll
+   */
+  async acquire(mode, timeout = -1) {
+    if (this.#isAcquiring) throw new Error('Lock is already being acquired');
+    this.#isAcquiring = true;
+    try {
+      if (this.#mode) {
+        throw new Error(`Lock ${this.#name} is already acquired`);
+      }
+
+      this.#releaser = new Promise((resolve, reject) => {
+        /** @type {LockOptions} */
+        const options = { mode, ifAvailable: timeout === 0 };
+        if (timeout > 0) {
+          options.signal = AbortSignal.timeout(timeout);
+        }
+
+        navigator.locks.request(this.#name, options, lock => {
+          if (lock === null) {
+            // Polling (with timeout = 0) did not acquire the lock.
+            return resolve(null);
+          }
+
+          // Lock acquired. The lock is released when this returned
+          // Promise is resolved.
+          this.#mode = mode;
+          return new Promise(releaser => {
+            resolve(releaser);
+          })
+        }).catch(e => {
+          return reject(e);
+        });
+      });
+
+      return this.#releaser.then(releaser => !!releaser)
+    } finally {
+      this.#isAcquiring = false;
+    }
+  }
+
+  release() {
+    this.#releaser.then(releaser => releaser?.(), () => {});
+    this.#mode = null;
+  }
+}
diff --git a/repro-345/vendor/OPFSWriteAheadVFS.js b/repro-345/vendor/OPFSWriteAheadVFS.js
new file mode 100644
index 00000000..80fc2a58
--- /dev/null
+++ b/repro-345/vendor/OPFSWriteAheadVFS.js
@@ -0,0 +1,973 @@
+import { FacadeVFS } from "../../src/FacadeVFS.js";
+import * as VFS from '../../src/VFS.js';
+import { LazyLock } from "./LazyLock.js";
+import { WriteAhead } from "./WriteAhead.js";
+
+const LIBRARY_FILES_ROOT = '.wa-sqlite';
+const DEFAULT_TEMP_FILES = 6;
+
+const finalizationRegistry = new FinalizationRegistry((/** @type {() => void} */ f) => f());
+
+/**
+ * @typedef FileEntry
+ * @property {string} zName
+ * @property {number} flags
+ * @property {FileSystemSyncAccessHandle} [accessHandle]
+
+ * Main database file properties:
+ * @property {*} [retryResult]
+ * @property {FileSystemSyncAccessHandle[]} [waHandles]
+ * 
+ * @property {'reserved'|'exclusive'|null} [writeHint]
+ * @property {'normal'|'exclusive'} [lockingMode]
+ * @property {number} [lockState] SQLITE_LOCK_*
+ * @property {LazyLock} [readLock]
+ * @property {LazyLock} [writeLock]
+ * @property {'none'|'read'|'write'|'readwrite'} [useLazyLock]
+ * @property {number} [timeout]
+ * @property {0|1|2|3} [synchronous]
+ * @property {number?} [pageSize]
+ * @property {boolean} [overwrite]
+ * 
+ * @property {WriteAhead} [writeAhead]
+ */
+
+/**
+ * @typedef OPFSWriteAheadOptions
+ * @property {number} [nTmpFiles]
+ * @property {number} [autoCheckpoint]
+ * @property {number} [backstopInterval]
+ */
+
+export class OPFSWriteAheadVFS extends FacadeVFS {
+  lastError = null;
+  log = null;
+  diagnosticLog = null;
+  tag = '?';
+  testDropTxIds = new Set();
+  testPauseConsumption = false;
+  
+  /** @type {Map} */ mapIdToFile = new Map();
+  /** @type {Map} */ mapPathToFile = new Map();
+
+  /** @type {Map} */ boundTempFiles = new Map();
+  /** @type {Set} */ unboundTempFiles = new Set();
+  /** @type {OPFSWriteAheadOptions} */ options = {
+    nTmpFiles: DEFAULT_TEMP_FILES
+  };
+
+  _ready;
+
+  static async create(name, module, options) {
+    const vfs = new OPFSWriteAheadVFS(name, module);
+    Object.assign(vfs.options, options);
+    await vfs.isReady();
+    return vfs;
+  }
+
+  constructor(name, module) {
+    super(name, module);
+    this._ready = (async () => {
+      // Ensure the library files root directory exists.
+      let dirHandle = await navigator.storage.getDirectory();
+      dirHandle = await dirHandle.getDirectoryHandle(LIBRARY_FILES_ROOT, { create: true });
+
+      // Clean up any stale session directories.
+      // @ts-ignore
+      for await (const name of dirHandle.keys()) {
+        if (name.startsWith('.session-')) {
+          // Acquire a lock on the session directory to ensure it is not in use.
+          await navigator.locks.request(name, { ifAvailable: true }, async lock => {
+            if (lock) {
+              // This directory is not in use.
+              try {
+                await dirHandle.removeEntry(name, { recursive: true });
+              } catch (e) {
+                // Ignore errors, will try again next time.
+              }
+            }
+          });
+        }
+      }
+
+      // Create our session directory.
+      const dirName = `.session-${Math.random().toString(16).slice(2)}`;
+      await new Promise(resolve => {
+        navigator.locks.request(dirName, () => {
+          // @ts-ignore
+          resolve();
+          return new Promise(release => {
+            // @ts-ignore
+            finalizationRegistry.register(this, release);
+          });
+        });
+      });
+      dirHandle = await dirHandle.getDirectoryHandle(dirName, { create: true });
+
+      // Create temporary files.
+      for (let i = 0; i < this.options.nTmpFiles; i++) {
+        const fileHandle= await dirHandle.getFileHandle(i.toString(), { create: true });
+        const accessHandle = await fileHandle.createSyncAccessHandle();
+        finalizationRegistry.register(this, () => accessHandle.close());
+        this.unboundTempFiles.add(accessHandle);
+      }
+    })();
+  }
+
+  isReady() {
+    return Promise.all([super.isReady(), this._ready]).then(() => true);
+  }
+
+ /**
+   * @param {string?} zName 
+   * @param {number} fileId 
+   * @param {number} flags 
+   * @param {DataView} pOutFlags 
+   * @returns {number}
+   */
+  jOpen(zName, fileId, flags, pOutFlags) {
+    try {
+      if (zName === null) {
+        // Generate a temporary filename. This will only be used as a
+        // key to map to a pre-opened temporary file access handle.
+        zName = Math.random().toString(16).slice(2);
+      }
+
+      const file = this.mapPathToFile.get(zName) ?? {
+        zName,
+        flags,
+        retryResult: null,
+      };
+      this.mapPathToFile.set(zName, file);
+
+      if (flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        // Open database and journal files with a retry operation.
+        if (file.retryResult === null) {
+          // This is the initial open attempt. Start the asynchronous task
+          // and return SQLITE_BUSY to force a retry.
+          this._module.retryOps.push(this.#retryOpen(zName, flags, fileId, pOutFlags));
+          return VFS.SQLITE_BUSY;
+        } else if (file.retryResult instanceof Error) {
+          const e = file.retryResult;
+          file.retryResult = null;
+          throw e;
+        }
+
+        // Initialize database file state.
+        file.accessHandle = file.retryResult.accessHandle;
+        file.waHandles = file.retryResult.waHandles;
+        file.writeAhead = file.retryResult.writeAhead;
+        file.retryResult = null;
+
+        file.lockState = VFS.SQLITE_LOCK_NONE;
+        file.lockingMode = 'normal';
+        file.readLock = new LazyLock(`${zName}#read`);
+        file.writeLock = new LazyLock(`${zName}#write`);
+        file.useLazyLock = 'readwrite';
+        file.timeout = -1;
+        file.synchronous = 1; // NORMAL
+        file.writeHint = null;
+        file.pageSize = null;
+        file.overwrite = false;
+      } else if (flags & (VFS.SQLITE_OPEN_WAL | VFS.SQLITE_OPEN_SUPER_JOURNAL)) {
+        throw new Error('WAL and super-journal files are not supported');
+      } else if (file.accessHandle) {
+        // This temporary file already has an access handle, which happens
+        // only for tests. Just use it as is.
+      } else {
+        // This is a temporary file. Use an unbound pre-opened accessHandle.
+        if (!(flags & VFS.SQLITE_OPEN_CREATE)) throw new Error('file not found');
+        file.accessHandle = this.#openTemporaryFile(zName);
+      }
+
+      this.mapIdToFile.set(fileId, file);
+      pOutFlags.setInt32(0, flags, true);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      this.mapPathToFile.delete(zName);
+      return VFS.SQLITE_CANTOPEN;
+    }
+  }
+
+  /**
+   * @param {string} zName 
+   * @param {number} syncDir 
+   * @returns {number}
+   */
+  jDelete(zName, syncDir) {
+    try {
+      if (this.boundTempFiles.has(zName)) {
+        const file = this.mapPathToFile.get(zName);
+        this.#deleteTemporaryFile(file);
+      } else {
+        throw new Error(`unexpected file deletion: ${zName}`);
+      }
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_DELETE;
+    }
+  }
+
+  /**
+   * @param {string} zName 
+   * @param {number} flags 
+   * @param {DataView} pResOut 
+   * @returns {number}
+   */
+  jAccess(zName, flags, pResOut) {
+    try {
+      const file = this.mapPathToFile.get(zName);
+      pResOut.setInt32(0, file ? 1 : 0, true);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_ACCESS;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @returns {number}
+   */
+  jClose(fileId) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file?.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        file.writeAhead.close();
+        file.accessHandle.close();
+        file.waHandles.forEach(handle => handle.close());
+        this.mapPathToFile.delete(file?.zName);
+
+        file.readLock.close();
+        file.writeLock.close();
+      } else if (file?.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
+        this.#deleteTemporaryFile(file);
+      }
+
+      // Disassociate fileId from file entry.
+      this.mapIdToFile.delete(fileId);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_CLOSE;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {Uint8Array} pData 
+   * @param {number} iOffset
+   * @returns {number}
+   */
+  jRead(fileId, pData, iOffset) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+
+      let bytesRead = null;
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        // Try reading from the write-ahead overlays first. A read on the
+        // database file is always a complete page, except when reading
+        // from the 100-byte header.
+        const pageOffset = iOffset < 100 ? iOffset : 0;
+        const page = file.writeAhead.read(iOffset - pageOffset);
+        if (page) {
+          const readData = page.subarray(pageOffset, pageOffset + pData.byteLength);
+          pData.set(readData);
+          bytesRead = readData.byteLength;
+        }
+      }
+
+      if (bytesRead === null) {
+        // Read directly from the OPFS file.
+
+        // On Chrome (at least), passing pData to accessHandle.read() is
+        // an error because pData is a Proxy of a Uint8Array. Calling
+        // subarray() produces a real Uint8Array and that works.
+        bytesRead = file.accessHandle.read(pData.subarray(), { at: iOffset });
+      }
+
+      if (bytesRead < pData.byteLength) {
+        pData.fill(0, bytesRead);
+        return VFS.SQLITE_IOERR_SHORT_READ;
+      }
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_READ;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {Uint8Array} pData 
+   * @param {number} iOffset
+   * @returns {number}
+   */
+  jWrite(fileId, pData, iOffset) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        // Write to the write-ahead overlay.
+        const isPageResize = file.overwrite && file.pageSize !== pData.byteLength;
+        file.writeAhead.write(iOffset, pData, {
+          dstPageSize: isPageResize ? file.pageSize : null
+        });
+        return VFS.SQLITE_OK;
+      }
+
+      // On Chrome (at least), passing pData to accessHandle.write() is
+      // an error because pData is a Proxy of a Uint8Array. Calling
+      // subarray() produces a real Uint8Array and that works.
+      file.accessHandle.write(pData.subarray(), { at: iOffset });
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_WRITE;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {number} iSize 
+   * @returns {number}
+   */
+  jTruncate(fileId, iSize) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        file.writeAhead.truncate(iSize);
+        return VFS.SQLITE_OK;
+      }
+      file.accessHandle.truncate(iSize);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_TRUNCATE;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {number} flags 
+   * @returns {number}
+   */
+  jSync(fileId, flags) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        const durability = file.synchronous > 1 ? 'strict' : 'relaxed';
+        file.writeAhead.sync({ durability });
+      } else {
+        // This is a temporary file so sync is not needed.
+        // Temporary journals are only used for rollback by the
+        // connection that created them, not for recovery.
+      }
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_FSYNC;
+    }
+  }
+
+  /**
+   * @param {number} fileId 
+   * @param {DataView} pSize64 
+   * @returns {number}
+   */
+  jFileSize(fileId, pSize64) {
+    try {
+      const file = this.mapIdToFile.get(fileId);
+
+      let size;
+      if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+        size = file.writeAhead.getFileSize() || file.accessHandle.getSize();
+      } else {
+        size = file.accessHandle.getSize();
+      }
+      pSize64.setBigInt64(0, BigInt(size), true);
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_FSTAT;
+    }
+  }
+
+  /**
+   * @param {number} pFile 
+   * @param {number} lockType 
+   * @returns {number|Promise}
+   */
+  jLock(pFile, lockType) {
+    try {
+      const file = this.mapIdToFile.get(pFile);
+      if (file.lockState === VFS.SQLITE_LOCK_NONE && lockType === VFS.SQLITE_LOCK_SHARED) {
+        // We do all our locking work in this transition.
+        if (file.retryResult === null) {
+          if (file.lockingMode === 'exclusive') {
+            // Exclusive locking mode is treated as a write, and the
+            // read lock is also acquired to block readers.
+            file.retryResult = {};
+            this._module.retryOps.push(this.#retryLockWrite(file));
+            return VFS.SQLITE_BUSY;
+          }
+
+          // With WAL, read and write transactions use separate locks. In
+          // each case if the required lock is already held then we can
+          // proceed synchronously. Otherwise we need to acquire state
+          // asynchronously and retry.
+          if (file.writeHint) {
+            // Write transaction.
+            if (!file.writeLock.acquireIfHeld('exclusive')) {
+              file.retryResult = {};
+              this._module.retryOps.push(this.#retryLockWrite(file));
+              return VFS.SQLITE_BUSY;
+            } else {
+              file.writeAhead.isolateForWrite();
+            }
+          } else {
+            // Read transaction.
+            if (!file.readLock.acquireIfHeld('shared')) {
+              file.retryResult = {};
+              this._module.retryOps.push(this.#retryLockRead(file));
+              return VFS.SQLITE_BUSY;
+            } else {
+              file.writeAhead.isolateForRead();
+            }
+          }
+        } else if (file.retryResult instanceof Error) {
+          const e = file.retryResult;
+          file.retryResult = null;
+          throw e;
+        }
+
+        // We have acquired the needed locks, either synchronously or
+        // via retry.
+        file.retryResult = null;
+      } else if (lockType >= VFS.SQLITE_LOCK_RESERVED && !file.writeLock.mode) {
+        // This is a write transaction but we don't already have the write
+        // lock. This happens when the write hint was not used, which this
+        // VFS treats as an error.
+        throw new Error('Write transaction cannot use BEGIN DEFERRED');
+      }
+      file.lockState = lockType;
+      return VFS.SQLITE_OK;
+    } catch (e) {
+      if (e.name === 'TimeoutError') {
+        return VFS.SQLITE_BUSY;
+      }
+
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_LOCK;
+    }
+  }
+
+  /**
+   * @param {number} pFile 
+   * @param {number} lockType 
+   * @returns {number}
+   */
+  jUnlock(pFile, lockType) {
+    try {
+      const file = this.mapIdToFile.get(pFile);
+
+      // If retryResult is non-null, an asynchronous lock operation is in
+      // progress. In that case, don't change any locks.
+      if (!file.retryResult && lockType === VFS.SQLITE_LOCK_NONE) {
+        // In this VFS, this is the only unlock transition that matters.
+        // Exit write-ahead isolation.
+        file.writeAhead.rejoin();
+
+        // Release any locks.
+        switch (file.useLazyLock) {
+          case 'none':
+            file.writeLock.release();
+            file.readLock.release();
+            break;
+          case 'read':
+            file.writeLock.release();
+            file.readLock.releaseLazy();
+            break;
+          case 'write':
+            file.writeLock.releaseLazy();
+            file.readLock.release();
+            break;
+          case 'readwrite':
+            file.writeLock.releaseLazy();
+            file.readLock.releaseLazy();
+            break;
+        }
+
+        // Reset state for the next transaction.
+        file.writeHint = null;
+      }
+      file.lockState = lockType;
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR_UNLOCK;
+    }
+  }
+
+  /**
+   * @param {number} pFile 
+   * @param {DataView} pResOut 
+   * @returns {number}
+   */
+  jCheckReservedLock(pFile, pResOut) {
+    // A hot journal cannot exist so this method should never be called.
+    console.assert(false, 'unexpected');
+    pResOut.setInt32(0, 0, true);
+    return VFS.SQLITE_OK;
+  }
+
+  /**
+   * @param {number} pFile
+   * @param {number} op
+   * @param {DataView} pArg
+   * @returns {number}
+   */
+  jFileControl(pFile, op, pArg) {
+    try {
+      const file = this.mapIdToFile.get(pFile);
+      switch (op) {
+        case VFS.SQLITE_FCNTL_PRAGMA:
+          const key = this._module.UTF8ToString(pArg.getUint32(4, true));
+          const valueAddress = pArg.getUint32(8, true);
+          const value = valueAddress ? this._module.UTF8ToString(valueAddress) : null;
+          this.log?.(`PRAGMA ${key} ${value}`);
+          switch (key.toLowerCase()) {
+            case 'experimental_pragma_20251114':
+              // After entering the SHARED locking state on the next
+              // transaction, SQLite intends to immediately transition to
+              // RESERVED if value is '1', or EXCLUSIVE if value is '2'.
+              switch (value) {
+                case '1':
+                  file.writeHint = 'reserved';
+                  break;
+                case '2':
+                  file.writeHint = 'exclusive';
+                  break;
+                default:
+                  throw new Error(`unexpected write hint value: ${value}`);
+              }
+              break;
+            case 'backstop_interval':
+              if (value !== null) {
+                const millis = parseInt(value);
+                file.writeAhead.setBackstopInterval(millis);
+              } else {
+                // Return current interval.
+                const s = file.writeAhead.options.backstopInterval.toString();
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+            case 'busy_timeout':
+              // Override SQLite's handling of busy timeouts with our
+              // blocking lock timeouts.
+              if (value !== null) {
+                file.timeout = parseInt(value);
+              } else {
+                // Return current timeout.
+                const s = file.timeout.toString();
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+            case 'journal_size_limit':
+              if (value !== null) {
+                const nPages = parseInt(value);
+                file.writeAhead.options.journalSizeLimit = nPages;
+              }
+              break;
+            case 'locking_mode':
+              // Track SQLite locking mode. Exclusive mode requires a
+              // write lock.
+              switch (value?.toLowerCase()) {
+                case 'normal':
+                  file.lockingMode = 'normal';
+                  break;
+                case 'exclusive':
+                  file.lockingMode = 'exclusive';
+                  break;
+              }
+              break;
+            case 'page_size':
+              if (value !== null) {
+                // Valid page sizes are 1 (which maps to 65536) or powers of
+                // two from 512 to 32768.
+                const n = parseInt(value);
+                if (n === 1 || (n >= 512 && n <= 32768 && (n & (n - 1)) === 0)) {
+                  file.pageSize = n === 1 ? 65536 : n;
+                }
+              }
+              break;
+            case 'synchronous':
+              // Track SQLite synchronous mode. Write-ahead transactions
+              // trade durability for performance on values 1 (NORMAL) or
+              // lower.
+              if (value !== null) {
+                switch (value.toLowerCase()) {
+                  case 'off':
+                  case '0':
+                    file.synchronous = 0;
+                    break;
+                  case 'normal':
+                  case '1':
+                    file.synchronous = 1;
+                    break;
+                  case 'full':
+                  case '2':
+                    file.synchronous = 2;
+                    break;
+                  case 'extra':
+                  case '3':
+                    file.synchronous = 3;
+                    break;
+                  default:
+                    throw new Error(`unexpected synchronous value: ${value}`);
+                }
+              }
+              break;
+            case 'vfs_trace':
+              // This is a trace feature for debugging only.
+              if (value !== null) {
+                this.log = parseInt(value) !== 0 ? console.debug : null;
+                file.writeAhead.log = this.log;
+              }
+              return VFS.SQLITE_OK;
+            case 'wal_autocheckpoint':
+              // A setting greater than zero enables automatic checkpoints
+              // with this connection (enabled by default).
+              if (value !== null) {
+                file.writeAhead.options.autoCheckpoint = parseInt(value);
+              }
+              break;
+            case 'wal_checkpoint':
+              const checkpointMode = (value ?? 'passive').toLowerCase();
+              switch (checkpointMode) {
+                case 'passive':
+                  this._module.pendingOps.push(this.#pendingCheckpoint(file, checkpointMode));
+                  break;
+                case 'full':
+                case 'restart':
+                case 'truncate':
+                  if (file.writeAhead.isTransactionPending()) {
+                    throw new Error('invalid while a transaction is in progress');
+                  }
+                  this._module.pendingOps.push(this.#pendingCheckpoint(file, checkpointMode));
+                  break;
+                case 'noop':
+                  break;
+                default:
+                  throw new Error(`unexpected wal_checkpoint mode: ${value}`);
+              }
+
+              // Return the approximate number of pages in the WAL before
+              // checkpointing. SQLite returns different information, but
+              // that is not feasible from a VFS.
+              {
+                const s = file.writeAhead.getWriteAheadSize().toString();
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+            case 'lazy_lock':
+              // Lazy locks don't actually release their Web Lock until
+              // they receive a message requesting it. Typically a setting
+              // of 'readwrite' (default) or 'read' is best.
+              if (value !== null) {
+                const useLazyLock = value.toLowerCase();
+                switch (useLazyLock) {
+                  case 'read':
+                  case 'write':
+                  case 'readwrite':
+                  case 'none':
+                    file.useLazyLock = useLazyLock;
+                    break;
+                  default:
+                    throw new Error(`unexpected value for lazy_lock: ${value}`);
+                }
+              }
+              {
+                const s = file.useLazyLock;
+                const ptr = this._module._sqlite3_malloc64(s.length + 1);
+                this._module.stringToUTF8(s, ptr, s.length + 1);
+                pArg.setUint32(0, ptr, true);
+              }
+              return VFS.SQLITE_OK;
+          }
+          break;
+
+        // Support SQLite batch atomic write transactions.
+        case VFS.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE:
+        case VFS.SQLITE_FCNTL_COMMIT_ATOMIC_WRITE:
+          if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+            return VFS.SQLITE_OK;
+          }
+          break;
+        case VFS.SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE:
+          if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+            file.writeAhead.rollback();
+            return VFS.SQLITE_OK;
+          }
+          break;
+
+        case VFS.SQLITE_FCNTL_SYNC:
+          if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
+            file.writeAhead.commit();
+          }
+          break;
+
+        case VFS.SQLITE_FCNTL_OVERWRITE:
+          file.overwrite = true;
+          break;
+      }
+    } catch (e) {
+      console.error(e.stack);
+      this.lastError = e;
+      return VFS.SQLITE_IOERR;
+    }
+    return VFS.SQLITE_NOTFOUND;
+  }
+
+  /**
+   * @param {number} pFile
+   * @returns {number}
+   */
+  jDeviceCharacteristics(pFile) {
+    return VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN
+      | VFS.SQLITE_IOCAP_BATCH_ATOMIC;
+  }
+
+  /**
+   * @param {Uint8Array} zBuf 
+   * @returns {number}
+   */
+  jGetLastError(zBuf) {
+    if (this.lastError) {
+      console.error(this.lastError);
+      const outputArray = zBuf.subarray(0, zBuf.byteLength - 1);
+      const { written } = new TextEncoder().encodeInto(this.lastError.message, outputArray);
+      zBuf[written] = 0;
+    }
+    return VFS.SQLITE_OK
+  }
+
+  /**
+   * @param {string} zName 
+   * @returns {FileSystemSyncAccessHandle}
+   */
+  #openTemporaryFile(zName) {
+    if (this.unboundTempFiles.size === 0) {
+      throw new Error('no temporary files available');
+    }
+
+    // Bind an access handle from the temporary pool.
+    const accessHandle = this.unboundTempFiles.values().next().value;
+    this.unboundTempFiles.delete(accessHandle);
+    this.boundTempFiles.set(zName, accessHandle);
+    return accessHandle;
+  }
+
+  /**
+   * @param {FileEntry} file 
+   */
+  #deleteTemporaryFile(file) {
+    file.accessHandle.truncate(0);
+
+    // Temporary files are not actually deleted, just returned to the pool.
+    this.mapPathToFile.delete(file.zName);
+    this.unboundTempFiles.add(file.accessHandle);
+    this.boundTempFiles.delete(file.zName);
+  }
+
+  /**
+   * @param {string} dbName 
+   * @param {number} i 
+   * @returns {string}
+   */
+  #getWriteAheadNameFromDbName(dbName, i) {
+    // Our WAL file is not compatible with SQLite WAL, so use a distinct name.
+    return `${dbName}-wa${i}`;
+  }
+
+  /**
+   * Asynchronous PRAGMA operation to checkpoint the write-ahead log.
+   * @param {FileEntry} file 
+   * @param {'passive'|'full'|'restart'|'truncate'} mode 
+   */
+  async #pendingCheckpoint(file, mode) {
+    const onFinally = [];
+    try {
+      if (mode !== 'passive' && file.lockState === VFS.SQLITE_LOCK_NONE) {
+        await file.writeLock.acquire('exclusive');
+        onFinally.push(() => file.writeLock.release());
+
+        file.writeAhead.isolateForWrite();
+        onFinally.push(() => file.writeAhead.rejoin());
+      }
+      
+      await file.writeAhead.checkpoint({ isPassive: mode === 'passive' });
+    } catch (e) {
+      if (e.name === 'AbortError') {
+        e.code = VFS.SQLITE_BUSY;
+      }
+      throw e;
+    } finally {
+      while (onFinally.length) {
+        onFinally.pop()();
+      }
+    }
+  }
+
+  /**
+   * @param {FileEntry} file 
+   */
+  async #retryLockRead(file) {
+    const onError = [];
+    try {
+      await file.readLock.acquire('shared', file.timeout);
+      onError.push(() => file.readLock.release());
+
+      file.writeAhead.isolateForRead();
+      file.retryResult = {};
+    } catch (e) {
+      while (onError.length) {
+        onError.pop()();
+      }
+      file.retryResult = e;
+    }
+  }
+
+  /**
+   * @param {FileEntry} file 
+   */
+  async #retryLockWrite(file) {
+    const onError = [];
+    try {
+      // Exclusive locking mode requires both read and write locks.
+      // Otherwise, only the write lock is needed.
+      if (file.lockingMode === 'exclusive') {
+        await file.readLock.acquire('exclusive', file.timeout);
+        onError.push(() => file.readLock.release());
+      }
+
+      await file.writeLock.acquire('exclusive', file.timeout);
+      onError.push(() => file.writeLock.release());
+
+      file.writeAhead.isolateForWrite();
+      file.retryResult = {};
+    } catch (e) {
+      while (onError.length) {
+        onError.pop()();
+      }
+      file.retryResult = e;
+    }
+  }
+
+  /**
+   * Handle asynchronous jOpen() tasks.
+   * @param {string} zName 
+   * @param {number} flags 
+   * @param {number} fileId 
+   * @param {DataView} pOutFlags 
+   * @returns {Promise}
+   */
+  async #retryOpen(zName, flags, fileId, pOutFlags) {
+    /** @type {(() => void)[]} */ const onError = [];
+    const file = this.mapPathToFile.get(zName);
+    try {
+      const { accessHandle, waHandles } =
+        await navigator.locks.request(`${zName}#open`, async lock => {
+        // Parse the path components.
+        const directoryNames = zName.split('/').filter(d => d);
+        const dbName = directoryNames.pop();
+
+        // Get the OPFS directory handle.
+        let dirHandle = await navigator.storage.getDirectory();
+        const create = !!(flags & VFS.SQLITE_OPEN_CREATE);
+        for (const directoryName of directoryNames) {
+          dirHandle = await dirHandle.getDirectoryHandle(directoryName, { create });
+        }
+
+        const isNewDatabase = create && await (async function() {
+          try {
+            await dirHandle.getFileHandle(dbName);
+            return false;
+          } catch (e) {
+            if (e.name === 'NotFoundError') {
+              return true;
+            }
+            throw e;
+          }
+        })();
+
+        // Convenience function for opening access handles.
+        async function openFile(
+          /** @type {string} */ filename,
+          /** @type {FileSystemGetFileOptions} */ options) {
+          const fileHandle = await dirHandle.getFileHandle(filename, options);
+          // @ts-ignore
+          const accessHandle = await fileHandle.createSyncAccessHandle({
+            mode: 'readwrite-unsafe'
+          });
+          onError.push(() => {
+            accessHandle.close();
+            if (isNewDatabase) {
+              dirHandle.removeEntry(filename);
+            }
+          });
+          return accessHandle;
+        }
+
+        // Open the main database OPFS file.
+        const accessHandle = await openFile(dbName, { create });
+
+        // Open WAL files.
+        const waHandles = await Promise.all([0, 1].map(async i => {
+          const waName = this.#getWriteAheadNameFromDbName(dbName, i);
+          const waHandle = await openFile(waName, { create: true });
+          if (isNewDatabase) {
+            waHandle.truncate(0);
+          }
+          return waHandle;
+        }));
+        return { accessHandle, waHandles };
+      });
+
+      // Create the write-ahead manager.
+      const writeAhead = new WriteAhead(zName, accessHandle, waHandles);
+      writeAhead.diagnosticLog = this.diagnosticLog;
+      writeAhead.tag = this.tag;
+      writeAhead.testDropTxIds = this.testDropTxIds;
+      Object.defineProperty(writeAhead, 'testPauseConsumption', {
+        get: () => this.testPauseConsumption,
+        set: (v) => { this.testPauseConsumption = v; },
+      });
+      await writeAhead.ready();
+
+      file.retryResult = { accessHandle, waHandles, writeAhead };
+    } catch (e) {
+      while (onError.length) {
+        onError.pop()();
+      }
+      file.retryResult = e;
+    }
+  }
+}
diff --git a/repro-345/vendor/WriteAhead.js b/repro-345/vendor/WriteAhead.js
new file mode 100644
index 00000000..253aa7e5
--- /dev/null
+++ b/repro-345/vendor/WriteAhead.js
@@ -0,0 +1,1268 @@
+import { Lock } from './Lock.js';
+
+const DEFAULT_JOURNAL_SIZE_LIMIT = 1000;
+const DEFAULT_BACKSTOP_INTERVAL = 30_000;
+
+const MAGIC = 0x377f0684;
+const FILE_HEADER_SIZE = 32;
+const FRAME_HEADER_SIZE = 32;
+const FRAME_TYPE_PAGE = 0;
+const FRAME_TYPE_COMMIT = 1;
+const FRAME_TYPE_END = 2;
+
+/**
+ * @typedef PageEntry
+ * @property {number} waOffset location in WAL file
+ * @property {number} waSalt1 WAL2 file identifier
+ * @property {number} pageSize
+ * @property {Uint8Array} [pageData]
+ */
+
+/**
+ * @typedef Transaction
+ * @property {number} id
+ * @property {Map} pages address to page data mapping
+ * @property {number} dbFileSize
+ * @property {number} [newPageSize]
+ * @property {number} waSalt1 WAL2 file identifier
+ * @property {number} waOffsetEnd
+ */
+
+/**
+ * @typedef WriteAheadOptions
+ * @property {number} [autoCheckpoint]
+ * @property {number} [backstopInterval]
+ * @property {number} [journalSizeLimit]
+ */
+
+export class WriteAhead {
+
+  log = null;
+  diagnosticLog = null;
+  tag = '?';
+  /** TEST ONLY: ids in this set are dropped as if the broadcast for them never
+   *  arrived, to reproduce genuine message loss (as opposed to delay). */
+  testDropTxIds = new Set();
+  /** TEST ONLY: while true, EVERY 'tx' broadcast is ignored entirely --
+   *  never added to #mapIdToPendingTx, #advanceTxId never called. Deterministic
+   *  stand-in for a connection whose BroadcastChannel listener is simply not
+   *  running (frozen tab, or a tab mid-reload) for a controlled span of real
+   *  swaps, without needing a busy-loop or guessing which specific ids to drop. */
+  testPauseConsumption = false;
+  /** TEST ONLY: force this connection's own view back to an arbitrary
+   *  salt1, simulating "this connection's view is N generations behind"
+   *  directly -- the state a real connection ends up in for whatever
+   *  real-world reason (missed a swap notification, reopened after a
+   *  gap), without needing to fight the checkpoint back-pressure that
+   *  blocks forcing multiple REAL swaps while another lock is stale. */
+  testForceActiveHeaderSalt1(salt1) { this.#activeHeader = { ...this.#activeHeader, salt1 }; }
+  /** TEST ONLY: directly populate #mapIdToPendingTx, bypassing
+   *  #handleMessage/BroadcastChannel entirely -- guarantees the entry is
+   *  present before a subsequent query's own isolateForRead/rejoin cycle
+   *  runs, instead of racing a real postMessage against it. */
+  testInjectPendingTx(id, waSalt1) {
+    this.#mapIdToPendingTx.set(id, { id, waSalt1, pages: new Map(), dbFileSize: 0, waOffsetEnd: 0 });
+  }
+  /** @type {WriteAheadOptions} */ options = {
+    autoCheckpoint: 1,
+    backstopInterval: DEFAULT_BACKSTOP_INTERVAL,
+    journalSizeLimit: DEFAULT_JOURNAL_SIZE_LIMIT,
+  };
+
+  #zName;
+  #dbHandle;
+
+  /** @type {FileSystemSyncAccessHandle[]} */ #waHandles;
+  /** @type {FileSystemSyncAccessHandle} */ #activeHandle;
+  /** @type {{nextTxId: number, salt1: number, salt2: number}} */ #activeHeader;
+  /** @type {number} */ #activeOffset;
+  /** @type {number} */ #txId = 0;
+  /** @type {Transaction} */ #txInProgress = null;
+
+  #dbFileSize = 0;
+
+  /** @type {Promise} */ #ready;
+  /** @type {'read'|'write'} */ #isolationState = null;
+
+  /** @type {Lock} */ #txIdLock = null;
+
+  /** @type {Map} */ #waOverlay = new Map();
+  /** @type {Map} */ #mapIdToTx = new Map();
+  /** @type {Map} */ #mapIdToPendingTx = new Map();
+
+  // This is the total number of pages in #mapIdToTx, i.e. the number
+  // of pages in transactions that have not been checkpointed. This may
+  // not exactly match the number of pages in the WAL files because a
+  // page can be written multiple times in a transaction but will only
+  // be counted once here.
+  #approxPageCount = 0;
+
+  // The sum across this array tracks the number of pages in the active
+  // WAL file. The element corresponding to the inactive WAL file will
+  // always be zero; it will *not* contain the number of pages in the
+  // inactive WAL file.
+  #activeHandlePageCounts = [0, 0];
+
+  /** @type {BroadcastChannel} */ #broadcastChannel;
+
+  /** @type {number} */ #backstopTimer;
+  /** @type {number} */ #backstopTimestamp = 0;
+
+  #abortController = new AbortController();
+
+  /**
+   * @param {string} zName
+   * @param {FileSystemSyncAccessHandle} dbHandle
+   * @param {FileSystemSyncAccessHandle[]} waHandles
+   * @param {WriteAheadOptions} options
+   */
+  constructor(zName, dbHandle, waHandles, options = {}) {
+    this.#zName = zName;
+    this.#dbHandle = dbHandle;
+    this.#waHandles = waHandles;
+    this.options = Object.assign(this.options, options);
+
+    // All the asynchronous initialization is done here.
+    this.#ready = (async () => {
+      // Acquire the checkpoint lock in case the database is newly created
+      // and we have to initialize a WAL file.
+      const { fileHeader } =
+        await navigator.locks.request(`${this.#zName}#ckpt`, async () => {
+        // Set our advertised txId to zero until we know the proper value.
+        // This will also prevent other connections from checkpointing
+        // after we release the #ckpt lock.
+        await this.#updateTxIdLock();
+
+        // Listen for transactions and checkpoints from other connections.
+        this.#broadcastChannel = new BroadcastChannel(`${zName}#wa`);
+        this.#broadcastChannel.onmessage = (event) => {
+          this.#handleMessage(event);
+        };
+
+        // Read headers from both WAL files and use the one with the
+        // lower nextTxId. If neither header is valid, create a new header.
+        const fileHeader = this.#waHandles
+          .map(handle => this.#readFileHeader(handle))
+          .filter(h => h)
+          .sort((a, b) => a.nextTxId - b.nextTxId)[0]
+          ?? this.#writeFileHeader(Math.floor(Math.random() * 0xffffffff));
+        return { fileHeader };
+      });
+
+      // The checkpoint lock has been released, but checkpointing will not
+      // happen until read the WAL files and advance our txId.
+      this.#activeHeader = fileHeader;
+      this.#activeHandle = this.#waHandles[fileHeader.salt1 & 1];
+      this.#activeOffset = FILE_HEADER_SIZE;
+      this.#txId = fileHeader.nextTxId - 1;
+
+      // Load all the transactions from the WAL.
+      for (const tx of this.#readAllTx()) {
+        this.#activateTx(tx);
+      }
+      this.#updateTxIdLock(); // doesn't need await
+
+      // Schedule backstop. The backstop is a guard against a crash in
+      // another context between persisting a transaction and broadcasting
+      // it.
+      this.#backstopTimestamp = performance.now();
+      this.#backstop();
+    })();
+  }
+
+  /**
+   * @returns {Promise}
+   */
+  ready() {
+    return this.#ready;
+  }
+
+  close() {
+    this.#abortController.abort();
+
+    // Stop asynchronous maintenance.
+    this.#broadcastChannel.onmessage = null;
+    clearTimeout(this.#backstopTimer);
+
+    this.#txIdLock?.release();
+    this.#broadcastChannel.close();
+  }
+
+  /**
+   * Freeze our view of the database.
+   * The view includes the transactions received so far but is not
+   * guaranteed to be completely up to date. Unfreeze the view with rejoin().
+   */
+  isolateForRead() {
+    if (this.#isolationState !== null) {
+      throw new Error('Already in isolated state');
+    }
+    this.#isolationState = 'read';
+
+    // Disable backstop during isolation.
+    clearTimeout(this.#backstopTimer);
+    this.#backstopTimer = null;
+  }
+
+  /**
+   * Freeze our view of the database for writing.
+   * The view includes all transactions. Unfreeze the view with rejoin().
+   */
+  isolateForWrite() {
+    if (this.#isolationState !== null) {
+      throw new Error('Already in isolated state');
+    }
+    this.#isolationState = 'write';
+
+    // Disable backstop during isolation.
+    clearTimeout(this.#backstopTimer);
+    this.#backstopTimer = null;
+
+    // A writer needs all previous transactions assimilated.
+    this.#advanceTxId({ readToCurrent: true });
+  }
+
+  rejoin() {
+    if (this.#isolationState === 'read') {
+      // Catch up on new transactions that arrived while isolated.
+      this.#advanceTxId({ autoCheckpoint: true });
+    }
+    this.#isolationState = null;
+
+    // Resume backstop after isolation.
+    this.#backstop();
+  }
+
+  /**
+   * @param {number} offset
+   * @return {Uint8Array?}
+   */
+  read(offset) {
+    // First look for the page in any write transaction in progress.
+    // If the page is not found in the transaction overlay, look in the
+    // write-ahead overlay.
+    const pageEntry = this.#txInProgress?.pages.get(offset) ?? this.#waOverlay.get(offset);
+    if (pageEntry) {
+      if (pageEntry.pageData) {
+        // Page data is cached.
+        this.log?.(`%cread page at ${offset} from WAL ${pageEntry.waSalt1 & 1}:${pageEntry.waOffset} (cached)`, 'background-color: gold;');
+        return pageEntry.pageData;
+      }
+
+      // Read the page from the WAL file.
+      this.log?.(`%cread page at ${offset} from WAL ${pageEntry.waSalt1 & 1}:${pageEntry.waOffset}`, 'background-color: gold;');
+      return this.#fetchPage(pageEntry);
+    }
+    return null;
+  }
+
+  /**
+   * @param {number} offset
+   * @param {Uint8Array} data
+   * @param {{dstPageSize: number?}} options
+   */
+  write(offset, data, options) {
+    if (this.#isolationState !== 'write') {
+      throw new Error('Not in write isolated state');
+    }
+
+    if (!this.#txInProgress) {
+      this.#beginTx();
+      if (options.dstPageSize !== data.byteLength) {
+        // This is a VACUUM to a new page size. The incoming writes are at
+        // the old page size, but we want to write to the WAL with the new
+        // size.
+        this.#txInProgress.newPageSize = options.dstPageSize;
+      }
+    }
+
+    if (this.#txInProgress.newPageSize) {
+      // The incoming data is not a single page because the page size
+      // is changing. The two cases are when the new page size is
+      // smaller or larger than the old page size.
+      const frameSize = FRAME_HEADER_SIZE + this.#txInProgress.newPageSize;
+      if (data.byteLength > this.#txInProgress.newPageSize) {
+        // New page size is smaller. Write multiple pages of the new
+        // page size.
+        for (let i = 0; i < data.byteLength; i += this.#txInProgress.newPageSize) {
+          const pageData = data.slice(i, i + this.#txInProgress.newPageSize);
+          const waOffset = this.#writePage(offset + i, pageData);
+          this.log?.(`%cwrite page at ${offset + i} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
+        }
+      } else {
+        // New page size is larger. Save the page data to the WAL file
+        // so it can be read back and rewritten as frames with the new
+        // page size.
+        const pageOffset = offset % this.#txInProgress.newPageSize;
+        const waOffset = this.#activeOffset +
+          (offset - pageOffset) / this.#txInProgress.newPageSize * frameSize +
+          FRAME_HEADER_SIZE +
+          pageOffset;
+        this.#activeHandle.write(data.subarray(), { at: waOffset });
+        this.log?.(`%cwrite page at ${offset} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
+      }
+    } else {
+      // This is the normal case without a page size change.
+      const waOffset = this.#writePage(offset, data.slice());
+      this.log?.(`%cwrite page at ${offset} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
+    }
+  }
+
+  /**
+   * @param {number} newSize
+   */
+  truncate(newSize) {
+    // Ignore truncation that happens outside of a transaction. That
+    // only happens (e.g. post-VACUUM) to ensure the file size matches
+    // the database header.
+    if (this.#txInProgress) {
+      // Remove any pages past the truncation point.
+      for (const offset of this.#txInProgress.pages.keys()) {
+        if (offset >= newSize) {
+          this.#txInProgress.pages.delete(offset);
+        }
+      }
+    }
+  }
+
+  getFileSize() {
+    return this.#txInProgress?.dbFileSize ?? this.#dbFileSize;
+  }
+
+  commit() {
+    const tx = this.#txInProgress;
+    if (tx.newPageSize && tx.pages.size === 0) {
+      // This transaction is a VACUUM with a page size increase. All
+      // the database pages have been written to the WAL file at their
+      // new size with blank frame headers. Read the page data back
+      // from the WAL file and rewrite as frames.
+      let pageCount = 1; // to be replaced on the first iteration
+      for (let i = 0; i < pageCount; i++) {
+        // Read the page data.
+        const pageData = new Uint8Array(tx.newPageSize);
+        const waOffset = this.#activeOffset +
+          i * (FRAME_HEADER_SIZE + tx.newPageSize) +
+          FRAME_HEADER_SIZE;
+        this.#activeHandle.read(pageData, { at: waOffset });
+
+        if (i === 0) {
+          // Get the actual page count from the file header.
+          const headerView = new DataView(pageData.buffer);
+          pageCount = headerView.getUint32(28);
+        }
+
+        // Write back as a frame.
+        this.#writePage(i * tx.newPageSize, pageData);
+      }
+    }
+
+    const page1 = this.#txInProgress.pages.get(0)?.pageData;
+    if (page1) {
+      const page1View = new DataView(page1.buffer, page1.byteOffset, page1.byteLength);
+      const pageCount = page1View.getUint32(28);
+      this.#txInProgress.dbFileSize = pageCount * page1.byteLength;
+    } else {
+      // The transaction doesn't include page 1, so this must be a
+      // non-batch-atomic rollback.
+      this.rollback();
+      return;
+    }
+
+    // Persist the final pending transaction page with the database size.
+    this.#commitTx();
+
+    // Incorporate the transaction locally.
+    this.#activateTx(tx);
+    this.#updateTxIdLock();
+
+    // Send the transaction to other connections.
+    const payload = { type: 'tx', tx };
+    this.#broadcastChannel.postMessage(payload);
+
+    // Check whether to move to the other WAL file. The other WAL file must
+    // be empty, and the active WAL file size (in pages) must exceed the
+    // configured threshold.
+    if (this.#isInactiveFileEmpty()) {
+      const walFilePageCount =
+        this.#activeHandlePageCounts[0] + this.#activeHandlePageCounts[1];
+      const nPageThreshold = this.options.journalSizeLimit > 0 ?
+        this.options.journalSizeLimit :
+        DEFAULT_JOURNAL_SIZE_LIMIT;
+      if (walFilePageCount >= nPageThreshold) {
+        this.log?.(`%cchange WAL file at ${walFilePageCount} pages`, 'background-color: lightskyblue;');
+        this.#swapActiveFile();
+      }
+    }
+
+    this.#autoCheckpoint();
+    this.#backstopTimestamp = performance.now();
+  }
+
+  rollback() {
+    // Discard transaction pages.
+    this.#abortTx();
+  }
+
+  /**
+   * @param {{durability: 'strict'|'relaxed'}} options
+   */
+  sync(options) {
+    if (options.durability === 'strict') {
+      this.#flushActiveFile();
+    }
+  }
+
+  /**
+   * Move pages from write-ahead to main database file.
+   *
+   * @param {{isPassive: boolean}} options
+   */
+  async checkpoint(options = { isPassive: true }) {
+    // Passive checkpointing is abandoned if another connection is
+    // already checkpointing.
+    const lockOptions = {
+      ifAvailable: options.isPassive,
+    };
+
+    await navigator.locks.request(`${this.#zName}#ckpt`, lockOptions, async lock => {
+      if (!lock) return;
+      if (this.#abortController.signal.aborted) return;
+
+      let ckptId = this.#getActiveFileStartingTxId() - 1;
+      if (options.isPassive) {
+        if (!this.#mapIdToTx.has(ckptId)) {
+          // There are no transactions to checkpoint.
+          return;
+        }
+
+        // Scan the txId locks to find the oldest txId.
+        const busyTxId = (await this.#getTxIdLocks())
+          .reduce((min, value) => Math.min(min, value.maxTxId), this.#txId);
+
+        if (busyTxId < ckptId) {
+          // The inactive WAL file is still being used.
+          return;
+        }
+      } else {
+        // Wait for all connections to reach the current txId.
+        await this.#waitForTxIdLocks(value => value.maxTxId >= this.#txId);
+        ckptId = this.#txId;
+      }
+      this.log?.(`%ccheckpoint through txId ${ckptId}`, 'background-color: lightgreen;');
+
+      // Sync the WAL file. This ensures that if there is a crash after
+      // part of the WAL has been copied, the uncopied part will still be
+      // available afterwards.
+      this.#flushInactiveFile();
+      if (!options.isPassive) {
+        this.#flushActiveFile();
+      }
+
+      // Starting at ckptId and going backwards (higher to lower txId),
+      // write transaction pages to the main database file. Do not overwrite
+      // a page written by a more recent transaction.
+      const writtenOffsets = new Set();
+      let dbFileSize = this.#dbHandle.getSize();
+      for (let tx = this.#mapIdToTx.get(ckptId); tx; tx = this.#mapIdToTx.get(tx.id - 1)) {
+        if (tx.id === ckptId && dbFileSize !== tx.dbFileSize) {
+          // Set the file size from the latest transaction.
+          dbFileSize = tx.dbFileSize;
+          this.#dbHandle.truncate(dbFileSize);
+        }
+
+        for (const [offset, pageEntry] of tx.pages) {
+          if (offset < dbFileSize && !writtenOffsets.has(offset)) {
+            // Fetch the page data from the WAL file if not cached.
+            const pageData = pageEntry.pageData ?? this.#fetchPage(pageEntry);
+
+            // Write the page to the database file.
+            const nWritten = this.#dbHandle.write(pageData, { at: offset });
+            if (nWritten !== pageData.byteLength) {
+              throw new Error('Checkpoint write failed');
+            }
+            writtenOffsets.add(offset);
+            this.log?.(`%ccheckpoint wrote txId ${tx.id} page at ${offset} to database`, 'background-color: lightgreen;');
+          }
+        }
+
+        if (tx.newPageSize) {
+          // This transaction used a new page size to overwrite the entire
+          // database file so no older pages need to be written. This is
+          // not just an optimization; it prevents incorrectly writing
+          // older smaller pages at addresses that aren't multiples of
+          // the new page size.
+          break;
+        }
+      }
+
+      // Ensure that database writes are durable.
+      this.log?.(`%ccheckpoint flush database file`, 'background-color: lightgreen;');
+      this.#dbHandle.flush();
+
+      // Notify other connections and ourselves of the checkpoint.
+      this.#broadcastChannel.postMessage({
+        type: 'ckpt',
+        ckptId,
+      });
+      this.#handleCheckpoint(ckptId);
+
+      // Wait for all connections to update their overlay.
+      this.log?.(`%ccheckpoint waiting for connection updates`, 'background-color: lightgreen;');
+      await this.#waitForTxIdLocks(value => value.minTxId > ckptId);
+
+      // Truncate the inactive WAL file. This prevents new connections from
+      // unnecessarily reading checkpointed data, and allows writers to make
+      // it active when their conditions are met.
+      this.#truncateInactiveFile();
+      this.log?.(`%ccheckpoint complete`, 'background-color: lightgreen;');
+    });
+  }
+
+  /**
+   * Return the approximate number of write-ahead pages. This is the
+   * sum of the number of unique page indices for each transaction,
+   * so it can be fewer than the number of pages if any transaction
+   * contains multiple frames for the same page.
+   * @returns {number}
+   */
+  getWriteAheadSize() {
+    return this.#approxPageCount;
+  }
+
+  isTransactionPending() {
+    return !!this.#txInProgress;
+  }
+
+  setBackstopInterval(intervalMillis) {
+    this.options.backstopInterval = intervalMillis;
+    if (intervalMillis > 0 && this.#isolationState) {
+      this.#backstop();
+    }
+  }
+
+  /**
+   * Incorporate a transaction into our view of the database.
+   * @param {Transaction} tx
+   */
+  #activateTx(tx) {
+    // Transfer to the active collection of transactions.
+    this.#mapIdToTx.set(tx.id, tx);
+
+    // Track the number of pages in the active WAL file.
+    const page1 = tx.pages.get(0);
+    const activeIndex = page1.waSalt1 & 0x1;
+    this.#activeHandlePageCounts[activeIndex] += tx.pages.size;
+    this.#activeHandlePageCounts[1 - activeIndex] = 0;
+
+    this.#approxPageCount += tx.pages.size;
+
+    // Add transaction pages to the write-ahead overlay.
+    for (const [offset, pageEntry] of tx.pages) {
+      this.#waOverlay.set(offset, pageEntry);
+    }
+    this.#dbFileSize = tx.dbFileSize;
+  }
+
+  /**
+   * Advance the local view of the database. By default, advance to the
+   * last broadcast transaction. Optionally, also advance through any
+   * additional transactions in the WAL file to be fully current.
+   *
+   * @param {{readToCurrent?: boolean, autoCheckpoint?: boolean}} options
+   */
+  #advanceTxId(options = {}) {
+    let didAdvance = false;
+    while (this.#mapIdToPendingTx.size) {
+      // Fetch the next transaction in sequence. Usually this will come
+      // from pendingTx, but if it is missing then read it from the file.
+      const nextTxId = this.#txId + 1;
+      let tx;
+      if (this.#mapIdToPendingTx.has(nextTxId)) {
+        // This transaction arrived via message.
+        tx = this.#mapIdToPendingTx.get(nextTxId);
+        this.#mapIdToPendingTx.delete(tx.id);
+
+        // Move the WAL file offset past this transaction.
+        this.#skipTx(tx);
+      } else {
+        // Read the transaction from the WAL file.
+        tx = this.#readTx();
+        if (!tx) {
+          this.diagnosticLog?.({
+            tag: this.tag, event: 'advanceTxId-readTx-null',
+            txId: this.#txId, nextTxId,
+            pendingKeys: [...this.#mapIdToPendingTx.keys()],
+            activeOffset: this.#activeOffset,
+            activeHeaderSalt1: this.#activeHeader.salt1,
+          });
+        }
+      }
+
+      this.#activateTx(tx);
+      didAdvance = true;
+    }
+
+    if (options.readToCurrent) {
+      // Read all additional transactions from the WAL file.
+      for (const tx of this.#readAllTx()) {
+        this.#activateTx(tx);
+        didAdvance = true;
+      }
+    }
+
+    if (didAdvance) {
+      // Publish our new view txId.
+      this.#updateTxIdLock();
+
+      if (options.autoCheckpoint) {
+        this.#autoCheckpoint();
+      }
+    }
+
+    if (options.readToCurrent || didAdvance) {
+      // The WAL has been accessed, so reset the backstop.
+      // Calling #backstop() here is not necessary because if we are
+      // in an isolated state then rejoin() will schedule the next call,
+      // and if we are not in an isolated state then the next call
+      // should already be scheduled.
+      this.#backstopTimestamp = performance.now();
+    }
+  }
+
+  #autoCheckpoint() {
+    if (this.options.autoCheckpoint > 0) {
+      this.checkpoint({ isPassive: true });
+    }
+  }
+
+  /**
+   * After a checkpoint, remove checkpointed pages from write-ahead.
+   * The checkpoint may be been done locally or by another connection.
+   * @param {number} ckptId
+   */
+  #handleCheckpoint(ckptId) {
+    this.log?.(`%capply checkpoint through txId ${ckptId}`, 'background-color: lightgreen;');
+
+    // Loop backwards from ckptId.
+    for (let tx = this.#mapIdToTx.get(ckptId); tx; tx = this.#mapIdToTx.get(tx.id - 1)) {
+      // Remove pages from write-ahead overlay.
+      for (const [offset, pageEntry] of tx.pages.entries()) {
+        // Be sure not to remove a newer version of the page.
+        const overlayEntry = this.#waOverlay.get(offset);
+        if (overlayEntry === pageEntry) {
+          this.log?.(`%cremove txId ${tx.id} page at offset ${offset}`, 'background-color: lightgreen;');
+          this.#waOverlay.delete(offset);
+        }
+      }
+
+      // Remove transaction.
+      this.#mapIdToTx.delete(tx.id);
+      this.#approxPageCount -= tx.pages.size;
+    }
+    this.#updateTxIdLock();
+  }
+
+  /**
+   * @param {MessageEvent} event
+   */
+  #handleMessage(event) {
+    if (event.data.type === 'tx') {
+      // New transaction from another connection. Don't use it if we
+      // already have it.
+      /** @type {Transaction} */ const tx = event.data.tx;
+      this.diagnosticLog?.({ tag: this.tag, event: 'debug-handleMessage', testPauseConsumption: this.testPauseConsumption, incomingTxId: tx.id, currentTxId: this.#txId, isolationState: this.#isolationState, pendingMapAfter: null });
+      if (this.testPauseConsumption) return;
+      if (tx.id > this.#txId) {
+        if (this.testDropTxIds.has(tx.id)) {
+          this.diagnosticLog?.({ tag: this.tag, event: 'test-dropped-broadcast', txId: tx.id });
+          this.testDropTxIds.delete(tx.id);
+          return;
+        }
+        this.#mapIdToPendingTx.set(tx.id, tx);
+        if (this.#isolationState === null) {
+          // Not in an isolated state, so advance our view of the database.
+          this.#advanceTxId({ autoCheckpoint: true });
+        }
+      }
+    } else if (event.data.type === 'ckpt') {
+      // Checkpoint notification from another connection.
+      /** @type {number} */ const ckptId = event.data.ckptId;
+      this.#handleCheckpoint(ckptId);
+    }
+  }
+
+  /**
+   * Periodic check for recovering from lost transaction broadcasts.
+   */
+  #backstop() {
+    if (this.options.backstopInterval <= 0) {
+      // Backstop is disabled.
+      return;
+    }
+
+    if (this.#isolationState) {
+      throw new Error('Backstop was invoked in an isolated state');
+    }
+
+    const now = performance.now();
+    if (now >= this.#backstopTimestamp + this.options.backstopInterval) {
+      // The time since the last WAL access (read, write, or skip) has
+      // exceeded the backstop interval. Check for transactions in the
+      // write-ahead log that have not arrived via message.
+      const oldTxId = this.#txId;
+      this.#advanceTxId({ readToCurrent: true });
+      if (this.#txId > oldTxId) {
+        this.log?.(`%cbackstop txId ${oldTxId} -> ${this.#txId}`, 'background-color: lightyellow;');
+      }
+      this.#backstopTimestamp = performance.now();
+    }
+
+    // Schedule next backstop.
+    const delay = this.#backstopTimestamp + this.options.backstopInterval - performance.now();
+    clearTimeout(this.#backstopTimer);
+    this.#backstopTimer = self.setTimeout(() => {
+      this.#backstop();
+    }, delay);
+  }
+
+  /**
+   * Update the lock that publishes our current txId.
+   */
+  async #updateTxIdLock() {
+    // Our view of the database, i.e. the txId, is encoded into the name
+    // of a lock so other connections can see it. When our txId changes,
+    // we acquire a new lock and release the old one. We must not release
+    // the old lock until the new one is in place.
+    const oldLock = this.#txIdLock;
+    const newLockName = this.#encodeTxIdLockName();
+    if (oldLock?.name !== newLockName) {
+      this.#txIdLock = new Lock(newLockName);
+      await this.#txIdLock.acquire('shared').then(() => {
+        // The new lock is acquired.
+        oldLock?.release();
+      });
+
+      if (this.log) {
+        const { minTxId, maxTxId } = this.#decodeTxIdLockName(newLockName);
+        this.log?.(`%ctxId to ${minTxId}:${maxTxId}`, 'background-color: pink;');
+      }
+    }
+  }
+
+  /**
+   * Get all txId locks for this database.
+   * @returns {Promise<{name: string, minTxId: number, maxTxId: number, encoded: string}[]>}
+   */
+  async #getTxIdLocks() {
+    const { held } = await navigator.locks.query();
+    return held
+      .map(lock => this.#decodeTxIdLockName(lock.name))
+      .filter(value => value !== null);
+  }
+
+  /**
+   * @returns {string}
+   */
+  #encodeTxIdLockName() {
+    // The maxTxId is our current view of the database. The minTxId is
+    // the lowest txId we get pages from the WAL for, which is the lowest
+    // key in mapIdToTx. If mapIdToTx is empty then we aren't reading
+    // from the WAL at all - in this case we arbitrarily set minTxId to
+    // invalid value maxTxId + 1.
+    //
+    // Use radix 36 to encode integer values to reduce the lock name length.
+    const maxTxId = this.#txId;
+    const minTxId = this.#mapIdToTx.keys().next().value ?? (maxTxId + 1);
+    return `${this.#zName}-txId<${minTxId.toString(36)}:${maxTxId.toString(36)}>`;
+  }
+
+  /**
+   * @param {string} lockName
+   * @returns {{name: string, minTxId: number, maxTxId: number, encoded: string}?}
+   */
+  #decodeTxIdLockName(lockName) {
+    const match = lockName.match(/^(.*)-txId<([0-9a-z]+):([0-9a-z]+)>$/);
+    if (match?.[1] === this.#zName) {
+      // This txId lock is for this database.
+      return {
+        name: match[1],
+        minTxId: parseInt(match[2], 36),
+        maxTxId: parseInt(match[3], 36),
+        encoded: lockName
+      };
+    }
+    return null;
+  }
+
+  /**
+   * Wait for all txId locks that fail the provided predicate.
+   * @param {(lock: {name: string, minTxId: number, maxTxId: number}) => boolean} predicate
+   */
+  async #waitForTxIdLocks(predicate) {
+    /** @type {string[]} */ let failingLockNames = [];
+    do {
+      // Wait for all connections that fail the predicate.
+      if (failingLockNames.length > 0) {
+        await Promise.all(
+          failingLockNames.map(name => navigator.locks.request(name, async () => {}))
+        );
+      }
+
+      // Refresh the list of failing locks.
+      failingLockNames = (await this.#getTxIdLocks())
+        .filter(value => !predicate(value))
+        .map(value => value.encoded);
+    } while (failingLockNames.length > 0);
+  }
+
+  /**
+   * @param {PageEntry} pageEntry
+   * @returns {Uint8Array}
+   */
+  #fetchPage(pageEntry) {
+    // Get the appropriate access handle based on salt parity.
+    const accessHandle = this.#waHandles[pageEntry.waSalt1 & 1];
+
+    // Read the page.
+    const pageData = new Uint8Array(pageEntry.pageSize);
+    const nBytesRead = accessHandle.read(pageData, { at: pageEntry.waOffset });
+
+    if (nBytesRead !== pageEntry.pageSize) {
+      throw new Error(`Short WAL read: expected ${pageEntry.pageSize} bytes, got ${nBytesRead}`);
+    }
+    return pageData;
+  }
+
+  *#readAllTx() {
+    while (true) {
+      const tx = this.#readTx();
+      if (!tx) break;
+      yield tx;
+    }
+  }
+
+  /**
+   * @returns {Transaction?}
+   */
+  #readTx() {
+    // Read the next complete transaction or return null.
+    /** @type {Transaction} */ const tx = {
+      id: 0, // placeholder
+      pages: new Map(),
+      dbFileSize: 0, // placeholder
+      waSalt1: 0, // placeholder
+      waOffsetEnd: 0, // placeholder
+    };
+
+    // The property this.#activeOffset is only advanced on a successful
+    // transition to the other WAL file or on reading a complete
+    // transaction. Use a local variable to track our progress.
+    let offset = this.#activeOffset;
+    while (true) {
+      const frame = this.#readFrame(offset);
+      if (!frame) return null;
+
+      if (frame.frameType === FRAME_TYPE_PAGE) {
+        tx.pages.set(
+          frame.pageOffset,
+          {
+            pageSize: frame.pageData.byteLength,
+            waOffset: offset + FRAME_HEADER_SIZE,
+            waSalt1: this.#activeHeader.salt1,
+          }
+        );
+      } else if (frame.frameType === FRAME_TYPE_COMMIT) {
+        // The transaction is complete. Update the instance state.
+        this.#txId += 1;
+        this.#activeOffset = offset + frame.byteLength;
+
+        // Finalize the transaction fields and return it.
+        tx.id = this.#txId;
+        tx.dbFileSize = frame.dbFileSize;
+        tx.waSalt1 = this.#activeHeader.salt1;
+        tx.newPageSize = (frame.flags & 1) ? tx.pages.get(0).pageSize : null;
+        tx.waOffsetEnd = this.#activeOffset;
+        return tx;
+      } else if (frame.frameType === FRAME_TYPE_END) {
+        // No more transactions on the current WAL file. Switch to the
+        // other file.
+        this.#followFileChange(frame.fileHeader);
+        offset = this.#activeOffset;
+        continue;
+      }
+
+      offset += frame.byteLength;
+    }
+  }
+
+  /**
+   * This method is called when transaction(s) have been received by other
+   * means than readTx(), e.g. via BroadcastChannel.
+   *
+   * @param {Transaction} tx
+   */
+  #skipTx(tx) {
+    if (tx.waSalt1 !== this.#activeHeader.salt1) {
+      // This transaction is on the other WAL file.
+      const before = this.#activeHeader.salt1;
+      if (!this.#followFileChange(null)) {
+        this.diagnosticLog?.({
+          tag: this.tag, event: 'skipTx-throw',
+          txId: this.#txId, txWaitingFor: tx.id, txWaSalt1: tx.waSalt1,
+          activeHeaderSalt1Before: before,
+        });
+        throw new Error('invalid WAL file');
+      }
+      this.diagnosticLog?.({
+        tag: this.tag, event: 'skipTx-followed',
+        txId: this.#txId, txWaitingFor: tx.id, txWaSalt1: tx.waSalt1,
+        activeHeaderSalt1Before: before, activeHeaderSalt1After: this.#activeHeader.salt1,
+      });
+    }
+
+    this.#txId = tx.id;
+    this.#activeOffset = tx.waOffsetEnd;
+  }
+
+  /**
+   * @param {{overwrite?: boolean}} options
+   * @returns {Transaction}
+   */
+  #beginTx(options = {}) {
+    this.#txInProgress = {
+      id: this.#txId + 1,
+      pages: new Map(),
+      dbFileSize: this.#dbFileSize,
+      waSalt1: this.#activeHeader.salt1,
+      waOffsetEnd: this.#activeOffset,
+    };
+    return this.#txInProgress;
+  }
+
+  /**
+   * Write a page frame to the WAL file.
+   *
+   * @param {number} pageOffset
+   * @param {Uint8Array} pageData
+   */
+  #writePage(pageOffset, pageData) {
+    const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    headerView.setUint8(0, FRAME_TYPE_PAGE);
+    headerView.setUint16(2, pageData.byteLength === 65536 ? 1 : pageData.byteLength);
+    headerView.setBigUint64(8, BigInt(pageOffset));
+    headerView.setUint32(16, this.#activeHeader.salt1);
+    headerView.setUint32(20, this.#activeHeader.salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    checksum.update(pageData);
+    headerView.setUint32(24, checksum.s0);
+    headerView.setUint32(28, checksum.s1);
+
+    const bytesWritten =
+      this.#activeHandle.write(headerView, { at: this.#txInProgress.waOffsetEnd }) +
+      this.#activeHandle.write(pageData, {
+        at: this.#txInProgress.waOffsetEnd + FRAME_HEADER_SIZE,
+      });
+    if (bytesWritten !== headerView.byteLength + pageData.byteLength) {
+      throw new Error('write failed');
+    }
+
+    // Cache page 1 as a performance optimization and to exercise the
+    // cache code path.
+    const pageEntry = {
+      pageSize: pageData.byteLength,
+      waOffset: this.#txInProgress.waOffsetEnd + FRAME_HEADER_SIZE,
+      waSalt1: this.#activeHeader.salt1,
+      pageData: pageOffset === 0 ? pageData : undefined
+    };
+    this.#txInProgress.pages.set(pageOffset, pageEntry);
+    this.#txInProgress.waOffsetEnd += bytesWritten;
+
+    return pageEntry.waOffset;
+  }
+
+  /**
+   * @returns {Transaction}
+   */
+  #commitTx() {
+    // Write a commit frame - which is a special frame header with no
+    // body - to the WAL file.
+    const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    headerView.setUint8(0, FRAME_TYPE_COMMIT);
+    headerView.setUint8(1, this.#txInProgress.newPageSize ? 1 : 0);
+    headerView.setBigUint64(8, BigInt(this.#txInProgress.dbFileSize));
+    headerView.setUint32(16, this.#activeHeader.salt1);
+    headerView.setUint32(20, this.#activeHeader.salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    headerView.setUint32(24, checksum.s0);
+    headerView.setUint32(28, checksum.s1);
+
+    const bytesWritten = this.#activeHandle.write(headerView, {
+      at: this.#txInProgress.waOffsetEnd,
+    });
+    if (bytesWritten !== headerView.byteLength) {
+      throw new Error('write failed');
+    }
+    this.#txInProgress.waOffsetEnd += bytesWritten;
+
+    const tx = this.#txInProgress;
+    this.#txInProgress = null;
+    this.#activeOffset = tx.waOffsetEnd;
+    this.#txId = tx.id;
+    return tx;
+  }
+
+  #abortTx() {
+    this.#txInProgress = null;
+    this.#activeHandle.truncate(this.#activeOffset);
+  }
+
+  /**
+   * Switch the active WAL file prior to writing the next transaction.
+   */
+  #swapActiveFile() {
+    // Write an end frame to terminate the currently active WAL file.
+    const frameView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    frameView.setUint8(0, FRAME_TYPE_END);
+    frameView.setUint32(16, this.#activeHeader.salt1);
+    frameView.setUint32(20, this.#activeHeader.salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(frameView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    frameView.setUint32(24, checksum.s0);
+    frameView.setUint32(28, checksum.s1);
+
+    const bytesWritten = this.#activeHandle.write(frameView, { at: this.#activeOffset });
+    if (bytesWritten !== frameView.byteLength) {
+      throw new Error('write failed');
+    }
+
+    // Initialize the other WAL file and make it active.
+    const oldSalt1 = this.#activeHeader.salt1;
+    this.#activeHeader = this.#writeFileHeader();
+    this.#activeHandle = this.#getInactiveHandle();
+    this.#activeOffset = FILE_HEADER_SIZE;
+    this.diagnosticLog?.({ tag: this.tag, event: 'swap', oldSalt1, newSalt1: this.#activeHeader.salt1, txId: this.#txId });
+  }
+
+  #getActiveFileStartingTxId() {
+    return this.#activeHeader.nextTxId;
+  }
+
+  #flushActiveFile() {
+    this.#activeHandle.flush();
+  }
+
+  #flushInactiveFile() {
+    const accessHandle = this.#getInactiveHandle();
+    accessHandle.flush();
+  }
+
+  #isInactiveFileEmpty() {
+    if (this.#mapIdToTx.has(this.#activeHeader.nextTxId - 1)) {
+      // At least one transaction on the inactive file has not been
+      // checkpointed.
+      return false;
+    }
+
+    const inactiveHandle = this.#getInactiveHandle();
+    if (inactiveHandle.getSize() < FILE_HEADER_SIZE) {
+      // The inactive file is smaller than the minimum size for a valid
+      // WAL file.
+      return true;
+    }
+
+    // This test is sufficient by itself but the previous tests are
+    // less expensive.
+    return this.#readFileHeader(inactiveHandle) === null;
+  }
+
+  #truncateInactiveFile() {
+    const accessHandle = this.#getInactiveHandle();
+    accessHandle.truncate(0);
+  }
+
+  /**
+   * This method is called after reading an end frame to switch to the
+   * other WAL file.
+   * @param {{nextTxId: number, salt1: number, salt2: number}?} fileHeader
+   */
+  #followFileChange(fileHeader) {
+    // As an optimization, the file header can be passed as an argument
+    // if it has already been read and validated. Otherwise that is
+    // done here.
+    const accessHandle = this.#getInactiveHandle();
+    if (!fileHeader) {
+      fileHeader = this.#readFileHeader(accessHandle);
+      if (fileHeader?.salt1 !== ((this.#activeHeader.salt1 + 1) >>> 0)) return null;
+    }
+
+    this.#activeHandle = accessHandle;
+    this.#activeHeader = fileHeader;
+    this.#activeOffset = FILE_HEADER_SIZE;
+    return fileHeader;
+  }
+
+  #getInactiveHandle() {
+    return this.#activeHandle !== this.#waHandles[0] ?
+      this.#waHandles[0] :
+      this.#waHandles[1];
+  }
+
+  /**
+   * @param {FileSystemSyncAccessHandle} accessHandle
+   */
+  #readFileHeader(accessHandle) {
+    const headerView = new DataView(new ArrayBuffer(FILE_HEADER_SIZE));
+    if (accessHandle.read(headerView, { at: 0 }) !== headerView.byteLength) {
+      return null;
+    }
+
+    if (headerView.getUint32(0) !== MAGIC) return null;
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FILE_HEADER_SIZE - 8));
+    if (!checksum.matches(headerView.getUint32(24), headerView.getUint32(28))) {
+      return null;
+    }
+
+    return {
+      nextTxId: Number(headerView.getBigUint64(8)),
+      salt1: headerView.getUint32(16),
+      salt2: headerView.getUint32(20),
+    };
+  }
+
+  /**
+   * @param {number} offset
+   */
+  #readFrame(offset) {
+    const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
+    if (this.#activeHandle.read(headerView, { at: offset }) !== headerView.byteLength) {
+      // EOF, not an error.
+      return null;
+    }
+
+    // Verify the frame header salt values match the file header.
+    const frameSalt1 = headerView.getUint32(16);
+    const frameSalt2 = headerView.getUint32(20);
+    if (frameSalt1 !== this.#activeHeader.salt1 || frameSalt2 !== this.#activeHeader.salt2) {
+      // Not necessarily an error, could be from a restart without truncation.
+      this.diagnosticLog?.({
+        tag: this.tag, event: 'readFrame-salt-mismatch',
+        offset, txId: this.#txId,
+        frameSalt1, frameSalt2,
+        activeHeaderSalt1: this.#activeHeader.salt1, activeHeaderSalt2: this.#activeHeader.salt2,
+      });
+      return null;
+    }
+
+    const payloadSize = (size => size === 1 ? 65536 : size)(headerView.getUint16(2));
+    /** @type {Uint8Array} */ let payloadData;
+    if (payloadSize) {
+      payloadData = new Uint8Array(payloadSize);
+      const payloadBytesRead = this.#activeHandle.read(
+        payloadData,
+        { at: offset + FRAME_HEADER_SIZE }
+      );
+      if (payloadBytesRead !== payloadSize) return null;
+    }
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
+    if (payloadData) {
+      checksum.update(payloadData);
+    }
+    if (!checksum.matches(headerView.getUint32(24), headerView.getUint32(28))) {
+      // Not necessarily an error, could be from a restart without truncation.
+      return null;
+    }
+
+    const frameType = headerView.getUint8(0);
+    if (frameType === FRAME_TYPE_PAGE) {
+      return {
+        frameType,
+        byteLength: FRAME_HEADER_SIZE + payloadSize,
+        pageOffset: Number(headerView.getBigUint64(8)),
+        pageData: payloadData,
+      };
+    } else if (frameType === FRAME_TYPE_COMMIT) {
+      return {
+        frameType,
+        byteLength: FRAME_HEADER_SIZE,
+        flags: headerView.getUint8(1),
+        dbFileSize: Number(headerView.getBigUint64(8)),
+      };
+    } else if (frameType === FRAME_TYPE_END) {
+      // Handling the end frame and new file header must be atomic, so
+      // we validate the new file header before returning the frame.
+      // If the file header is corrupt, the end frame effectively does
+      // not exist.
+      //
+      // A corrupt file header should be repaired by the next writer
+      // that attempts to swap WAL files.
+      const fileHeader = this.#readFileHeader(this.#getInactiveHandle());
+      if (fileHeader?.salt1 !== ((this.#activeHeader.salt1 + 1) >>> 0)) return null;
+
+      return {
+        frameType,
+        byteLength: FRAME_HEADER_SIZE,
+        fileHeader,
+      };
+    }
+    throw new Error(`Invalid frame type: ${frameType}`);
+  }
+
+  #writeFileHeader(prevSalt1 = this.#activeHeader.salt1) {
+    // Derive new values from the previous values.
+    const nextTxId = this.#txId + 1;
+    const salt1 = (prevSalt1 + 1) >>> 0;
+    const salt2 = Math.floor(Math.random() * 0xffffffff) >>> 0;
+    const headerView = new DataView(new ArrayBuffer(FILE_HEADER_SIZE));
+    headerView.setUint32(0, MAGIC);
+    headerView.setBigUint64(8, BigInt(nextTxId));
+    headerView.setUint32(16, salt1);
+    headerView.setUint32(20, salt2);
+
+    const checksum = new Checksum();
+    checksum.update(new Uint8Array(headerView.buffer, 0, FILE_HEADER_SIZE - 8));
+    headerView.setUint32(24, checksum.s0);
+    headerView.setUint32(28, checksum.s1);
+
+    // The even/odd parity of salt1 determines which file is written to.
+    const accessHandle = this.#waHandles[salt1 & 1];
+    const bytesWritten = accessHandle.write(headerView, { at: 0 });
+    if (bytesWritten !== headerView.byteLength) {
+      throw new Error('write failed');
+    }
+
+    return { nextTxId, salt1, salt2 };
+  }
+}
+
+// https://www.sqlite.org/fileformat.html#checksum_algorithm
+class Checksum {
+  /** @type {number} */ s0 = 0;
+  /** @type {number} */ s1 = 0;
+
+  /**
+   * @param {ArrayBuffer|ArrayBufferView} data
+   */
+  update(data) {
+    if ((data.byteLength % 8) !== 0) throw new Error('Data must be a multiple of 8 bytes');
+    const words = ArrayBuffer.isView(data) ?
+      new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4) :
+      new Uint32Array(data);
+    for (let i = 0; i < words.length; i += 2) {
+      this.s0 = (this.s0 + words[i] + this.s1) >>> 0;
+      this.s1 = (this.s1 + words[i + 1] + this.s0) >>> 0;
+    }
+  }
+
+  matches(s0, s1) {
+    return this.s0 === s0 && this.s1 === s1;
+  }
+}