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

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.
Files changed (42) hide show
  1. package/NOTICE +56 -0
  2. package/README.md +435 -63
  3. package/dist/LICENSE +21 -0
  4. package/dist/NOTICE +56 -0
  5. package/dist/api.d.ts +376 -0
  6. package/dist/bulk.d.ts +42 -0
  7. package/dist/capabilities.d.ts +23 -0
  8. package/dist/client.d.ts +198 -0
  9. package/dist/credits.d.ts +31 -0
  10. package/dist/{esm/src/debug.d.ts → debug.d.ts} +21 -10
  11. package/dist/delete.d.ts +42 -0
  12. package/dist/epochs.d.ts +55 -0
  13. package/dist/errors.d.ts +37 -0
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +5 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/locks.d.ts +54 -0
  18. package/dist/logger.d.ts +24 -0
  19. package/dist/pool.d.ts +98 -0
  20. package/dist/queries.d.ts +36 -0
  21. package/dist/scheduler.d.ts +131 -0
  22. package/dist/supervisor.d.ts +17 -0
  23. package/dist/transaction.d.ts +38 -0
  24. package/dist/types.d.ts +348 -0
  25. package/dist/utils.d.ts +116 -0
  26. package/dist/worker/cloneable.d.ts +25 -0
  27. package/dist/worker/statement-cache.d.ts +22 -0
  28. package/dist/worker/wa-sqlite-async.wasm +0 -0
  29. package/dist/worker/wa-sqlite-jspi.wasm +0 -0
  30. package/dist/worker/wa-sqlite.wasm +0 -0
  31. package/dist/worker/worker.js +11 -0
  32. package/dist/worker/worker.js.map +1 -0
  33. package/package.json +36 -20
  34. package/dist/esm/index.js +0 -424
  35. package/dist/esm/rslib.config.d.ts +0 -2
  36. package/dist/esm/rstest.config.d.ts +0 -2
  37. package/dist/esm/src/client.d.ts +0 -332
  38. package/dist/esm/src/index.d.ts +0 -1
  39. package/dist/esm/src/orchestrator.d.ts +0 -87
  40. package/dist/esm/src/types.d.ts +0 -83
  41. package/dist/esm/src/utils.d.ts +0 -6
  42. /package/dist/{esm/src → worker}/worker.d.ts +0 -0
package/NOTICE ADDED
@@ -0,0 +1,56 @@
1
+ THIRD-PARTY NOTICES
2
+ ===================
3
+
4
+ browser-sqlite is distributed under the MIT License; see LICENSE.
5
+
6
+ Its published worker artifact (dist/worker/worker.js) has third-party code
7
+ bundled into it, and ships compiled WebAssembly built from third-party
8
+ sources (dist/worker/wa-sqlite.wasm, wa-sqlite-async.wasm, wa-sqlite-jspi.wasm).
9
+ The notices below travel with those files and must be preserved in any
10
+ redistribution.
11
+
12
+
13
+ -------------------------------------------------------------------------------
14
+ wa-sqlite — https://github.com/rhashimoto/wa-sqlite
15
+ -------------------------------------------------------------------------------
16
+
17
+ The JavaScript glue and the VFS implementations bundled into
18
+ dist/worker/worker.js, and the .wasm binaries beside it, are produced by
19
+ wa-sqlite.
20
+
21
+ MIT License
22
+
23
+ Copyright (c) 2023 Roy T. Hashimoto
24
+
25
+ Permission is hereby granted, free of charge, to any person obtaining a copy
26
+ of this software and associated documentation files (the "Software"), to deal
27
+ in the Software without restriction, including without limitation the rights
28
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
29
+ copies of the Software, and to permit persons to whom the Software is
30
+ furnished to do so, subject to the following conditions:
31
+
32
+ The above copyright notice and this permission notice shall be included in all
33
+ copies or substantial portions of the Software.
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
36
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
37
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
38
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
39
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
40
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
41
+ SOFTWARE.
42
+
43
+
44
+ -------------------------------------------------------------------------------
45
+ SQLite — https://sqlite.org
46
+ -------------------------------------------------------------------------------
47
+
48
+ The .wasm binaries are builds of SQLite. SQLite is in the public domain and
49
+ requires no attribution; the customary blessing is reproduced here.
50
+
51
+ The author disclaims copyright to this source code. In place of
52
+ a legal notice, here is a blessing:
53
+
54
+ May you do good and not evil.
55
+ May you find forgiveness for yourself and forgive others.
56
+ May you share freely, never taking more than you give.
package/README.md CHANGED
@@ -2,6 +2,11 @@
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.
9
+
5
10
  ## Install
