From 953d95f5cf6260183690e136a7b086df74b47988 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:22:33 -0700 Subject: [PATCH 1/9] feat(sqlite): open file databases with WAL and synchronous NORMAL The adapter used SQLite's defaults: a rollback journal with synchronous=FULL, about four fsync calls for each commit. node:sqlite is synchronous, so on a slow disk each commit stopped the event loop, including timers, HTTP handlers, heartbeats, and lease renewals. A CI run with a slow disk timed out a 16 ms test after 5 seconds. The Ruby gem already gets WAL with synchronous=NORMAL from Active Record. The switch to WAL needs a lock, and SQLite refuses it at once while another connection writes. So the adapter switches on its first access, with the same bounded retries and deadline rules as a transaction, instead of in the constructor. --- src/database/sqlite.ts | 24 +++++++++++++++++++++++- test/sqlite.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/database/sqlite.ts b/src/database/sqlite.ts index c2d4f9f..b34a8cd 100644 --- a/src/database/sqlite.ts +++ b/src/database/sqlite.ts @@ -57,6 +57,7 @@ export class SQLiteDatabase implements Database { private closed = false private readonly timeoutMilliseconds: number private readonly lockRetryAttempts: number + private journalConfigured = false constructor(options: SQLiteDatabaseOptions) { this.timeoutMilliseconds = options.timeoutMilliseconds ?? 5_000 @@ -115,12 +116,33 @@ export class SQLiteDatabase implements Database { } try { if (initialRemaining !== undefined) requireDatabaseDeadlineRemaining() - return await this.withBusyTimeout(callback) + return await this.withBusyTimeout(async () => { + await this.configureJournalWithRetry() + return callback() + }) } finally { release() } } + private async configureJournalWithRetry(): Promise { + if (this.journalConfigured) return + let retries = 0 + while (true) { + try { + this.database.exec("PRAGMA journal_mode = WAL") + this.database.exec("PRAGMA synchronous = NORMAL") + this.journalConfigured = true + return + } catch (error) { + if (databaseDeadlineRemainingMilliseconds() !== undefined) throw error + if (!sqliteBusyError(error) || retries >= this.lockRetryAttempts) throw error + retries += 1 + await waitForRetry(retries) + } + } + } + private async withBusyTimeout(callback: () => Promise): Promise { const remaining = requireDatabaseDeadlineRemaining() if (remaining === undefined) return callback() diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index 504f27b..469f5ef 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -18,6 +18,25 @@ afterEach(async () => { }) describe("SQLite adapter", () => { + it("opens a file database with write-ahead logging and normal sync by default", async () => { + path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` + database = sqlite({ path }) + + await expect(journalSettings(database)).resolves.toEqual({ journal: "wal", synchronous: 1 }) + }) + + it("switches a rollback-journal file to write-ahead logging after another writer releases its lock", async () => { + path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` + const setup = new DatabaseSync(path) + setup.exec("CREATE TABLE records(id INTEGER PRIMARY KEY)") + setup.close() + blocker = lockDatabase(path, { holdMilliseconds: 50 }) + await nextMessage(blocker) + database = sqlite({ path, timeoutMilliseconds: 1, lockRetryAttempts: 10 }) + + await expect(journalSettings(database)).resolves.toEqual({ journal: "wal", synchronous: 1 }) + }) + it("retries a transaction after transient writer contention", async () => { path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` const setup = new DatabaseSync(path) @@ -64,6 +83,16 @@ describe("SQLite adapter", () => { }) }) +async function journalSettings( + target: SQLiteDatabase, +): Promise<{ journal: string; synchronous: number }> { + return target.connection(async (connection) => { + const journal = await connection.get<{ journal_mode: string }>("PRAGMA journal_mode") + const synchronous = await connection.get<{ synchronous: number | bigint }>("PRAGMA synchronous") + return { journal: journal!.journal_mode, synchronous: Number(synchronous!.synchronous) } + }) +} + function lockDatabase(databasePath: string, options: { holdMilliseconds?: number } = {}): Worker { return new Worker( ` From 25690b53d42b3f5a5d8b21a858a6244ce927dfef Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:23:13 -0700 Subject: [PATCH 2/9] test: run the SQLite lock tests on WAL files The adapter now switches a file to WAL on its first access. These tests are about transaction retries and deadlines under writer contention, so their files start in WAL mode, as a file that the adapter made does. The switch under contention has its own test. --- test/database-deadline.test.ts | 1 + test/sqlite.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/test/database-deadline.test.ts b/test/database-deadline.test.ts index 17d3847..ccafe60 100644 --- a/test/database-deadline.test.ts +++ b/test/database-deadline.test.ts @@ -93,6 +93,7 @@ describe("database deadlines", () => { it("turns SQLite lock contention into a deadline error", async () => { const path = `/tmp/solid-objects-deadline-${crypto.randomUUID()}.sqlite3` const blocker = new DatabaseSync(path) + blocker.exec("PRAGMA journal_mode = WAL") blocker.exec("CREATE TABLE records(id INTEGER PRIMARY KEY)") blocker.exec("BEGIN IMMEDIATE") const database = sqlite({ path, timeoutMilliseconds: 5_000 }) diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index 469f5ef..ee728d3 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -40,6 +40,7 @@ describe("SQLite adapter", () => { it("retries a transaction after transient writer contention", async () => { path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` const setup = new DatabaseSync(path) + setup.exec("PRAGMA journal_mode = WAL") setup.exec("CREATE TABLE records(id INTEGER PRIMARY KEY)") setup.close() blocker = lockDatabase(path) @@ -66,6 +67,7 @@ describe("SQLite adapter", () => { it("stops retrying after its transaction retry budget", async () => { path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` const setup = new DatabaseSync(path) + setup.exec("PRAGMA journal_mode = WAL") setup.exec("CREATE TABLE records(id INTEGER PRIMARY KEY)") setup.close() blocker = lockDatabase(path, { holdMilliseconds: 1_000 }) From 0e36a361ee858c065713a9d0206cf89b5d583257 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:23:23 -0700 Subject: [PATCH 3/9] feat(sqlite): let synchronous choose full instead of normal A user who wants each commit flushed to disk before it returns can pass synchronous: "full". The database still uses WAL, so a commit costs about one fsync instead of four. --- src/database/sqlite.ts | 7 ++++++- test/sqlite.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/database/sqlite.ts b/src/database/sqlite.ts index b34a8cd..e349058 100644 --- a/src/database/sqlite.ts +++ b/src/database/sqlite.ts @@ -9,10 +9,13 @@ import { import { DatabaseDeadlineExceeded } from "../errors.js" import { databaseTransactionActive, withDatabaseTransaction } from "./transaction-context.js" +export type SQLiteSynchronous = "normal" | "full" + export interface SQLiteDatabaseOptions { path: string timeoutMilliseconds?: number lockRetryAttempts?: number + synchronous?: SQLiteSynchronous } class SQLiteConnection implements DatabaseConnection { @@ -57,11 +60,13 @@ export class SQLiteDatabase implements Database { private closed = false private readonly timeoutMilliseconds: number private readonly lockRetryAttempts: number + private readonly synchronous: SQLiteSynchronous private journalConfigured = false constructor(options: SQLiteDatabaseOptions) { this.timeoutMilliseconds = options.timeoutMilliseconds ?? 5_000 this.lockRetryAttempts = options.lockRetryAttempts ?? 10 + this.synchronous = options.synchronous ?? "normal" if (!Number.isSafeInteger(this.lockRetryAttempts) || this.lockRetryAttempts < 1) { throw new TypeError("lockRetryAttempts must be a positive safe integer") } @@ -131,7 +136,7 @@ export class SQLiteDatabase implements Database { while (true) { try { this.database.exec("PRAGMA journal_mode = WAL") - this.database.exec("PRAGMA synchronous = NORMAL") + this.database.exec(`PRAGMA synchronous = ${this.synchronous.toUpperCase()}`) this.journalConfigured = true return } catch (error) { diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index ee728d3..448e61a 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -25,6 +25,13 @@ describe("SQLite adapter", () => { await expect(journalSettings(database)).resolves.toEqual({ journal: "wal", synchronous: 1 }) }) + it("opens a file database with write-ahead logging and full sync when synchronous is full", async () => { + path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` + database = sqlite({ path, synchronous: "full" }) + + await expect(journalSettings(database)).resolves.toEqual({ journal: "wal", synchronous: 2 }) + }) + it("switches a rollback-journal file to write-ahead logging after another writer releases its lock", async () => { path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` const setup = new DatabaseSync(path) From 36ac2300da726b5d972ca973ce135920496d4018 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:23:41 -0700 Subject: [PATCH 4/9] feat(sqlite): reject a synchronous level other than normal or full The value goes into a PRAGMA statement, so the adapter accepts only the two documented levels and fails at construction, like lockRetryAttempts. --- src/database/sqlite.ts | 3 +++ test/sqlite.test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/database/sqlite.ts b/src/database/sqlite.ts index e349058..848bc0d 100644 --- a/src/database/sqlite.ts +++ b/src/database/sqlite.ts @@ -67,6 +67,9 @@ export class SQLiteDatabase implements Database { this.timeoutMilliseconds = options.timeoutMilliseconds ?? 5_000 this.lockRetryAttempts = options.lockRetryAttempts ?? 10 this.synchronous = options.synchronous ?? "normal" + if (this.synchronous !== "normal" && this.synchronous !== "full") { + throw new TypeError('synchronous must be "normal" or "full"') + } if (!Number.isSafeInteger(this.lockRetryAttempts) || this.lockRetryAttempts < 1) { throw new TypeError("lockRetryAttempts must be a positive safe integer") } diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index 448e61a..3ae304c 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest" import { rm } from "node:fs/promises" import { DatabaseSync } from "node:sqlite" import { Worker } from "node:worker_threads" -import { sqlite, type SQLiteDatabase } from "../src/database/sqlite.js" +import { sqlite, type SQLiteDatabase, type SQLiteSynchronous } from "../src/database/sqlite.js" let database: SQLiteDatabase | undefined let blocker: Worker | undefined @@ -65,6 +65,12 @@ describe("SQLite adapter", () => { ).resolves.toEqual({ count: 1n }) }) + it("rejects a synchronous level other than normal or full", () => { + expect(() => + sqlite({ path: ":memory:", synchronous: "off" as unknown as SQLiteSynchronous }), + ).toThrow(new TypeError('synchronous must be "normal" or "full"')) + }) + it("validates its transaction retry budget", () => { expect(() => sqlite({ path: ":memory:", lockRetryAttempts: 0 })).toThrow( "lockRetryAttempts must be a positive safe integer", From 3ffad298104396d57e78a506c5ffdae668a6c49a Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:24:03 -0700 Subject: [PATCH 5/9] test: keep the memory journal for an in-memory SQLite database SQLite answers "memory" when an in-memory database asks for WAL. This guard keeps sqlite({ path: ":memory:" }) working after the WAL default. --- test/sqlite.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index 3ae304c..78d436e 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -32,6 +32,12 @@ describe("SQLite adapter", () => { await expect(journalSettings(database)).resolves.toEqual({ journal: "wal", synchronous: 2 }) }) + it("keeps the memory journal for an in-memory database", async () => { + database = sqlite({ path: ":memory:" }) + + await expect(journalSettings(database)).resolves.toEqual({ journal: "memory", synchronous: 1 }) + }) + it("switches a rollback-journal file to write-ahead logging after another writer releases its lock", async () => { path = `/tmp/solid-objects-sqlite-${crypto.randomUUID()}.sqlite3` const setup = new DatabaseSync(path) From 72d10bd6dedee342f0782f44476d523789fc67e5 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:24:25 -0700 Subject: [PATCH 6/9] refactor(sqlite): share the busy retry between BEGIN and the journal switch --- src/database/sqlite.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/database/sqlite.ts b/src/database/sqlite.ts index 848bc0d..656d5be 100644 --- a/src/database/sqlite.ts +++ b/src/database/sqlite.ts @@ -135,20 +135,11 @@ export class SQLiteDatabase implements Database { private async configureJournalWithRetry(): Promise { if (this.journalConfigured) return - let retries = 0 - while (true) { - try { - this.database.exec("PRAGMA journal_mode = WAL") - this.database.exec(`PRAGMA synchronous = ${this.synchronous.toUpperCase()}`) - this.journalConfigured = true - return - } catch (error) { - if (databaseDeadlineRemainingMilliseconds() !== undefined) throw error - if (!sqliteBusyError(error) || retries >= this.lockRetryAttempts) throw error - retries += 1 - await waitForRetry(retries) - } - } + await this.retryWhileBusy(() => { + this.database.exec("PRAGMA journal_mode = WAL") + this.database.exec(`PRAGMA synchronous = ${this.synchronous.toUpperCase()}`) + }) + this.journalConfigured = true } private async withBusyTimeout(callback: () => Promise): Promise { @@ -180,10 +171,14 @@ export class SQLiteDatabase implements Database { } private async beginTransactionWithRetry(): Promise { + await this.retryWhileBusy(() => this.database.exec("BEGIN IMMEDIATE")) + } + + private async retryWhileBusy(operation: () => void): Promise { let retries = 0 while (true) { try { - this.database.exec("BEGIN IMMEDIATE") + operation() return } catch (error) { if (databaseDeadlineRemainingMilliseconds() !== undefined) throw error From fe0be94c254fb634d0bd84ac0d7c34c20e60b511 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:25:53 -0700 Subject: [PATCH 7/9] docs: document the SQLite WAL default and the synchronous option The configuration guide states the durability trade-off of synchronous NORMAL and FULL, the -wal and -shm files, and the switch on first access. The parity ledger records that both runtimes now use WAL with synchronous NORMAL for a file database. --- CHANGELOG.md | 10 ++++++++++ docs/api.md | 5 ++++- docs/configuration.md | 23 ++++++++++++++++++++--- docs/parity.md | 1 + 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3620d0b..6f00b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- A SQLite file database now uses write-ahead logging (WAL) with + `synchronous = NORMAL`, the settings that the Ruby gem gets from Active + Record. Before, each commit made about four `fsync` calls, and + `node:sqlite` blocked the event loop while it waited for the disk; on a slow + disk this stopped timers, heartbeats, and lease renewals. A process crash + loses no commit. A power loss or an operating system crash can now lose the + last commits, and the database stays consistent. Pass `synchronous: "full"` + to flush each commit before it returns. The adapter switches an existing file + to WAL on its first access, with the same retries as a transaction. WAL keeps + `-wal` and `-shm` files next to the database. - The short answer in `docs/virtual-actors.md` no longer states that version 1.0 makes no claim of measured scale. It links `docs/versioning.md`, the one page that states the limits of version 1.0. Version 1.0.5 missed this diff --git a/docs/api.md b/docs/api.md index 766b123..c254bf4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -776,7 +776,10 @@ and `SyncTimeoutWaitingOn` type timeout diagnostics. See - `sqlite(options)`: construct `SQLiteDatabase`. - `SQLiteDatabase`: `Database` implementation and `close()` owner. -- `SQLiteDatabaseOptions`: path, busy timeout, and lock retry options. +- `SQLiteDatabaseOptions`: path, busy timeout, lock retry, and `synchronous` + options. +- `SQLiteSynchronous`: `"normal" | "full"`, the `synchronous` level of a file + database. ## `solid-objects/database/sqlite-wasm` diff --git a/docs/configuration.md b/docs/configuration.md index 7a387a6..352ce42 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,11 +167,28 @@ IDs, while PostgreSQL callers should use `RETURNING` with `get()`. ### SQLite -`sqlite({ path, timeoutMilliseconds = 5_000, lockRetryAttempts = 10 })` uses -Node's built-in `node:sqlite`. `lockRetryAttempts` is a positive integer. The -timeout controls SQLite's busy timeout; transaction acquisition retries use +`sqlite({ path, timeoutMilliseconds = 5_000, lockRetryAttempts = 10, synchronous = "normal" })` +uses Node's built-in `node:sqlite`. `lockRetryAttempts` is a positive integer. +The timeout controls SQLite's busy timeout; transaction acquisition retries use short capped backoff only when no synchronous deadline is active. +A file database uses write-ahead logging (WAL). On its first access, the +adapter sets `journal_mode = WAL` and the `synchronous` level, with the same +retries as a transaction. WAL keeps two more files next to the database: +`-wal` and `-shm`. Copy the three files together, or close the +database first: a clean close writes the WAL into the database file. An +in-memory database (`":memory:"`) keeps its memory journal. + +`synchronous` is `"normal"` or `"full"`: + +- `"normal"`, the default, is the level that the Ruby gem gets from Active + Record. A commit makes almost no `fsync` call. A process crash loses no + commit. A power loss or an operating system crash can lose the last commits, + but the database stays consistent. +- `"full"` flushes each commit to disk before the commit returns. A commit + costs about one `fsync` call, and `node:sqlite` blocks the event loop while + it waits for the disk. + ### PostgreSQL `postgresql(options)` accepts: diff --git a/docs/parity.md b/docs/parity.md index 55dd6ce..5b83e2d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -101,6 +101,7 @@ Ruby publishes RBS contracts with native Ruby field names; this does not change | Capability | Status | TypeScript shape or remaining work | | --------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. Ruby uses a yielding busy handler for background writes so a waiting thread lets the lock holder commit, including on Rails 7.1/7.2; both preserve synchronous deadlines. | +| SQLite durability settings | Native | File databases use WAL with synchronous NORMAL in both runtimes. Ruby gets them from Active Record; the Node adapter sets them on first access, and `synchronous: "full"` is optional. | | PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. | | MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. | | Durable polling fallback | Native | Every role progresses without a notification service. Effects, reminders, and broadcasts use canonical ordered polling indexes; PostgreSQL and MySQL lock only the selected row with `FOR UPDATE SKIP LOCKED`. Broadcasts and reminders compare separate available and stale-recovery probes, preserve the oldest-first choice, and retry past candidates locked by another claimant. | From abeca2f5dae3560e3107459da1388ed22b225212 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:31:33 -0700 Subject: [PATCH 8/9] docs: give a safe way to back up a WAL database Review found that closing one adapter does not write the WAL into the database file while another connection still has it open, and that a copy of three live files is not a consistent snapshot. The guide now points to VACUUM INTO or the SQLite backup command for a database in use. The validation test also casts its bad value without unknown, as the repository rule asks. --- docs/configuration.md | 8 +++++--- test/sqlite.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 352ce42..f2d3fbd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,9 +175,11 @@ short capped backoff only when no synchronous deadline is active. A file database uses write-ahead logging (WAL). On its first access, the adapter sets `journal_mode = WAL` and the `synchronous` level, with the same retries as a transaction. WAL keeps two more files next to the database: -`-wal` and `-shm`. Copy the three files together, or close the -database first: a clean close writes the WAL into the database file. An -in-memory database (`":memory:"`) keeps its memory journal. +`-wal` and `-shm`. Committed data can be in the `-wal` file, so a +copy of the database file alone can miss it. To back up a database that is in +use, run `VACUUM INTO ''` or use the SQLite backup command. Copy +the files only when no process has the database open. An in-memory database +(`":memory:"`) keeps its memory journal. `synchronous` is `"normal"` or `"full"`: diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index 78d436e..bfe2888 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -73,7 +73,7 @@ describe("SQLite adapter", () => { it("rejects a synchronous level other than normal or full", () => { expect(() => - sqlite({ path: ":memory:", synchronous: "off" as unknown as SQLiteSynchronous }), + sqlite({ path: ":memory:", synchronous: "off" as SQLiteSynchronous }), ).toThrow(new TypeError('synchronous must be "normal" or "full"')) }) From 29f1d3ad48fc9f14aa1c01eab18b4afffc4b8ab8 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 18 Oct 2026 21:31:44 -0700 Subject: [PATCH 9/9] style: format the SQLite validation test --- test/sqlite.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index bfe2888..5c4e793 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -72,9 +72,9 @@ describe("SQLite adapter", () => { }) it("rejects a synchronous level other than normal or full", () => { - expect(() => - sqlite({ path: ":memory:", synchronous: "off" as SQLiteSynchronous }), - ).toThrow(new TypeError('synchronous must be "normal" or "full"')) + expect(() => sqlite({ path: ":memory:", synchronous: "off" as SQLiteSynchronous })).toThrow( + new TypeError('synchronous must be "normal" or "full"'), + ) }) it("validates its transaction retry budget", () => {