browser-sqlite 1.0.0-rc.4 → 1.0.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  A persistent SQLite database that lives in your browser — yes, for real. Powered by [wa-sqlite](https://github.com/rhashimoto/wa-sqlite) (WebAssembly), built for (read) concurrency.
4
4
 
5
- **▶ [Run the benchmarks in your own browser](https://lalexdotcom.github.io/browser-sqlite/)** every
6
- VFS this library ships, put through the same conformance checks and measurements, on your device.
7
- It is the honest way to choose one: which VFS wins depends on the engine, and it changes often —
8
- a single browser release can move the answer.
5
+ A client stores its data through a wa-sqlite VFS, which decides where that data lives.
6
+ **[Read the VFS page](VFS.md)** to compare them, then
7
+ **[run the benchmarks in your own browser](https://lalexdotcom.github.io/browser-sqlite/)**.
9
8
 
10
9
  ## Install
11
10
 
@@ -17,7 +16,8 @@ pnpm add browser-sqlite
17
16
 
18
17
  Requires a bundler that supports Web Workers with dynamic imports — or no bundler at all.
19
18
 
20
- ## Bundler Configuration
19
+ <details>
20
+ <summary><b>Bundler Configuration</b></summary>
21
21
 
22
22
  Works with no configuration under **rsbuild 1+**, **rspack 1+**, **Parcel 2+**, **Vite 8+**, **webpack 5.101+** — and with no bundler at all.
23
23
 
@@ -32,254 +32,8 @@ export default defineConfig({
32
32
 
33
33
  Another bundler will likely work — the worker and its `.wasm` are reached through plain, statically analysable URLs — but may need configuration of its own.
34
34
 
35
- The `.wasm` are read from beside `worker.js`. If a build separates them, or you move them by hand, point at them with [`wasmUrl`](#options).
36
-
37
- ## Usage
38
-
39
- [createSQLiteClient](#createsqliteclient) · [*client*.read](#clientread) · [*client*.write](#clientwrite) · [*client*.stream](#clientstream) · [*client*.chunk](#clientchunk) · [*client*.first](#clientfirst) · [*client*.transaction](#clienttransaction) · [*client*.bulkWrite](#clientbulkwrite) · [*client*.output](#clientoutput) · [*client*.close](#clientclose) · [deleteDatabase](#deletedatabase)
40
-
41
- ### createSQLiteClient
42
-
43
- ```typescript
44
- import { createSQLiteClient } from 'browser-sqlite';
45
-
46
- const db = createSQLiteClient('myapp.sqlite', {
47
- poolSize: 2, // number of worker threads (default: 2)
48
- vfs: 'OPFSAdaptiveVFS', // required — see VFS Selection
49
- build: 'async', // wa-sqlite build (default: the VFS's first)
50
- pragmas: { // SQLite PRAGMAs applied on open
51
- journal_mode: 'WAL',
52
- synchronous: 'NORMAL',
53
- },
54
- });
55
- ```
56
-
57
- `createSQLiteClient` spawns `poolSize` Web Worker threads immediately. Workers reach READY state asynchronously — queries made before workers are ready are queued automatically.
58
-
59
- Every option is listed under [Options](#options). `vfs` is the one with no default — [VFS Selection](#vfs-selection) is how to choose it, and a database written through one VFS is not readable through another.
60
-
61
- ### *client*.read
62
-
63
- ```typescript
64
- type User = { id: number; name: string };
65
-
66
- const users = await db.read<User>(
67
- 'SELECT id, name FROM users WHERE active = ?',
68
- [1],
69
- );
70
- // users: User[]
71
- ```
72
-
73
- Read queries are dispatched to any available worker, enabling concurrent reads.
74
-
75
- | Option | Type | Default | Description |
76
- |---|---|---|---|
77
- | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
78
- | `chunkSize` | `number` | `500` | Rows per chunk crossing the worker boundary. Back-pressure grants credits per chunk with a window of 2, so the worker may run up to `2 × chunkSize` rows ahead of the consumer. |
79
-
80
- On `read()` this is transport only — it still resolves with the whole array.
81
-
82
- ### *client*.write
83
-
84
- ```typescript
85
- const { affected } = await db.write(
86
- 'INSERT INTO users (name, email) VALUES (?, ?)',
87
- ['Alice', 'alice@example.com'],
88
- );
89
- // affected: number of rows inserted
90
- ```
91
-
92
- Write queries are serialized through a dedicated writer worker — only one write executes at a time.
93
-
94
- | Option | Type | Default | Description |
95
- |---|---|---|---|
96
- | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
97
-
98
- ### *client*.stream
99
-
100
- ```typescript
101
- // Worker is held for the full generator lifetime — always exhaust or break.
102
- for await (const row of db.stream<User>('SELECT * FROM large_table', [])) {
103
- processRow(row); // row is User
104
- }
105
- ```
106
-
107
- `stream()` yields individual rows without buffering the full result set in memory.
108
- Use `chunk()` to iterate in batches: `for await (const rows of db.chunk(...))`.
109
-
110
- | Option | Type | Default | Description |
111
- |---|---|---|---|
112
- | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
113
- | `chunkSize` | `number` | `500` | Rows per chunk crossing the worker boundary. Back-pressure grants credits per chunk with a window of 2, so the worker may run up to `2 × chunkSize` rows ahead of the consumer. |
114
-
115
- On `stream()`, `chunkSize` is the only lever on how many rows are in flight.
116
-
117
- ### *client*.chunk
118
-
119
- ```typescript
120
- // Worker is held for the full generator lifetime — always exhaust or break.
121
- for await (const rows of db.chunk<User>('SELECT * FROM large_table', [])) {
122
- processBatch(rows); // rows is User[]
123
- }
124
- ```
125
-
126
- `chunk()` yields arrays instead of rows. Prefer it over `stream()` when the work
127
- is per-batch — one `INSERT` per chunk rather than per row.
128
-
129
- | Option | Type | Default | Description |
130
- |---|---|---|---|
131
- | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
132
- | `chunkSize` | `number` | `500` | Rows per chunk crossing the worker boundary. Back-pressure grants credits per chunk with a window of 2, so the worker may run up to `2 × chunkSize` rows ahead of the consumer. |
133
-
134
- Here `chunkSize` is the batch size the consumer sees, not only a transport detail.
135
-
136
- ### *client*.first
137
-
138
- ```typescript
139
- const user = await db.first<User>(
140
- 'SELECT * FROM users WHERE id = ?',
141
- [42],
142
- );
143
- // user: User | undefined
144
- ```
145
-
146
- `first()` returns the first result row, or `undefined` if no rows match. Use it for lookups by primary key or unique field.
147
-
148
- | Option | Type | Default | Description |
149
- |---|---|---|---|
150
- | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
151
-
152
- `first()` stops the query after one row instead of draining the result set.
153
-
154
- ### *client*.transaction
155
-
156
- ```typescript
157
- const orders = await db.transaction(async (tx) => {
158
- await tx.write('INSERT INTO orders (id, total) VALUES (?, ?)', [1, 42]);
159
- await tx.write('UPDATE stock SET qty = qty - 1 WHERE id = ?', [7]);
160
- const rows = await tx.read<{ n: number }>('SELECT count(*) AS n FROM orders');
161
- return rows[0].n;
162
- });
163
- ```
164
-
165
- One worker is held for the callback's whole lifetime, so nothing else can run on
166
- it: the transaction is genuinely isolated, not merely wrapped in `BEGIN`.
167
- Returning commits, throwing rolls back and re-throws. `{ readOnly: true }`
168
- rejects write statements; `{ autoCommit: false }` leaves the commit to you.
169
-
170
- `tx` carries the same querying surface as the client — `read`, `write`, `chunk`, `stream`, `first`, `bulkWrite`, `output` — plus `commit` and `rollback`.
171
-
172
- `{ signal }` abandons the transaction at any point, including while it waits for a worker and while your callback sits on something that is not a statement. It rolls back and rejects with `signal.reason`, and it never commits — a callback that catches its own statement's rejection cannot commit around the abort. Your callback is not interrupted, but every statement it issues afterwards rejects. `BEGIN`, `COMMIT` and `ROLLBACK` are the exception: they do not carry the signal, so an abort raised while one of them is in flight lands when it settles.
173
-
174
- | Option | Type | Default | Description |
175
- |---|---|---|---|
176
- | `readOnly` | `boolean` | `false` | Rejects write statements with `READ_ONLY_TRANSACTION`, at the call rather than at the first flush. |
177
- | `autoCommit` | `boolean` | `true` | Commits when the callback resolves. Set it false to commit or roll back yourself. |
178
- | `signal` | `AbortSignal` | — | Abandons the transaction. Rolls back and rejects with `signal.reason`; never commits. |
179
-
180
- ### *client*.bulkWrite
181
-
182
- ```typescript
183
- const rows = db.bulkWrite('events', ['id', 'kind', 'at']);
184
- for (const event of events) rows.enqueue(event);
185
- const affected = await rows.close();
186
- ```
187
-
188
- Batches inserts to stay under SQLite's variable limit (`SQLITE_MAX_VARS`,
189
- 32 766), flushing whenever the next row would cross it. `close()` flushes the
190
- remainder and resolves with the total number of rows written.
191
-
192
- Single-use: `enqueue()` and `close()` throw once closed. A batch that fails
193
- rejects with a `SQLiteBulkWriteError` carrying `rowsWritten` and `rowsNotWritten` — a
194
- multi-row INSERT is statement-atomic, so the failing batch wrote nothing.
195
-
196
- `bulkWrite()` is not atomic: batches are committed as they flush, so a failure leaves the rows already written in place. Call it on a `tx` if you need all-or-nothing.
197
-
198
- Pass `{ signal }` to abort a load. `close()` then rejects with `signal.reason`, and the abort lands **between** batches — never inside one, because a multi-row INSERT is statement-atomic. The batches already written stay written, for the same reason a failure leaves them: an abort stops the load, it does not undo it.
199
-
200
- Await `enqueue()` to be slowed to the speed of the database. It resolves immediately while fewer than `queueSize` rows are queued for writing, and only defers beyond that — so a producer that awaits every row never holds more than that many unwritten rows. Ignoring the returned promise is legal and loads exactly as before: the bound is an offer, not a guarantee, and only you can take it. `queueSize` counts rows, not bytes: if your columns carry blobs, set it yourself.
201
-
202
- | Option | Type | Default | Description |
203
- |---|---|---|---|
204
- | `signal` | `AbortSignal` | — | Aborts the load between batches. `close()` rejects with `signal.reason`. |
205
- | `queueSize` | `number` | 2 batches | Rows queued for writing above which `enqueue()` defers. A batch is `floor(32766 / columns)` rows. |
206
-
207
- ### *client*.output
208
-
209
- ```typescript
210
- const out = db.output(
211
- 'products',
212
- { id: 'INTEGER', name: 'TEXT', price: { type: 'REAL', required: true } },
213
- { indexes: ['name', { columns: ['name', 'price'], unique: true }] },
214
- );
215
- out.enqueue({ id: 1, name: 'widget', price: 9.99 });
216
- const affected = await out.close();
217
- ```
218
-
219
- Builds a table from a schema declaration and populates it. Rows land in a
220
- staging table and the swap happens atomically at `close()`, so **the previous
221
- table stays intact and fully populated until the new one is ready** — a reader
222
- querying mid-load sees the old data, never a half-filled table. A target that
223
- did not exist appears only at `close()`. Single-use, like `bulkWrite`.
224
-
225
- `output()` takes `{ signal }` too, and an aborted one is observationally a no-op: the staging table is dropped and nothing else is touched. No rename, no partial publication — whatever was in the target before is still there, whole.
226
-
227
- | Option | Type | Default | Description |
228
- |---|---|---|---|
229
- | `indexes` | `Index[]` | — | Indexes built after the swap, under their final names. A column name, an array of them, or `{ columns, unique }`. |
230
- | `signal` | `AbortSignal` | — | Aborts the load between batches. `close()` rejects with `signal.reason` and the target is untouched. |
231
- | `queueSize` | `number` | 2 batches | Rows queued for writing above which `enqueue()` defers. A batch is `floor(32766 / columns)` rows. |
232
-
233
- **Inside a transaction, `output()` costs more than it looks.** On its own it loads rows outside any transaction and holds the write lock only for the final swap. Called on a `tx`, the entire load runs inside your transaction — every other write, in this tab and in others, waits for it to finish.
234
-
235
- ### *client*.close
236
-
237
- ```typescript
238
- await db.close();
239
- ```
240
-
241
- Drains in-flight work, rejects queued work, closes each database connection, then terminates all workers. The returned promise settles once every worker has closed and been terminated, or once `drainTimeout` has elapsed. Calling `close()` a second time returns the same promise — the operation runs exactly once.
242
-
243
- **Stored data is not deleted.** `close()` releases workers and connections; it removes nothing. To remove the database itself, use [`deleteDatabase`](#deletedatabase).
244
-
245
- ### deleteDatabase
246
-
247
- Removes a database and the `-journal` / `-wal` files SQLite may have left beside it. The database must not be open, in this tab or any other.
248
-
249
- ```typescript
250
- import { deleteDatabase } from 'browser-sqlite';
251
-
252
- await deleteDatabase('myapp.sqlite', { vfs: 'OPFSAdaptiveVFS' });
253
- ```
254
-
255
- `vfs` is required and must be the VFS the database was created with: a database written through one VFS is not visible through another, so deleting through the wrong one deletes nothing and reports success. `build` and `wasmUrl` are accepted with the same meaning as on `createSQLiteClient`.
256
-
257
- Deleting a database that does not exist is not an error.
258
-
259
- What a VFS keeps for itself is left alone — the IndexedDB store shared by every database that VFS holds on this origin, and the `AccessHandlePoolVFS` directory whose files are its reusable capacity. The deleted database's own bytes are freed in both cases.
260
-
261
- Throws `SQLiteError` with code `BUSY` when the database is open or being opened, and `TIMEOUT` when the VFS cannot answer within 30 seconds — most often the same cause.
262
-
263
- | Option | Type | Default | Description |
264
- |---|---|---|---|
265
- | `vfs` | `SQLiteVFS` | — (required) | The VFS the database was created with. Deleting through another one deletes nothing and reports success. |
266
- | `build` | `SQLiteBuild` | first build the VFS declares | Which wa-sqlite build to load. It does not affect where the database lives — only which builds can instantiate the VFS. |
267
- | `wasmUrl` | `string \| ((build: SQLiteBuild) => string)` | `undefined` | Same meaning as on [`createSQLiteClient`](#options). A deployment that needs it to open a database needs it to delete one. |
268
-
269
- ## Options
270
-
271
- | Option | Type | Default | Description |
272
- |--------|------|---------|-------------|
273
- | `poolSize` | `number` | `2` | Number of Web Workers spawned in the pool. A larger pool allows more concurrent reads but uses more memory. Must be `1` with `AccessHandlePoolVFS`. |
274
- | `vfs` | `SQLiteVFS` | — (required) | VFS implementation for storage. See the [VFS Selection](#vfs-selection) table. |
275
- | `build` | `SQLiteBuild` | first build the VFS declares | Which wa-sqlite WebAssembly build to load: `'sync'`, `'async'`, or `'jspi'`. Throws `INVALID_OPTION` at construction if the VFS does not support it. See [Builds](#builds). |
276
- | `wasmUrl` | `string \| ((build: SQLiteBuild) => string)` | `undefined` | Where the workers fetch their `.wasm`. Omit it and resolution is unchanged: the files are read from beside `worker.js`. A string is a directory resolved against the page — relative, absolute or a full URL, trailing slash optional. A callback receives the resolved `build` and names one file, for a bundler-emitted asset carrying a content hash. Called once, at construction. Throws `INVALID_OPTION` there if the value is not a URL. Another origin needs CORS and `Content-Type: application/wasm`. |
277
- | `pragmas` | `Record<string, string>` | `undefined` | SQLite PRAGMAs applied to each worker connection on open. |
278
- | `maxWorkerRestarts` | `number` | `1` | How many times a slot may be restarted after it dies. The counter resets once a replacement has actually served a request. A slot that fails to open is retried once, but only if another worker did open — when none did, the failure is a configuration error and the client fails immediately rather than retrying. |
279
- | `openTimeout` | `number` (ms) | `30_000` | How long a worker has to post `ready` after `open` is sent. On expiry the slot is failed — the most common cause is a database held under an exclusive lock by another tab. |
280
- | `drainTimeout` | `number` (ms) | `60_000` | How long the drain loop may run in the query generator's `finally` before the worker is presumed dead and the crash path is invoked. |
281
- | `debug` | `string \| boolean` | `undefined` | Enables lifecycle logging. A string value is used as the log prefix; `true` falls back to the client prefix (e.g. `"SQLite 1"`). Only lifecycle events are logged — worker created, ready, open-error, crash, restart, worker lost, close, and skipped staging sweep. No line per query. Off by default, with one exception: a permanently lost worker always warns, because a pool quietly smaller than `poolSize` is not something to discover later. When enabled, `db.debug` also exposes a live introspection state tree for query throughput and worker status. |
282
- | `onWorkerLost` | `(event: WorkerLostEvent) => void` | `undefined` | Called when a worker is lost for good, with the slot index, how many workers are left, the requested `poolSize`, and the error. Fires before the client fails if it was the last one. A throwing callback is caught and warned about; it cannot break the pool. |
35
+ The `.wasm` are read from beside `worker.js`. If a build separates them, or you move them by hand, point at them with [`wasmUrl`](API.md#options).
36
+ </details>
283
37
 
284
38
  ## Browser support
285
39
 
@@ -287,212 +41,94 @@ Throws `SQLiteError` with code `BUSY` when the database is open or being opened,
287
41
  |---|---|---|
288
42
  | 92+ | 95+ | 15.4+ |
289
43
 
290
- ## VFS Selection
291
-
292
- browser-sqlite delegates storage to a
293
- [wa-sqlite Virtual File System](https://github.com/rhashimoto/wa-sqlite/tree/master/src/examples#readme)
294
- (VFS).
295
-
296
- **`vfs` is required — there is no default.** A VFS decides *where* your database
297
- is written, so a default that moved between versions would leave you reading an
298
- empty database while your bytes sat in a store nothing queries.
299
-
300
- **Pass `OPFSAdaptiveVFS` unless you have a reason not to.** Across every engine we
301
- could test — Chrome, Firefox and Safari, desktop and mobile — it opened and passed
302
- every conformance check without exception. It is the only VFS here of which that is
303
- true.
304
-
305
- > **Each VFS is a separate store.** A database written through one VFS is not
306
- > visible through another — the bytes are still there, but nothing reads them.
307
- > Changing `vfs` later does not migrate anything.
44
+ Cross-origin isolation is worth adding where you control your headers: it is what lets an
45
+ aborted call stop a running statement when using a VFS with `sync` build. See
46
+ [Aborting a call](#aborting-a-call).
308
47
 
309
- You would leave that choice when you control which browser runs your code — an
310
- Electron app, a kiosk, a managed fleet — and need something it cannot give you:
311
-
312
- | Browser you can guarantee | Concurrent reads | Write-heavy workloads |
313
- |---|---|---|
314
- | None — the open web | `OPFSAnyContextVFS` if you can require Safari 26+; otherwise `IDBBatchAtomicVFS` | stay on `OPFSAdaptiveVFS` |
315
- | Chromium 121+ | already the case | `OPFSWriteAheadVFS` |
316
- | Firefox 111+ | `OPFSAnyContextVFS` | stay |
317
- | Safari 26+ / iPadOS 26+ | `OPFSAnyContextVFS` | stay |
318
- | iOS (iPhone) | none measured to help | stay |
319
-
320
- **Concurrent reads** covers both serving a read while a write transaction is open
321
- and running several reads at once under a pool: a VFS holding one exclusive
322
- access handle can do neither, because it is the same handle a second worker never
323
- gets. For how much any of this is worth on your own targets, run
324
- [the benchmark page](https://lalexdotcom.github.io/browser-sqlite/) — no timings
325
- appear in this file.
326
-
327
- <!-- BEGIN GENERATED VFS TABLE — edit VFS_CAPABILITIES in src/types.ts, then run `pnpm docs:vfs` -->
328
-
329
- | VFS | Builds | Browser compatibility | Pool size | Shared between connections | Survives close | Memory |
330
- |-----|--------|-----------------------|-----------|----------------------------|----------------|--------|
331
- | `OPFSAdaptiveVFS` **(recommended)** | [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 111+/153+ [(*)](#-reduced-mode)<br>Safari 15.4+/27+ [(*)](#-reduced-mode)<br>Android 109+/?<br>iOS 15.4+/27+ [(*)](#-reduced-mode) | Any | Yes | Yes | Page cache only, bounded by `PRAGMA cache_size` |
332
- | `OPFSWriteAheadVFS` | [`sync`](#build-sync), [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 111+/153+ [(*)](#-reduced-mode)<br>Safari 15.4+/27+ [(*)](#-reduced-mode)<br>Android 109+/?<br>iOS 15.4+/27+ [(*)](#-reduced-mode) | Any | Yes | Yes | Page cache only, bounded by `PRAGMA cache_size` |
333
- | `OPFSCoopSyncVFS` | [`sync`](#build-sync), [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 111+/153+<br>Safari 15.4+/27+<br>Android 109+/?<br>iOS 15.4+/27+ | Any | Yes | Yes | Page cache only, bounded by `PRAGMA cache_size` |
334
- | `AccessHandlePoolVFS` | [`sync`](#build-sync), [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 111+/153+<br>Safari 15.4+/27+<br>Android 109+/?<br>iOS 15.4+/27+ | **1** — it cannot share access handles between connections | No | Yes | Page cache only, bounded by `PRAGMA cache_size` |
335
- | `IDBBatchAtomicVFS` | [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 95+/153+<br>Safari 15.4+/27+<br>Android 92+/?<br>iOS 15.4+/27+ | Any | Yes | Yes | Page cache only, bounded by `PRAGMA cache_size` |
336
- | `IDBMirrorVFS` | [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 95+/153+<br>Safari 15.4+/27+<br>Android 92+/?<br>iOS 15.4+/27+ | **1** — its pages are mirrored per worker and commits propagate asynchronously, so a larger pool reads stale data or fails outright | No | Yes | **Whole database in RAM**, multiplied by `poolSize` |
337
- | `OPFSAnyContextVFS` | [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 111+/153+<br>Safari 26+/27+<br>Android 109+/?<br>iOS 26+/27+ | Any | Yes | Yes | Page cache only, bounded by `PRAGMA cache_size` |
338
- | `MemoryVFS` | [`sync`](#build-sync), [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 95+/153+<br>Safari 15.4+/27+<br>Android 92+/?<br>iOS 15.4+/27+ | **1** — its pages live in the worker that opened them, so a larger pool would open independent databases that diverge silently | No | **No — volatile** | **Whole database in RAM**, multiplied by `poolSize` |
339
- | `MemoryAsyncVFS` | [`async`](#build-async), [`jspi`](#build-jspi) | Chrome 92+/137+<br>Firefox 95+/153+<br>Safari 15.4+/27+<br>Android 92+/?<br>iOS 15.4+/27+ | **1** — its pages live in the worker that opened them, so a larger pool would open independent databases that diverge silently | No | **No — volatile** | **Whole database in RAM**, multiplied by `poolSize` |
340
-
341
- <!-- END GENERATED VFS TABLE -->
342
-
343
- The **Browser compatibility** column is derived from documented platform support,
344
- not from our own test runs. It covers where the VFS stores data; which **builds**
345
- are reachable on each engine is a separate question, answered under
346
- [Builds](#builds) — the `Builds` column links straight to the build it names.
347
-
348
- #### (*) Reduced mode
349
-
350
- The VFS runs on that engine, but without `readwrite-unsafe` access handles: one
351
- exclusive handle rotated between workers instead of one held per connection. It
352
- is not a partial failure — `OPFSAdaptiveVFS` passes 102 of 104 browser tests on
353
- Firefox in exactly that mode.
354
-
355
- What it costs is pool concurrency under one specific shape. **On an engine
356
- without `readwrite-unsafe`, a VFS that rotates a single exclusive OPFS access
357
- handle cannot serve any other worker while a write transaction holds that
358
- handle** — the worker that took it does not give it back before the transaction
359
- ends, and the next acquisition blocks in the scheduler, before an `AbortSignal`
360
- is ever consulted. That covers `OPFSAdaptiveVFS` in reduced mode.
361
- `IDBMirrorVFS`, `OPFSAnyContextVFS` and `IDBBatchAtomicVFS` hold no such handle
362
- and are unaffected.
363
-
364
- `OPFSCoopSyncVFS` has the same symptom for a different reason, and it is **not**
365
- conditional on the engine — it never uses `readwrite-unsafe`, so it is never in
366
- reduced mode. See [Known Limitations](#known-limitations).
367
-
368
- **A long *read* does not produce this effect, except once per worker after a
369
- write.**
370
-
371
- #### `OPFSAnyContextVFS` and wa-sqlite
372
-
373
- This VFS needs a patched wa-sqlite to work on Safari. browser-sqlite ships that
374
- patch inside its own worker bundle — there is nothing for you to install or
375
- configure.
376
-
377
- ### Builds
378
-
379
- Each VFS runs on one or more wa-sqlite WebAssembly builds. The `build` option
380
- selects one; omitted, the first build the VFS declares is used — `async` for the
381
- default VFS. A pair the VFS does not support throws a `SQLiteError` with code
382
- `INVALID_OPTION` at construction, naming the builds it does support. The pairing
383
- is declared in one place, `VFS_CAPABILITIES`, which is also what the `SQLiteVFS`
384
- type is derived from.
385
-
386
- A build carries its own engine requirement, independent of where the VFS stores
387
- data — so a VFS can be reachable in `sync` on an old browser and in `jspi` only
388
- on a much newer one.
48
+ ## Usage
389
49
 
390
- <!-- BEGIN GENERATED BUILD TABLE edit FEATURE_SUPPORT in scripts/render-vfs-matrix.ts -->
50
+ Read the [detailed API documentation](API.md) for the full description.
391
51
 
392
- #### Build `sync`
52
+ ```typescript
53
+ import { createSQLiteClient } from 'browser-sqlite';
393
54
 
394
- | Chrome / Edge | Firefox | Safari | Chrome Android | Safari iOS |
395
- |---|---|---|---|---|
396
- | Any | Any | Any | Any | Any |
55
+ const db = createSQLiteClient('myapp.sqlite', { vfs: 'OPFSAdaptiveVFS' });
397
56
 
398
- Plain synchronous WebAssembly. Needs nothing beyond baseline WASM, so it runs anywhere but only VFS whose file operations are all synchronous can offer it.
57
+ await db.write('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)');
58
+ await db.write('INSERT INTO users (name) VALUES (?)', ['Alice']);
399
59
 
400
- #### Build `async`
60
+ const users = await db.read<{ id: number; name: string }>('SELECT id, name FROM users');
401
61
 
402
- | Chrome / Edge | Firefox | Safari | Chrome Android | Safari iOS |
403
- |---|---|---|---|---|
404
- | Any | Any | Any | Any | Any |
62
+ for await (const row of db.stream<{ id: number; name: string }>('SELECT * FROM users')) {
63
+ process(row);
64
+ }
405
65
 
406
- Asyncify: the WASM stack is unwound and rewound around asynchronous file operations. Also needs nothing beyond baseline WASM. This is the default, and every VFS here can run on it.
66
+ await db.close();
67
+ ```
407
68
 
408
- #### Build `jspi`
69
+ [*client*.id](API.md#clientid) · [*client*.name](API.md#clientname) · [*client*.file](API.md#clientfile) · [*client*.vfs](API.md#clientvfs) · [*client*.build](API.md#clientbuild) · [*client*.poolSize](API.md#clientpoolsize)
409
70
 
410
- | Chrome / Edge | Firefox | Safari | Chrome Android | Safari iOS |
411
- |---|---|---|---|---|
412
- | 137+ | 153+ | 27+ | Yes | 27+ |
71
+ [createSQLiteClient()](API.md#createsqliteclient) · [*client*.read()](API.md#clientread) · [*client*.write()](API.md#clientwrite) · [*client*.stream()](API.md#clientstream) · [*client*.chunk()](API.md#clientchunk) · [*client*.first()](API.md#clientfirst) · [*client*.transaction()](API.md#clienttransaction) · [*client*.bulkWrite()](API.md#clientbulkwrite) · [*client*.output()](API.md#clientoutput) · [*client*.inspect()](API.md#clientinspect) · [*client*.close()](API.md#clientclose) · [deleteDatabase()](API.md#deletedatabase) · [inspectDatabase()](API.md#inspectdatabase)
413
72
 
414
- JavaScript Promise Integration — the same asynchrony handled by the engine rather than by Asyncify. Opt-in, and no default uses it, so its narrower availability constrains nobody who does not ask for it.
73
+ ## Storage
415
74
 
75
+ The VFS decides *where* your database is written.
76
+ [See every available VFS on the dedicated page](VFS.md), with their pros, their cons, their
77
+ limitations and their browser compatibility.
416
78
 
417
- <!-- END GENERATED BUILD TABLE -->
79
+ `OPFSWriteAheadVFS` and `OPFSAdaptiveVFS` are the recommended options.
418
80
 
419
- ## Error handling
81
+ [`OPFSWriteAheadVFS`](VFS.md#opfswriteaheadvfs) · [`OPFSAdaptiveVFS`](VFS.md#opfsadaptivevfs) · [`OPFSCoopSyncVFS`](VFS.md#opfscoopsyncvfs) · [`AccessHandlePoolVFS`](VFS.md#accesshandlepoolvfs) · [`IDBBatchAtomicVFS`](VFS.md#idbbatchatomicvfs) · [`IDBMirrorVFS`](VFS.md#idbmirrorvfs) · [`OPFSAnyContextVFS`](VFS.md#opfsanycontextvfs) · [`MemoryVFS`](VFS.md#memoryvfs) · [`MemoryAsyncVFS`](VFS.md#memoryasyncvfs)
420
82
 
421
- Errors raised by this library are instances of `SQLiteError`, exported from the package entry point. Discriminate on `error.code` or `error.name` — they carry the same value, so `err.name` reads the way `'AbortError'` does on a DOM `AbortError`.
83
+ ## Guarantees
422
84
 
423
- | Code | When it is thrown |
424
- |------|------------------|
425
- | `NOT_A_READ_QUERY` | `read()`, `chunk()`, `stream()`, or `first()` was called with a statement that is not a provably readable query. A bare read pragma (`PRAGMA journal_mode`) is accepted; a pragma that assigns a value or takes an argument must go through `write()`. |
426
- | `CLIENT_CLOSED` | A query was queued after `close()` was called. |
427
- | `WORKER_CRASHED` | A pool worker died and the supervisor decided not to restart it. All queued and in-flight work on that slot is rejected. |
428
- | `TIMEOUT` | A worker did not post `ready` within `openTimeout` milliseconds. The most common cause is a database held under an exclusive lock by another tab or client. |
429
- | `PROTOCOL_ERROR` | A message was received from a worker that could not be deserialized (`messageerror`). The worker survives; only the in-flight request is rejected. |
430
- | `BUSY` | SQLite reported a lock conflict (`SQLITE_BUSY` or `SQLITE_LOCKED`); the numeric SQLite code is on `sqliteCode`. The operation is not retried. |
431
- | `READ_ONLY_TRANSACTION` | raised when a write statement, `bulkWrite()` or `output()` is used inside a transaction opened with `readOnly: true`. |
85
+ ### Reads run concurrently
432
86
 
433
- ```typescript
434
- import { SQLiteError } from 'browser-sqlite';
435
-
436
- try {
437
- await db.write('...');
438
- } catch (err) {
439
- if (err instanceof SQLiteError) {
440
- switch (err.code) {
441
- case 'WORKER_CRASHED': /* restart or notify */ break;
442
- case 'CLIENT_CLOSED': /* client was shut down */ break;
443
- }
444
- }
445
- }
446
- ```
87
+ Every read is dispatched to whichever worker in the pool is free, so several run at once.
88
+ Writes take a dedicated writer worker instead, one at a time.
447
89
 
448
- **Request timeouts.** The library adds no per-request timeout. To bound a query, pass `AbortSignal.timeout(ms)`:
90
+ ### Read-your-own-writes
449
91
 
450
- ```typescript
451
- const rows = await db.read('SELECT * FROM large_table', [], {
452
- signal: AbortSignal.timeout(5_000),
453
- });
454
- ```
92
+ It holds within a tab and across tabs. Once a write has resolved, any read issued afterwards
93
+ observes it from that client, from any other client in the same tab, and from any other tab
94
+ on the same database, whatever the pool size. A worker that has not yet observed the latest
95
+ commit runs one discarded statement that opens a real read transaction before it serves the
96
+ query; that costs one extra worker round-trip on each worker's first statement after a write,
97
+ and nothing under read-only load. `poolSize: 1` and reading inside the same `transaction()`
98
+ remain valid, they are no longer required.
455
99
 
456
- **`close()` is async.** Always `await db.close()` the returned promise settles once every worker has closed its database connection and been terminated. Discarding the promise means the caller cannot tell when teardown is complete.
100
+ The one exception is [`IDBMirrorVFS`](VFS.md#idbmirrorvfs), which does not hold it across
101
+ tabs.
457
102
 
458
- **Read methods reject write statements.** `read()`, `chunk()`, `stream()`, and `first()` reject any statement that is not a provably readable query, throwing `NOT_A_READ_QUERY`. A bare read pragma (`PRAGMA journal_mode`) is accepted; a pragma that assigns a value or takes an argument must go through `write()`.
103
+ ### Writes are serialized
459
104
 
460
- **Read-your-own-writes is guaranteed within a tab.** Once a write has resolved,
461
- any read issued afterwards from that client or from any other client in the
462
- same tab on the same database observes it, whatever the pool size. A worker
463
- that has not yet observed the latest commit runs one discarded statement that
464
- opens a real read transaction before it serves the query; that costs one extra
465
- worker round-trip on each worker's first statement after a write, and nothing
466
- under read-only load. `poolSize: 1` and reading inside the same `transaction()`
467
- remain valid, they are no longer required.
105
+ A write, a write transaction, and each batch of a `bulkWrite` take one lock per database
106
+ across the whole origin, so a second writer **waits** rather than failing between clients
107
+ and between tabs alike. The wait is unbounded and first-come-first-served: pass a `signal` if
108
+ you would rather fail than wait. A write transaction holds that lock for the whole of its
109
+ callback, so a callback that never returns blocks every other writer in the origin, not only
110
+ its own client. **A `bulkWrite` takes the lock per batch and commits per batch**, so another
111
+ client's write can land between two of its batches use `tx.bulkWrite` where you need all or
112
+ nothing.
468
113
 
469
- **It is not guaranteed across tabs.** A write in one tab may not be visible to a
470
- read in another. No bound is claimed on how long that lasts.
114
+ ## Known Limitations
471
115
 
472
- **Nothing serializes writes between clients.** Two clients writing to one
473
- database concurrently can fail on a lock; the failure surfaces as
474
- `SQLiteError` with code `BUSY` and `sqliteCode` 5 or 6, and it is **not**
475
- retried — no `busy_timeout` is applied. This was true before the guarantee
476
- above existed; it matters now because the guarantee makes several clients on
477
- one database a reasonable thing to do.
116
+ Some VFS have limitations of their own see [the detailed VFS page](VFS.md#vfs-reference).
117
+ What follows holds on all of them.
478
118
 
479
- ## Requirements
119
+ ### Aborting a call
480
120
 
481
- browser-sqlite requires no special HTTP headers. OPFS access handles work in a plain worker context; cross-origin isolation is not needed. The default build needs no browser opt-in; only `build: 'jspi'` does, and that is an unrelated browser constraint, not a header requirement.
121
+ An abort does not always stop the work, and your hosting decides. On the `sync` build, a
122
+ `signal` or a `timeout` rejects your promise straight away, but the statement runs to its
123
+ end on its worker, which stays unavailable until it does.
482
124
 
483
- Note: the "Coop" in `OPFSCoopSyncVFS` stands for *cooperative*, not the `Cross-Origin-Opener-Policy` header.
125
+ Two ways out serving the page cross-origin isolated, or `build: 'async'` — and what each
126
+ one costs are under [Interrupting a call](API.md#interrupting-a-call).
484
127
 
485
- ## Known Limitations
128
+ ### Deleting a database
486
129
 
487
- - **`AccessHandlePoolVFS` requires `poolSize: 1`.** Passing `poolSize > 1` with this VFS throws synchronously at client creation time.
488
- - **`build: 'jspi'` is not available everywhere.** The [`jspi` build table](#build-jspi) carries the per-engine versions; it is generated, so it is the one place that stays current. The build is opt-in and no default uses it, so this constrains nobody who does not ask for it.
489
- - **`OPFSWriteAheadVFS` buys you nothing outside Chromium.** It opens access handles with `mode: 'readwrite-unsafe'`, which Firefox and Safari do not support — and which they ignore rather than reject, so it still works but falls back to the same reduced mode as `OPFSAdaptiveVFS` and serves no concurrent reads there. On **Safari 27 the `sync` build can also fail to reopen a database** — seen once in three runs, on macOS and on iPadOS. Use `OPFSAdaptiveVFS` outside Chromium.
490
- - **`OPFSCoopSyncVFS` does not read concurrently, and stalls unpredictably under a pool.** Unlike the other OPFS VFS it implements its own locking and silently ignores the `lockPolicy: 'shared'` this library constructs every VFS with, holding one *exclusive* access handle and rotating it between workers instead of one per connection. A read issued while a write transaction is open is **never served** — the pool acquisition blocks before any `AbortSignal` is consulted — where `IDBBatchAtomicVFS`, `IDBMirrorVFS` and `OPFSAnyContextVFS` serve it every time. A bulk insert either finishes promptly or **exceeds 30 seconds**, with no middle ground and no consistency across runs. None of this depends on `readwrite-unsafe`: unlike the reduced mode described above, it happens on Chromium too.
491
- - **Read-your-own-writes is guaranteed within a tab, not across tabs.** See the
492
- caveat under [Error handling](#error-handling).
493
- - **Two clients writing at once are not serialized, and what the loser gets depends on the VFS.** Nothing here orders writes between clients or between tabs — SQLite's own locking decides, and it decides differently in the two modes above. Where each connection holds its own access handle, the second writer is refused at once with `BUSY`, and retrying is the remedy; `BEGIN` is deferred, so both transactions open cleanly and it is the first write *inside* that fails. Where one exclusive handle is rotated instead, the second writer is not refused at all — it waits for the file as long as the first one holds it, then goes through. **Pass a `signal` and the two read alike**: an error inside a budget you chose, rather than a wait you did not. **A `bulkWrite` that is refused or abandoned leaves a partial load, not a failed one** — it commits per batch, so everything before is in the database, and one further batch may still land after you gave up: the one already handed to a worker, which no signal can recall. Use `tx.bulkWrite` where you need all or nothing.
494
- - **A database that is open cannot be deleted**, in this tab or another. `deleteDatabase` takes the same origin-wide lock a client takes while opening, which prevents an open from interleaving with a delete, and reports `BUSY` rather than deleting under a live connection. A connection that already holds its handles cannot be revoked from this library — close every client on the database first.
495
- - **`deleteDatabase` can time out outside Chromium**, on `OPFSWriteAheadVFS` and `OPFSCoopSyncVFS` — an observation rather than a measured rate. The call fails to settle rather than reporting an error; it has never reported success without deleting. Both VFS rotate a single exclusive OPFS access handle where `readwrite-unsafe` is unavailable, the same shape as the reduced mode described above.
130
+ A database that any client still holds cannot be deleted, in this tab or another, on every
131
+ VFS. More on the dedicated [`deleteDatabase`](API.md#deletedatabase) API entry.
496
132
 
497
133
  ## Development
498
134
 
@@ -0,0 +1,77 @@
1
+ import type { PoolWorker } from './pool';
2
+ /**
3
+ * Whether this cleanup has already run.
4
+ *
5
+ * A plain object rather than a boolean because the held value must observe a
6
+ * change the generator makes after registration. Three routes reach the
7
+ * cleanup — the registry, the abort listener and the generator's own `finally`
8
+ * — and whichever arrives first closes the door on the other two.
9
+ *
10
+ * What it deliberately does NOT record is whether the query ever started. That
11
+ * answers "did this generator run", where the only question that matters is
12
+ * "is the worker still serving this query" — and the worker is the one that
13
+ * knows, which is why `interrupt()` is asked rather than told.
14
+ */
15
+ export type AbandonState = {
16
+ done: boolean;
17
+ };
18
+ /**
19
+ * What the cleanup needs, and all it may hold.
20
+ *
21
+ * **It must never refer to the registered generator.** A `FinalizationRegistry`
22
+ * held value that reaches its own target keeps the target alive and the
23
+ * callback then never fires. Every field here points downward or sideways: the
24
+ * worker and the transport iterator are reachable from the pool anyway, `state`
25
+ * is a plain flag, `detach` closes over the caller's signal and its listener,
26
+ * and `release` is the owning layer's teardown.
27
+ */
28
+ export type Abandoned = {
29
+ worker: Pick<PoolWorker, 'interrupt'>;
30
+ iterator: {
31
+ return: (value?: undefined) => Promise<unknown>;
32
+ };
33
+ state: AbandonState;
34
+ /**
35
+ * Removes the abort listener that carries this very cleanup. Without it a
36
+ * listener stays armed on a signal the CALLER owns, long after the query it
37
+ * belonged to has ended — and fires against whatever the worker is doing
38
+ * then.
39
+ */
40
+ detach: () => void;
41
+ release?: (() => void) | undefined;
42
+ };
43
+ /**
44
+ * What the `finally` of `queries.chunk` would have done, for a generator that
45
+ * will never run it.
46
+ *
47
+ * The order is that `finally`'s and for its reason: `interrupt()` first, so the
48
+ * queued `return()` is not parked behind a `next()` that will not settle.
49
+ *
50
+ * **Nothing here may assume the worker is still ours.** This runs at a moment
51
+ * nobody chose — a collection, or the caller tidying up its own controller —
52
+ * and by then the worker may be serving a query that has nothing to do with
53
+ * this one. So the transport is named in both calls: `interrupt(iterator)` is a
54
+ * no-op unless the worker is still serving it, and `iterator.return()` resumes
55
+ * a transport whose own `finally` makes the same check. `return()` on a
56
+ * generator whose body never ran is a no-op besides, the body having never
57
+ * entered its `try`.
58
+ *
59
+ * `release` is the exception and runs unconditionally: it is the owning layer's
60
+ * resource — a lease, a timer, a merge teardown — and it is owed whatever the
61
+ * worker has since moved on to.
62
+ */
63
+ export declare const reclaim: ({ worker, iterator, state, detach, release, }: Abandoned) => void;
64
+ export type AbandonRegistry = {
65
+ /** Watch `target`; `held` is what the cleanup receives, `token` unregisters. */
66
+ watch: (target: object, held: Abandoned, token: object) => void;
67
+ /** The generator ended by an ordinary route — there is nothing to reclaim. */
68
+ forget: (token: object) => void;
69
+ };
70
+ /**
71
+ * `run` is injected so that tests drive the cleanup without a collection.
72
+ * Nothing else here is observable: a `FinalizationRegistry` fires when the
73
+ * engine decides, which is not a schedule a test can assert against.
74
+ */
75
+ export declare const createAbandonRegistry: (run?: (held: Abandoned) => void) => AbandonRegistry;
76
+ /** The one this library uses. */
77
+ export declare const abandonRegistry: AbandonRegistry;