6
11
 
7
12
  ```bash
@@ -10,34 +15,38 @@ npm install browser-sqlite
10
15
  pnpm add browser-sqlite
11
16
  ```
12
17
 
13
- Requires a bundler that supports Web Workers with dynamic imports (Rsbuild, webpack 5, Vite 3+).
18
+ Requires a bundler that supports Web Workers with dynamic imports or no bundler at all.
14
19
 
15
- ## VFS Selection
20
+ ## Bundler Configuration
16
21
 
17
- browser-sqlite delegates storage to a wa-sqlite Virtual File System (VFS). Choose based on browser support and storage requirements:
22
+ Works with no configuration under **rsbuild 1+**, **rspack 1+**, **Parcel 2+**, **Vite 8+**, **webpack 5.101+** and with no bundler at all.
18
23
 
19
- | VFS | Storage | Constraint | When to use |
20
- |-----|---------|------------|-------------|
21
- | `OPFSPermutedVFS` **(default)** | OPFS | None — supports `poolSize >= 1` | General purpose. Best choice for most applications. |
22
- | `OPFSAdaptiveVFS` | OPFS | Requires JSPI (Chromium 126+) | When JSPI is available and adaptive sync strategy is desired. |
23
- | `OPFSCoopSyncVFS` | OPFS | None — cooperative sync, no JSPI required | Broader browser compatibility fallback when JSPI is unavailable. |
24
- | `AccessHandlePoolVFS` | OPFS | **`poolSize` must be `1`** — throws otherwise | Single-connection scenarios requiring access handle pool semantics. |
25
- | `IDBBatchAtomicVFS` | IndexedDB | None | Fallback when OPFS is unavailable (older browsers, some mobile environments). |
24
+ Works under **Vite 6.1 to 7** with the following config, which only the dev server needs:
25
+
26
+ ```typescript
27
+ // vite.config.ts
28
+ export default defineConfig({
29
+ optimizeDeps: { exclude: ['browser-sqlite'] },
30
+ });
31
+ ```
26
32
 
27
- When `vfs` is omitted, `OPFSPermutedVFS` is used.
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.
28
34
 
29
- For a detailed VFS comparison, see the [wa-sqlite VFS comparison](https://github.com/rhashimoto/wa-sqlite/tree/master/src/examples#vfs-comparison).
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).
30
36
 
31
37
  ## Usage
32
38
 
33
- ### Initialize
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
34
42
 
35
43
  ```typescript
36
44
  import { createSQLiteClient } from 'browser-sqlite';
37
45
 
38
46
  const db = createSQLiteClient('myapp.sqlite', {
39
47
  poolSize: 2, // number of worker threads (default: 2)
40
- vfs: 'OPFSPermutedVFS', // VFS selection (default: 'OPFSPermutedVFS')
48
+ vfs: 'OPFSAdaptiveVFS', // required see VFS Selection
49
+ build: 'async', // wa-sqlite build (default: the VFS's first)
41
50
  pragmas: { // SQLite PRAGMAs applied on open
42
51
  journal_mode: 'WAL',
43
52
  synchronous: 'NORMAL',
@@ -47,7 +56,9 @@ const db = createSQLiteClient('myapp.sqlite', {
47
56
 
48
57
  `createSQLiteClient` spawns `poolSize` Web Worker threads immediately. Workers reach READY state asynchronously — queries made before workers are ready are queued automatically.
49
58
 
50
- ### Read
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
51
62
 
52
63
  ```typescript
53
64
  type User = { id: number; name: string };
@@ -61,7 +72,14 @@ const users = await db.read<User>(
61
72
 
62
73
  Read queries are dispatched to any available worker, enabling concurrent reads.
63
74
 
64
- ### Write
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
65
83
 
66
84
  ```typescript
67
85
  const { affected } = await db.write(
@@ -73,89 +91,443 @@ const { affected } = await db.write(
73
91
 
74
92
  Write queries are serialized through a dedicated writer worker — only one write executes at a time.
75
93
 
76
- ### Stream (large result sets)
94
+ | Option | Type | Default | Description |
95
+ |---|---|---|---|
96
+ | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
97
+
98
+ ### *client*.stream
77
99
 
78
100
  ```typescript
79
101
  // Worker is held for the full generator lifetime — always exhaust or break.
80
- for await (const chunk of db.stream<User>(
81
- 'SELECT * FROM large_table',
82
- [],
83
- { chunkSize: 100 },
84
- )) {
85
- processChunk(chunk); // chunk is User[]
102
+ for await (const row of db.stream<User>('SELECT * FROM large_table', [])) {
103
+ processRow(row); // row is User
86
104
  }
87
105
  ```
88
106
 
89
- `stream()` yields rows in chunks without buffering the full result set in memory.
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. |
90
114
 
91
- ### One (first row)
115
+ On `stream()`, `chunkSize` is the only lever on how many rows are in flight.
116
+
117
+ ### *client*.chunk
92
118
 
93
119
  ```typescript
94
- const user = await db.one<User>(
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>(
95
140
  'SELECT * FROM users WHERE id = ?',
96
141
  [42],
97
142
  );
98
143
  // user: User | undefined
99
144
  ```
100
145
 
101
- `one()` automatically aborts after the first result row. Use it for lookups by primary key or unique field.
146
+ `first()` returns the first result row, or `undefined` if no rows match. Use it for lookups by primary key or unique field.
102
147
 
103
- ### Advanced
148
+ | Option | Type | Default | Description |
149
+ |---|---|---|---|
150
+ | `signal` | `AbortSignal` | — | Aborts the query. Rejects with `signal.reason`. |
104
151
 
105
- For batch inserts, schema-driven table replacement, or explicit transactions, see:
106
- - `db.bulkWrite(table, keys)` — batches inserts within `SQLITE_MAX_VARS` limit
107
- - `db.output(table, schema, options)` — drops, recreates, and populates a table from a schema definition
108
- - `db.transaction(callback, options)` — wraps operations in a SQLite transaction with auto-commit and rollback
152
+ `first()` stops the query after one row instead of draining the result set.
109
153
 
110
- ### Close
154
+ ### *client*.transaction
111
155
 
112
156
  ```typescript
113
- db.close();
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
+ });
114
163
  ```
115
164
 
116
- Terminates all worker threads.
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.
117
169
 
118
- ## Requirements
170
+ `tx` carries the same querying surface as the client — `read`, `write`, `chunk`, `stream`, `first`, `bulkWrite`, `output` — plus `commit` and `rollback`.
119
171
 
120
- > **These HTTP headers are mandatory.** Without them, `new SharedArrayBuffer()` throws a `SecurityError` and browser-sqlite cannot initialize.
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.
121
173
 
122
- browser-sqlite uses a `SharedArrayBuffer` to coordinate worker pool state. Browsers require [cross-origin isolation](https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated) to create `SharedArrayBuffer` instances. Your page must be served with:
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. |
123
179
 
124
- ```http
125
- Cross-Origin-Opener-Policy: same-origin
126
- Cross-Origin-Embedder-Policy: require-corp
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();
127
186
  ```
128
187
 
129
- ### Server configuration examples
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. |
130
206
 
131
- **Nginx**
132
- ```nginx
133
- add_header Cross-Origin-Opener-Policy "same-origin";
134
- add_header Cross-Origin-Embedder-Policy "require-corp";
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();
135
217
  ```
136
218
 
137
- **Express**
138
- ```javascript
139
- app.use((req, res, next) => {
140
- res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
141
- res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
142
- next();
143
- });
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();
144
239
  ```
145
240
 
146
- **Rsbuild / Vite dev server**
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
+
147
249
  ```typescript
148
- // rsbuild.config.ts or vite.config.ts
149
- server: {
150
- headers: {
151
- 'Cross-Origin-Opener-Policy': 'same-origin',
152
- 'Cross-Origin-Embedder-Policy': 'require-corp',
153
- },
154
- },
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. |
283
+
284
+ ## Browser support
285
+
286
+ | Chrome | Firefox | Safari |
287
+ |---|---|---|
288
+ | 92+ | 95+ | 15.4+ |
289
+
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.
308
+
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.
389
+
390
+ <!-- BEGIN GENERATED BUILD TABLE — edit FEATURE_SUPPORT in scripts/render-vfs-matrix.ts -->
391
+
392
+ #### Build `sync`
393
+
394
+ | Chrome / Edge | Firefox | Safari | Chrome Android | Safari iOS |
395
+ |---|---|---|---|---|
396
+ | Any | Any | Any | Any | Any |
397
+
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.
399
+
400
+ #### Build `async`
401
+
402
+ | Chrome / Edge | Firefox | Safari | Chrome Android | Safari iOS |
403
+ |---|---|---|---|---|
404
+ | Any | Any | Any | Any | Any |
405
+
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.
407
+
408
+ #### Build `jspi`
409
+
410
+ | Chrome / Edge | Firefox | Safari | Chrome Android | Safari iOS |
411
+ |---|---|---|---|---|
412
+ | 137+ | 153+ | 27+ | Yes | 27+ |
413
+
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.
415
+
416
+
417
+ <!-- END GENERATED BUILD TABLE -->
418
+
419
+ ## Error handling
420
+
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`.
422
+
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`. |
432
+
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
+ ```
447
+
448
+ **Request timeouts.** The library adds no per-request timeout. To bound a query, pass `AbortSignal.timeout(ms)`:
449
+
450
+ ```typescript
451
+ const rows = await db.read('SELECT * FROM large_table', [], {
452
+ signal: AbortSignal.timeout(5_000),
453
+ });
155
454
  ```
156
455
 
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.
457
+
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()`.
459
+
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.
468
+
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.
471
+
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.
478
+
479
+ ## Requirements
480
+
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.
482
+
483
+ Note: the "Coop" in `OPFSCoopSyncVFS` stands for *cooperative*, not the `Cross-Origin-Opener-Policy` header.
484
+
157
485
  ## Known Limitations
158
486
 
159
487
  - **`AccessHandlePoolVFS` requires `poolSize: 1`.** Passing `poolSize > 1` with this VFS throws synchronously at client creation time.
160
- - **`SharedArrayBuffer` requires cross-origin isolation.** See the [Requirements](#requirements) section. Omitting COOP/COEP headers causes a `SecurityError` at runtime with no fallback.
161
- - **`OPFSAdaptiveVFS` requires Chromium 126+.** This VFS uses JavaScript Promise Integration (JSPI), which is not available in Firefox or Safari as of 2025.
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.
496
+
497
+ ## Development
498
+
499
+ ```bash
500
+ pnpm install
501
+ pnpm build # rslib → dist/
502
+ pnpm test # unit (Node) + browser (Playwright/Chromium)
503
+ pnpm check # biome, with --write
504
+ ```
505
+
506
+ Two suites run on demand rather than on every change:
507
+
508
+ ```bash
509
+ pnpm test:conformance # every declared (vfs, build) pair through six invariants
510
+ pnpm test:consumer # packs the tarball and drives four bundler modes
511
+ ```
512
+
513
+ ### The benchmark page
514
+
515
+ `scripts/bench/html/index.html` is the page published above. It is one self-contained file
516
+ served beside a verbatim copy of `dist/`, so it exercises the library exactly as
517
+ a consumer would with no bundler at all.
518
+
519
+ ```bash
520
+ pnpm bench:dev # build, serve on http://127.0.0.1:8099, rebuild on change
521
+ pnpm bench:serve # same without the watch
522
+ pnpm bench:build # assemble _site/ only
523
+ ```
524
+
525
+ `http://127.0.0.1` is a secure context, so OPFS works with no certificate — no
526
+ TLS setup is needed to develop against it. A phone on the LAN is a different
527
+ matter: it is not a secure context, so OPFS is unavailable there and a tunnel
528
+ (or the published page) is the way to test a real device.
529
+
530
+ `node scripts/bench/check.mjs [chromium|firefox] [--all]` drives the page under
531
+ Playwright and asserts that it still works — it is run by hand and deliberately
532
+ not wired into CI. It checks the *page*, never that a VFS passes: a red cell can
533
+ be a correct report about the engine you are on.