@delali/sirannon-db 0.1.6 → 0.1.8
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 +154 -19
- package/dist/backup-scheduler/index.d.ts +18 -3
- package/dist/backup-scheduler/index.mjs +2 -2
- package/dist/{change-tracker-CFTQ9TSn.d.ts → change-tracker-CbmaMO-N.d.ts} +12 -2
- package/dist/chunk-4ISB7XMA.mjs +21 -0
- package/dist/chunk-BNUTBHHH.mjs +22 -0
- package/dist/chunk-CJLYFDP5.mjs +26 -0
- package/dist/chunk-CW6S3WL5.mjs +222 -0
- package/dist/chunk-DVWQD3GF.mjs +49 -0
- package/dist/chunk-GEZUUIKV.mjs +268 -0
- package/dist/{chunk-UVMVN3OT.mjs → chunk-H5AB6NIR.mjs} +1 -1
- package/dist/{chunk-UTO3ZAFS.mjs → chunk-HHRMRFFR.mjs} +148 -28
- package/dist/chunk-TKGHYWQ6.mjs +35 -0
- package/dist/chunk-UXLAO6ZH.mjs +352 -0
- package/dist/chunk-VLTICJOD.mjs +470 -0
- package/dist/{chunk-O7BHI3CF.mjs → chunk-YPYVQJ4C.mjs} +15 -1
- package/dist/client/index.d.ts +107 -15
- package/dist/client/index.mjs +348 -37
- package/dist/core/index.d.ts +70 -13
- package/dist/core/index.mjs +635 -192
- package/dist/core/writer-worker.d.ts +2 -0
- package/dist/core/writer-worker.mjs +129 -0
- package/dist/{database-BVY1GqE7.d.ts → database-DuGp0Rtr.d.ts} +51 -19
- package/dist/driver/better-sqlite3.d.ts +1 -1
- package/dist/driver/better-sqlite3.mjs +44 -6
- package/dist/driver/bun.mjs +35 -6
- package/dist/driver/expo.mjs +2 -2
- package/dist/driver/node.d.ts +1 -1
- package/dist/driver/node.mjs +48 -5
- package/dist/driver/wa-sqlite.d.ts +109 -0
- package/dist/driver/wa-sqlite.mjs +30 -2
- package/dist/{errors-C00ed08Q.d.ts → errors-5Nf5ZAEC.d.ts} +19 -1
- package/dist/file-migrations/index.d.ts +2 -3
- package/dist/file-migrations/index.mjs +1 -1
- package/dist/replication/coordinator/etcd.mjs +2 -2
- package/dist/replication/index.d.ts +7 -8
- package/dist/replication/index.mjs +79 -78
- package/dist/server/index.d.ts +111 -28
- package/dist/server/index.mjs +1016 -384
- package/dist/{sirannon-Cd-lK6T0.d.ts → sirannon-4SspRvP5.d.ts} +3 -3
- package/dist/transport/grpc.d.ts +3 -4
- package/dist/transport/grpc.mjs +2 -2
- package/dist/{types-Lc7ywFx7.d.ts → types-BsjobKbl.d.ts} +2 -2
- package/dist/{types-BeozgNPr.d.ts → types-D4p4UyDK.d.ts} +1 -1
- package/dist/types-D_hQW1hr.d.ts +494 -0
- package/package.json +3 -23
- package/dist/chunk-3MCMONVP.mjs +0 -115
- package/dist/chunk-74UN4DIE.mjs +0 -14
- package/dist/chunk-FB2U2Q3Y.mjs +0 -21
- package/dist/chunk-GS7T5YMI.mjs +0 -51
- package/dist/chunk-PXKAKK2V.mjs +0 -124
- package/dist/index-CLdNrcPz.d.ts +0 -16
- package/dist/types-BFSsG77t.d.ts +0 -29
- package/dist/types-D-74JiXb.d.ts +0 -265
package/README.md
CHANGED
|
@@ -8,8 +8,26 @@
|
|
|
8
8
|
|
|
9
9
|
Build a networked SQLite service with connection pooling, change data capture, migrations, backups, and a client SDK. Applications reach Sirannon over HTTP or WebSocket, while Sirannon nodes replicate primary-owned changes over gRPC. Coordinator mode adds etcd-backed authority and automatic failover.
|
|
10
10
|
|
|
11
|
+
The benchmarks compare Sirannon against Postgres 17 on the same OLTP workloads, driving each engine through the client it provides and matching durability on both sides. Every published figure is generated from a recorded run on a disclosed machine, and the page shows where each engine wins. See the full [methodology and results](../../BENCHMARKS.md).
|
|
12
|
+
|
|
13
|
+
See a three-node cluster keep serving through a primary failure in the [distributed entitlements example](https://github.com/assetcorp/sirannon-db/tree/main/packages/ts/examples/distributed-entitlements), which runs the etcd coordinator, gRPC replication with mutual TLS, and fault injection on your machine.
|
|
14
|
+
|
|
11
15
|
> *sirannon* means 'gate-stream' in Sindarin.
|
|
12
16
|
|
|
17
|
+
## Project status
|
|
18
|
+
|
|
19
|
+
Sirannon has two levels of maturity. The core data layer, the server, the client, and primary-replica replication are stable. Coordinator-backed automatic failover is the newest part and needs more production use before it is stable.
|
|
20
|
+
|
|
21
|
+
| Part | Status | Details |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| Core engine (`@delali/sirannon-db`) | Stable | Queries, transactions, connection pooling, change data capture, migrations, backups, hooks, metrics, and multi-tenant lifecycle, covered by more than 130 test files with continuous integration on Node 22 and 24. |
|
|
24
|
+
| Server and client (`@delali/sirannon-db/server`, `@delali/sirannon-db/client`) | Stable | HTTP and WebSocket access with reconnection and subscription restore. The server runs client SQL by design, so read the [security section](#security) before you expose it. |
|
|
25
|
+
| Primary-replica replication (`@delali/sirannon-db/replication`) | Stable | Hybrid Logical Clock stamping, conflict resolvers, first sync, write concerns, and a gRPC transport with mutual TLS. |
|
|
26
|
+
| Coordinator-backed automatic failover (`@delali/sirannon-db/replication/coordinator/etcd`) | Experimental | etcd authority, primary terms, and in-sync sets, verified by a Docker conformance run under fault injection. It is new and not yet proven in production. |
|
|
27
|
+
| Drivers | Stable: better-sqlite3, Node, wa-sqlite. Experimental: Bun, Expo | The Bun and Expo drivers run today but have no TypeScript declarations yet. |
|
|
28
|
+
|
|
29
|
+
Durability follows SQLite's WAL mode with `synchronous=NORMAL` by default, and you can raise it. The [roadmap](https://github.com/assetcorp/sirannon-db/blob/main/ROADMAP.md) sets out what is next, including a second-language implementation and scaling beyond a single node's disk.
|
|
30
|
+
|
|
13
31
|
## Install
|
|
14
32
|
|
|
15
33
|
```bash
|
|
@@ -47,7 +65,7 @@ await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', 'ada@e
|
|
|
47
65
|
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')
|
|
48
66
|
```
|
|
49
67
|
|
|
50
|
-
Node.js 22+ users can skip the extra dependency by using the built-in `node:sqlite` module
|
|
68
|
+
Node.js 22+ users can skip the extra dependency by using the built-in `node:sqlite` module. Node enables it by default from 22.13.0 and 23.4.0 onward; earlier 22.x releases need the `--experimental-sqlite` flag. The module is still experimental.
|
|
51
69
|
|
|
52
70
|
```ts
|
|
53
71
|
import { nodeSqlite } from '@delali/sirannon-db/driver/node'
|
|
@@ -103,7 +121,7 @@ const users = await db.query<{ id: number; name: string }>('SELECT * FROM users'
|
|
|
103
121
|
|
|
104
122
|
### Bun
|
|
105
123
|
|
|
106
|
-
|
|
124
|
+
You need no extra dependency, because Bun includes `bun:sqlite`.
|
|
107
125
|
|
|
108
126
|
```ts
|
|
109
127
|
import { Sirannon } from '@delali/sirannon-db'
|
|
@@ -129,7 +147,7 @@ Sirannon-db separates the database engine from the library. You pick the driver
|
|
|
129
147
|
| Driver | Import | Runtime | Install |
|
|
130
148
|
| --- | --- | --- | --- |
|
|
131
149
|
| better-sqlite3 | `@delali/sirannon-db/driver/better-sqlite3` | Node.js | `pnpm add -E better-sqlite3` |
|
|
132
|
-
| Node built-in | `@delali/sirannon-db/driver/node` | Node.js >= 22 | None (
|
|
150
|
+
| Node built-in | `@delali/sirannon-db/driver/node` | Node.js >= 22 | None (built in; flag-free from Node 22.13.0 and 23.4.0) |
|
|
133
151
|
| wa-sqlite | `@delali/sirannon-db/driver/wa-sqlite` | Browser | `pnpm add -E wa-sqlite` |
|
|
134
152
|
| Bun | `@delali/sirannon-db/driver/bun` | Bun | None (uses `bun:sqlite`) |
|
|
135
153
|
| Expo | `@delali/sirannon-db/driver/expo` | React Native | `pnpm add -E expo-sqlite` |
|
|
@@ -147,18 +165,22 @@ import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
|
|
|
147
165
|
const driver = waSqlite({ vfs: 'IDBBatchAtomicVFS' })
|
|
148
166
|
```
|
|
149
167
|
|
|
168
|
+
You write a custom driver by passing `capabilities` and an `open` function to `defineDriver`. To let it run on a writer worker thread (see [Writer worker](#writer-worker-offload-disk-writes)), add a `worker` entry so the worker can rebuild it: a `specifier` the worker can import, the `exportName` of your factory, and a `config` value that survives a structured clone. The worker imports the module and calls the factory with the config, because the `open` function itself can't cross the thread boundary.
|
|
169
|
+
|
|
150
170
|
## Package exports
|
|
151
171
|
|
|
152
|
-
The package
|
|
172
|
+
The package provides independent exports so you only bundle what you need:
|
|
153
173
|
|
|
154
174
|
| Import | What you get |
|
|
155
175
|
| --- | --- |
|
|
156
176
|
| `@delali/sirannon-db` | Core library: queries, transactions, CDC, migrations, backups, hooks, metrics, lifecycle |
|
|
157
177
|
| `@delali/sirannon-db/driver/*` | SQLite driver adapters (see table above) |
|
|
158
178
|
| `@delali/sirannon-db/file-migrations` | Load `.up.sql` / `.down.sql` files from a directory |
|
|
179
|
+
| `@delali/sirannon-db/backup-scheduler` | Cron-scheduled backup runner with file rotation, also re-exported from the core entry |
|
|
159
180
|
| `@delali/sirannon-db/server` | HTTP + WebSocket server powered by uWebSockets.js |
|
|
160
181
|
| `@delali/sirannon-db/client` | Browser/Node.js client SDK with auto-reconnect and subscription restore |
|
|
161
182
|
| `@delali/sirannon-db/replication` | Replication engine, conflict resolvers, topologies, HLC |
|
|
183
|
+
| `@delali/sirannon-db/replication/coordinator/etcd` | etcd-backed cluster coordinator for primary authority and automatic failover |
|
|
162
184
|
| `@delali/sirannon-db/transport/grpc` | gRPC replication transport with TLS support |
|
|
163
185
|
| `@delali/sirannon-db/transport/memory` | In-memory transport for testing |
|
|
164
186
|
|
|
@@ -189,6 +211,39 @@ const total = await db.transaction(async tx => {
|
|
|
189
211
|
})
|
|
190
212
|
```
|
|
191
213
|
|
|
214
|
+
### Bulk load
|
|
215
|
+
|
|
216
|
+
Loading a large dataset through many small committed transactions is slow and can stall the whole server. At `synchronous = full`, every commit calls `fsync`, and because the engine is synchronous, each `fsync` blocks the event loop until the disk confirms; tens of thousands of blocking `fsync` calls back to back stop the server from answering anything. `db.bulkLoad` runs the whole batch in one transaction under a relaxed durability level, then restores the configured level before it resolves.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const summary = await db.bulkLoad(
|
|
220
|
+
'INSERT INTO events (id, payload) VALUES (?, ?)',
|
|
221
|
+
rows, // an array of parameter arrays, one per row
|
|
222
|
+
{ durability: 'off' },
|
|
223
|
+
)
|
|
224
|
+
// summary.rowsLoaded, summary.changes
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The load holds the single writer for its whole duration, so no other write commits under the relaxed level and no two loads race on the durability setting. On success the WAL is checkpointed at the restored level, so the loaded rows are written into the main database file before the call resolves. That checkpoint runs synchronously and blocks the event loop for the length of the WAL flush, which grows with the size of the load.
|
|
228
|
+
|
|
229
|
+
`durability` defaults to `'off'`. SQLite sanctions `'off'` for a load that starts from an empty database and that the operator can re-run after a power loss; a crash during an `'off'` load can corrupt the file, so recovery means re-running the load from scratch. Use `'normal'` for a load into a database that already holds data you cannot afford to lose, because it keeps WAL corruption safety while it still drops the per-commit `fsync`. Either way the configured `synchronous` level is restored when the load finishes, and a crash mid-load leaves the configured level in force on the next open, because `PRAGMA synchronous` is connection state that SQLite never stores in the database file.
|
|
230
|
+
|
|
231
|
+
The result sums the row count and the changes rather than returning one object per row, so a load of millions of rows never holds millions of result objects in memory. Over the server, one load must fit under `maxBodyBytes`; send a larger dataset as several sequential loads, each of which restores durability on its own.
|
|
232
|
+
|
|
233
|
+
For a dataset that spans more than one request, the client's `db.loadAll` batches it for you. Hand it a synchronous or asynchronous iterable of parameter sets; it splits the rows into batches, runs the one fsyncing WAL checkpoint once after the final batch, and restores the configured durability after every batch:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const summary = await db.loadAll(
|
|
237
|
+
'INSERT INTO events (id, payload) VALUES (?, ?)',
|
|
238
|
+
rowStream, // an iterable or async iterable of parameter arrays
|
|
239
|
+
{ batchSize: 5000, durability: 'off' },
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`batchSize` defaults to 1000, and each batch is one request, so size it to stay under the server's `maxBodyBytes`. `loadAll` runs the finalize itself and keeps the checkpoint flag out of your code. On network-attached disks, where each checkpoint fsync is slow, this collapses hundreds of per-batch flushes into one flush at the end.
|
|
244
|
+
|
|
245
|
+
The low-level `db.load` exposes the same behaviour through a `checkpoint` flag for callers that batch by hand: pass `{ checkpoint: false }` on every load but the last, so the WAL checkpoint runs once, after the final load. Each load still restores the configured durability, so an import you abandon partway keeps the writer at the configured level, and SQLite's automatic checkpoint keeps the WAL bounded during the import. Prefer `loadAll` unless you need that control, because a forgotten final `checkpoint: true` leaves the last pages in the WAL until the next checkpoint.
|
|
246
|
+
|
|
192
247
|
### Connection pooling
|
|
193
248
|
|
|
194
249
|
Every database opens with 1 dedicated write connection and N read connections (default 4). WAL mode is enabled by default, allowing concurrent reads during writes.
|
|
@@ -296,10 +351,13 @@ db.scheduleBackup({
|
|
|
296
351
|
cron: '0 */6 * * *', // every 6 hours
|
|
297
352
|
destDir: './backups',
|
|
298
353
|
maxFiles: 10, // keep the 10 most recent
|
|
354
|
+
timezone: 'America/New_York', // optional; defaults to the host timezone
|
|
299
355
|
onError: err => console.error('Backup failed:', err),
|
|
300
356
|
})
|
|
301
357
|
```
|
|
302
358
|
|
|
359
|
+
The cron expression supports five or six fields (an optional leading seconds field), ranges, steps, lists, month and weekday names, and `@daily`-style nicknames. It runs in `timezone` when you set one, and in the host's local timezone otherwise. When the clocks go forward for daylight saving time, the scheduler skips the missing hour; when they go back, it runs a backup timed for the repeated hour once. The scheduler does not backfill: if the host sleeps or the clock jumps forward past a scheduled time, that run is skipped rather than run late, and a backward clock step repeats nothing.
|
|
360
|
+
|
|
303
361
|
### Hooks
|
|
304
362
|
|
|
305
363
|
Hooks run before or after key operations. Throwing from a before-hook denies the operation.
|
|
@@ -379,19 +437,76 @@ await server.listen()
|
|
|
379
437
|
|
|
380
438
|
See the [Security](#security) section for authentication, TLS, and CORS configuration.
|
|
381
439
|
|
|
440
|
+
The server offers three write shapes, on both transports. Reach for each one when:
|
|
441
|
+
|
|
442
|
+
- **transaction** runs several *different* statements that must all succeed or all fail together, such as a debit on one row and a credit on another.
|
|
443
|
+
- **batch** runs *one* statement many times with different values, such as inserting a thousand rows into the same table. It costs less than a transaction of a thousand near-identical statements, and it stays all-or-nothing.
|
|
444
|
+
- **load** runs a batch for a large, from-scratch import. It relaxes durability while the rows go in and restores it afterward, so it trades power-loss safety during the load for speed; if the process dies mid-load, you re-run it.
|
|
445
|
+
|
|
446
|
+
### Writer worker (offload disk writes)
|
|
447
|
+
|
|
448
|
+
Under full durability every commit flushes to disk, and a WAL checkpoint flushes too. While the serving thread runs one of those flushes it can't accept or answer connections, so a burst of writes can push the server into refusing fresh connections. Turn on `writerWorker` to run writes, WAL checkpoints, bulk loads, migrations, and backups on a dedicated worker thread. The serving thread hands the work across and stays free to accept and answer connections. Reads stay on the serving thread, since they're fast and served from the page cache.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
const db = await sirannon.open('app', './data/app.db', {
|
|
452
|
+
synchronous: 'full',
|
|
453
|
+
writerWorker: true,
|
|
454
|
+
})
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Full durability holds. A write returns to the client only after its flush completes on the worker, exactly as it does without offload. The built-in `better-sqlite3` and `node` drivers support offload, and a custom driver opts in by declaring a worker entry (see [Pluggable drivers](#pluggable-drivers)). Enabling `writerWorker` on a driver that can't support it fails at open with a clear error rather than quietly keeping writes on the serving thread.
|
|
458
|
+
|
|
459
|
+
Pass an object instead of `true` to tune backpressure and the worker's lifecycle:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
const db = await sirannon.open('app', './data/app.db', {
|
|
463
|
+
synchronous: 'full',
|
|
464
|
+
writerWorker: {
|
|
465
|
+
maxPendingWrites: 1024,
|
|
466
|
+
writeTimeoutMs: 30000,
|
|
467
|
+
maxRestarts: 5,
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`maxPendingWrites` bounds how many writes may be in flight before the server sheds load. Past it, a write returns HTTP 503 with a `Retry-After` header, and a `WRITE_OVERLOADED` error over WebSocket, so clients back off and retry instead of the server buffering without bound. Size it from your sustainable write rate times your worst-case write latency. `writeTimeoutMs` is the per-operation deadline, so a hung flush fails loudly instead of hanging a client. When it expires, the host asks the worker to cancel the operation; the worker itself keeps running, since a thread inside a synchronous SQLite call cannot be interrupted safely. Work the worker has not started yet is shed and rejected with `WRITE_OVERLOADED`, a known outcome that is safe to retry. A result that arrives within one further deadline is delivered normally, and an operation still unresolved after that grace window is rejected with `WRITER_WORKER_TIMEOUT`, an indeterminate outcome: the write may or may not have committed, so reconcile state before retrying a non-idempotent write. A genuinely dead disk keeps rejecting writes until you restart the process. Raise the deadline only for unusually large single operations, such as dropping a very large table. `maxRestarts` caps how many times the worker is respawned after it crashes on its own before writes fail permanently.
|
|
473
|
+
|
|
382
474
|
### HTTP routes
|
|
383
475
|
|
|
384
476
|
| Method | Path | Description |
|
|
385
477
|
| --- | --- | --- |
|
|
386
478
|
| `POST` | `/db/:id/query` | Execute a SELECT, returns `{ rows }` |
|
|
387
479
|
| `POST` | `/db/:id/execute` | Execute a mutation, returns `{ changes, lastInsertRowId }` |
|
|
388
|
-
| `POST` | `/db/:id/transaction` | Execute
|
|
480
|
+
| `POST` | `/db/:id/transaction` | Execute many statements atomically in one transaction, returns `{ results }` |
|
|
481
|
+
| `POST` | `/db/:id/batch` | Apply one statement over many parameter sets in one transaction, returns `{ results }` |
|
|
482
|
+
| `POST` | `/db/:id/load` | Bulk-load rows with relaxed durability, returns `{ rowsLoaded, changes }` |
|
|
483
|
+
| `GET` | `/db/:id/cluster` | Cluster status for the database: role, replication group, current primary, primary term, read endpoints, and health; returns 404 when the server has no cluster status source configured |
|
|
389
484
|
| `GET` | `/health` | Liveness check |
|
|
390
485
|
| `GET` | `/health/ready` | Readiness check with per-database status |
|
|
391
486
|
|
|
392
487
|
### WebSocket protocol
|
|
393
488
|
|
|
394
|
-
Connect to `ws://host:port/db/:id` and send JSON messages
|
|
489
|
+
Connect to `ws://host:port/db/:id` and send JSON messages. Every message carries a `type` and a client-chosen `id`, and every reply echoes that `id`. The server dispatches CDC change events to subscribers in real time.
|
|
490
|
+
|
|
491
|
+
| Inbound `type` | Fields | Reply |
|
|
492
|
+
| --- | --- | --- |
|
|
493
|
+
| `query` | `sql`, `params?` | `{ type: 'result', data: { rows } }` |
|
|
494
|
+
| `execute` | `sql`, `params?` | `{ type: 'result', data: { changes, lastInsertRowId } }` |
|
|
495
|
+
| `transaction` | `statements`, `writeConcern?` | `{ type: 'result', data: { results } }` |
|
|
496
|
+
| `batch` | `sql`, `paramsBatch`, `writeConcern?` | `{ type: 'result', data: { results } }` |
|
|
497
|
+
| `load` | `sql`, `paramsBatch`, `durability?`, `checkpoint?` | `{ type: 'result', data: { rowsLoaded, changes } }` |
|
|
498
|
+
| `subscribe` | `table`, `filter?`, `sinceSeq?`, `epoch?` | `{ type: 'subscribed', seq?, epoch?, resync? }` then `change` events |
|
|
499
|
+
| `unsubscribe` | - | `{ type: 'unsubscribed' }` |
|
|
500
|
+
|
|
501
|
+
The `transaction`, `batch`, and `load` messages run every statement server-side in one transaction and reply once. The server never holds the write lock across a network round-trip, so it does not accept an interactive transaction where the client sends `BEGIN`, then more statements, then `COMMIT` over separate messages; a single slow or dead client would otherwise freeze every write to the database.
|
|
502
|
+
|
|
503
|
+
Each `change` event carries the change `type` (`insert`, `update`, or `delete`), the `table`, the `row`, the `oldRow` for updates and deletes, the `seq` as a decimal string, and a `timestamp` in milliseconds since the epoch.
|
|
504
|
+
|
|
505
|
+
A subscription can resume after a reconnect. Send `sinceSeq`, the highest `seq` the client has processed, as a decimal string, and the server replays every retained change with a greater `seq` before delivering live events. The `subscribed` reply carries `seq`, the sequence the subscription is live from, and `epoch`, which identifies the sequence space the changes come from; store both and echo `epoch` when resuming, so a cursor carried to a different database forces a resync instead of a silent replay of unrelated rows. When the requested `sinceSeq` falls below the retained history, or the `epoch` does not match, the reply sets `resync: true`: the subscription still starts live from now, and the client must treat its prior state as stale and re-read it. The server's `cdcRetentionMs` option bounds how far back a subscriber can resume. The client SDK handles all of this for you when `autoReconnect` is on.
|
|
506
|
+
|
|
507
|
+
### Values over the wire
|
|
508
|
+
|
|
509
|
+
Both transports round-trip every SQLite value, including 64-bit integers and binary blobs, even though the messages are JSON. A binary value crosses the wire as a hex envelope, `{ "__sirannon_blob": "<uppercase hex>" }`. An integer beyond JavaScript's safe range crosses as a decimal-string envelope, `{ "__sirannon_int": "<decimal string>" }`, while an integer inside the safe range narrows to a plain number. A `lastInsertRowId` beyond the safe range is returned as a decimal string. The same envelopes work in bind parameters, and the server rejects a malformed envelope instead of passing it to SQL. The client SDK encodes parameters and decodes query rows and change events for you, so `BigInt` and `Uint8Array` values round-trip with no application code. The normative definition is in the specification's server document, [`packages/spec/05-server.md`](../spec/05-server.md).
|
|
395
510
|
|
|
396
511
|
## Client SDK
|
|
397
512
|
|
|
@@ -412,7 +527,7 @@ const users = await db.query<{ id: number; name: string }>('SELECT * FROM users'
|
|
|
412
527
|
|
|
413
528
|
await db.execute('INSERT INTO users (name) VALUES (?)', ['Turing'])
|
|
414
529
|
|
|
415
|
-
const sub = db.
|
|
530
|
+
const sub = await db.on('users').subscribe(event => {
|
|
416
531
|
console.log('User changed:', event)
|
|
417
532
|
})
|
|
418
533
|
|
|
@@ -440,6 +555,10 @@ httpClient.close()
|
|
|
440
555
|
|
|
441
556
|
## Distributed replication
|
|
442
557
|
|
|
558
|
+
<p align="center">
|
|
559
|
+
<img src="../../docs/assets/replication-topology.svg" alt="Sirannon replication topology: application clients reach the primary and read replicas, the primary replicates to replicas over gRPC with mutual TLS, and an etcd coordinator tracks authority, leases, and the in-sync set." width="820">
|
|
560
|
+
</p>
|
|
561
|
+
|
|
443
562
|
Sirannon can replicate a SQLite database across multiple nodes with change propagation, new-node bootstrapping, write concerns, and coordinator-backed failover. The production path is primary-replica: one primary accepts writes, replicas serve reads and can forward writes, and coordinator mode manages authority when failover is enabled. When replication is not enabled, the replication engine does not run.
|
|
444
563
|
|
|
445
564
|
```ts
|
|
@@ -555,7 +674,7 @@ Automatic write failover needs at least three voting data-bearing nodes. With fe
|
|
|
555
674
|
|
|
556
675
|
Normal writes are serialised through one primary per replication group. When a receiver applies a batch and finds the target row already present, it passes the local and incoming versions to the configured resolver. This is part of normal batch application, not a separate repair command.
|
|
557
676
|
|
|
558
|
-
|
|
677
|
+
The replication module includes three built-in strategies:
|
|
559
678
|
|
|
560
679
|
| Strategy | Class | Behaviour |
|
|
561
680
|
| --- | --- | --- |
|
|
@@ -580,7 +699,7 @@ When a new node joins a running cluster, it needs the full dataset before it can
|
|
|
580
699
|
|
|
581
700
|
The state machine is: `pending` -> `syncing` -> `catching-up` -> `ready`. You can monitor it via `engine.status().syncState`.
|
|
582
701
|
|
|
583
|
-
During `syncing`, `syncState.completedTables` lists the tables the joiner has finished and `syncState.totalTables`
|
|
702
|
+
During `syncing`, `syncState.completedTables` lists the tables the joiner has finished and `syncState.totalTables` records how many it will receive in all, so `completedTables.length / totalTables` gives you first-sync progress. A source that predates this field leaves `totalTables` at 0 until the sync finishes.
|
|
584
703
|
|
|
585
704
|
For large databases where a network transfer is impractical, the out-of-band path lets you copy the SQLite file directly and start from a known sequence:
|
|
586
705
|
|
|
@@ -612,23 +731,23 @@ In coordinator mode, `majority` is calculated from configured voting data-bearin
|
|
|
612
731
|
|
|
613
732
|
#### Is this SQLite over a shared network file system?
|
|
614
733
|
|
|
615
|
-
No. Each node
|
|
734
|
+
No. Each node has its own SQLite database file. Sirannon moves change batches through its replication transport and exposes database operations through the server and client layers. It does not rely on many machines opening the same SQLite file over NFS or another shared network file system.
|
|
616
735
|
|
|
617
736
|
#### What is replicated?
|
|
618
737
|
|
|
619
|
-
Sirannon replicates checksummed batches of `ReplicationChange` records. Each change
|
|
738
|
+
Sirannon replicates checksummed batches of `ReplicationChange` records. Each change includes the table, operation, row ID, primary key, HLC timestamp, transaction ID, node ID, old data, new data, and an optional DDL statement.
|
|
620
739
|
|
|
621
740
|
#### Is the protocol row-based, statement-based, operation-log based, or CRDT-like?
|
|
622
741
|
|
|
623
|
-
It is operation-log based at the Sirannon layer. Data changes
|
|
742
|
+
It is operation-log based at the Sirannon layer. Data changes include row images and primary-key metadata. DDL changes include a validated DDL statement. The current production write path is not CRDT-like; it prevents normal write conflicts with a single writable primary.
|
|
624
743
|
|
|
625
744
|
#### What ordering model does it use?
|
|
626
745
|
|
|
627
|
-
Each change
|
|
746
|
+
Each change includes a Hybrid Logical Clock timestamp. The HLC gives deterministic causal ordering across nodes without relying on perfectly synchronised wall clocks. Batches also include a sequence range, checksum, and, in coordinator mode, `groupId` and `primaryTerm`.
|
|
628
747
|
|
|
629
748
|
#### What happens under partitions?
|
|
630
749
|
|
|
631
|
-
Static mode
|
|
750
|
+
Static mode has no automatic failover. If the static primary is lost, writes stay unavailable until an operator or external system promotes another node and reroutes clients.
|
|
632
751
|
|
|
633
752
|
Coordinator mode uses a cluster coordinator, primary terms, leases, in-sync sets, and fail-closed write behaviour. A primary may accept writes only while it can prove current authority. Replicas reject stale batches, stale sync messages, and stale forwarded writes. Only an in-sync replica can be promoted.
|
|
634
753
|
|
|
@@ -650,7 +769,7 @@ Read concern controls this. `local` reads the selected node's local state. `majo
|
|
|
650
769
|
|
|
651
770
|
#### Is this local-first or multi-writer today?
|
|
652
771
|
|
|
653
|
-
The current production path is primary-replica. Conflict resolvers
|
|
772
|
+
The current production path is primary-replica. Conflict resolvers determine how a receiving node applies a change to an existing row; they do not provide local-first reconciliation or a multi-writer CRDT layer.
|
|
654
773
|
|
|
655
774
|
### Transport options
|
|
656
775
|
|
|
@@ -691,7 +810,7 @@ The current production path is primary-replica. Conflict resolvers decide how a
|
|
|
691
810
|
| `maxBatchChanges` | `number` | `1000` | Maximum accepted changes in one inbound batch |
|
|
692
811
|
| `ackTimeoutMs` | `number` | `5000` | Replication batch ack timeout |
|
|
693
812
|
| `initialSync` | `boolean` | `true` | Pull a full snapshot when joining a cluster |
|
|
694
|
-
| `syncBatchSize` | `number` | `10000` | Rows per sync batch during
|
|
813
|
+
| `syncBatchSize` | `number` | `10000` | Rows per sync batch during first sync |
|
|
695
814
|
| `maxConcurrentSyncs` | `number` | `2` | Maximum simultaneous sync sessions on the source |
|
|
696
815
|
| `maxSyncDurationMs` | `number` | `1800000` | Source aborts sync after this duration (30 min) |
|
|
697
816
|
| `maxSyncLagBeforeReady` | `number` | `100` | Catch-up lag threshold (in sequences) to transition to ready |
|
|
@@ -699,7 +818,7 @@ The current production path is primary-replica. Conflict resolvers decide how a
|
|
|
699
818
|
| `catchUpDeadlineMs` | `number` | `600000` | Max time in catch-up phase before transitioning to ready (10 min) |
|
|
700
819
|
| `resumeFromSeq` | `bigint` | - | Start replication from a specific sequence (out-of-band sync) |
|
|
701
820
|
| `snapshotConnectionFactory` | `() => Promise<SQLiteConnection>` | - | Factory for read-only connections used during sync serving |
|
|
702
|
-
| `changeTracker` | `ChangeTracker` | - | CDC trigger manager, required for
|
|
821
|
+
| `changeTracker` | `ChangeTracker` | - | CDC trigger manager, required for first sync |
|
|
703
822
|
| `flowControl` | `{ maxLagSeconds?, onLagExceeded? }` | - | Replication lag monitoring callbacks |
|
|
704
823
|
| `onBeforeForwardedQuery` | `(sql, params?) => void` | - | Validation or authorisation hook called before the primary executes each forwarded statement |
|
|
705
824
|
| `coordinator` | `CoordinatorModeConfig` | - | Enables coordinator-backed authority and failover |
|
|
@@ -820,7 +939,7 @@ Protocol values must be valid `Sec-WebSocket-Protocol` tokens. If you derive the
|
|
|
820
939
|
|
|
821
940
|
### TLS and transport security
|
|
822
941
|
|
|
823
|
-
The built-in server binds plain HTTP and WebSocket. For any traffic outside a trusted local network, terminate TLS upstream with a reverse proxy, load balancer, or platform edge and use `https://` and `wss://` client URLs. Without TLS, credentials, SQL text, parameters, and CDC payloads
|
|
942
|
+
The built-in server binds plain HTTP and WebSocket. For any traffic outside a trusted local network, terminate TLS upstream with a reverse proxy, load balancer, or platform edge and use `https://` and `wss://` client URLs. Without TLS, credentials, SQL text, parameters, and CDC payloads are sent in cleartext.
|
|
824
943
|
|
|
825
944
|
### CORS and browser access
|
|
826
945
|
|
|
@@ -885,6 +1004,17 @@ All errors extend `SirannonError` with a machine-readable `code` property:
|
|
|
885
1004
|
| `MaxDatabasesError` | `MAX_DATABASES` | Capacity limit reached |
|
|
886
1005
|
| `ExtensionError` | `EXTENSION_ERROR` | SQLite extension load failure |
|
|
887
1006
|
|
|
1007
|
+
The server and the bulk-load path add a few more codes. `createServer` throws `SirannonError` with `INVALID_MAX_BODY_BYTES` when `maxBodyBytes` is not a positive integer or exceeds `4_294_967_295`, the largest value uWebSockets.js can store; a larger value would wrap modulo 2^32 and enforce a limit you never configured, so the server refuses to start instead. `INVALID_WS_BACKPRESSURE` guards `maxWebSocketBackpressureBytes` with the same bounds. A bulk load throws `INVALID_DURABILITY` when `durability` is neither `'off'` nor `'normal'`, and `DURABILITY_RESTORE_FAILED` when the load committed but the writer connection failed before its durability could be restored; treat that last code as 'the load succeeded, do not re-run it'. Over the wire the server also returns `PAYLOAD_TOO_LARGE` when a request or message exceeds `maxBodyBytes`, and `BULK_LOAD_UNSUPPORTED` when the resolved execution target for a database does not implement bulk load.
|
|
1008
|
+
|
|
1009
|
+
The [writer worker](#writer-worker-offload-disk-writes) adds its own family of codes:
|
|
1010
|
+
|
|
1011
|
+
| Code | When | Retry? |
|
|
1012
|
+
| --- | --- | --- |
|
|
1013
|
+
| `WRITE_OVERLOADED` | More writes are pending than `maxPendingWrites` allows, or a queued write was shed when an earlier operation's deadline expired. Over HTTP this maps to a 503 with a `Retry-After` header. | Yes; the write was never applied. |
|
|
1014
|
+
| `WRITER_WORKER_TIMEOUT` | An in-flight operation did not resolve within `writeTimeoutMs` plus the grace window. | Only after reconciling state; the outcome is indeterminate. |
|
|
1015
|
+
| `WRITER_WORKER_UNSUPPORTED` | `writerWorker` was enabled on a driver without a worker entry; the database refuses to open. | No; use a driver with a worker entry or turn the option off. |
|
|
1016
|
+
| `INVALID_WRITER_WORKER` | A `writerWorker` option is not an integer in its allowed range. | No; fix the configuration. |
|
|
1017
|
+
|
|
888
1018
|
```ts
|
|
889
1019
|
import { QueryError } from '@delali/sirannon-db'
|
|
890
1020
|
|
|
@@ -916,8 +1046,10 @@ try {
|
|
|
916
1046
|
| `readOnly` | `boolean` | `false` | Open in read-only mode |
|
|
917
1047
|
| `readPoolSize` | `number` | `4` | Number of read connections |
|
|
918
1048
|
| `walMode` | `boolean` | `true` | Enable WAL mode |
|
|
1049
|
+
| `synchronous` | `'off' \| 'normal' \| 'full' \| 'extra'` | `'normal'` | Writer durability (`PRAGMA synchronous`); this is the level a bulk load restores when it finishes |
|
|
919
1050
|
| `cdcPollInterval` | `number` | `50` | CDC polling interval in ms |
|
|
920
1051
|
| `cdcRetention` | `number` | `3_600_000` | CDC retention period in ms (1 hour) |
|
|
1052
|
+
| `writerWorker` | `boolean \| WriterWorkerOptions` | `false` | Run writes on a dedicated worker thread so disk flushes never block the serving thread; see [Writer worker](#writer-worker-offload-disk-writes) |
|
|
921
1053
|
|
|
922
1054
|
### `ServerOptions`
|
|
923
1055
|
|
|
@@ -926,6 +1058,9 @@ try {
|
|
|
926
1058
|
| `host` | `string` | `'127.0.0.1'` | Bind address |
|
|
927
1059
|
| `port` | `number` | `9876` | Listen port |
|
|
928
1060
|
| `cors` | `boolean \| CorsOptions` | `false` | CORS configuration |
|
|
1061
|
+
| `maxBodyBytes` | `number` | `1_048_576` | Maximum HTTP request body and WebSocket message size in bytes; one value governs both transports, and it must be a positive integer no larger than `4_294_967_295` |
|
|
1062
|
+
| `maxWebSocketBackpressureBytes` | `number` | larger of `16_777_216` and `maxBodyBytes` | Maximum bytes buffered per WebSocket connection before the server closes it so the client reconnects rather than losing a frame silently; must be at least `maxBodyBytes` so a single frame fits, and no larger than `4_294_967_295` |
|
|
1063
|
+
| `cdcRetentionMs` | `number` | `3_600_000` | How long change events are retained for WebSocket CDC subscriptions; bounds both change-log growth and how far back a reconnecting subscriber can resume with `sinceSeq` |
|
|
929
1064
|
| `onRequest` | `OnRequestHook` | - | Middleware hook for auth, rate limiting, and request validation |
|
|
930
1065
|
|
|
931
1066
|
### `ClientOptions`
|
|
@@ -984,7 +1119,7 @@ pnpm run dev
|
|
|
984
1119
|
|
|
985
1120
|
## Benchmarks
|
|
986
1121
|
|
|
987
|
-
The benchmark suite compares Sirannon
|
|
1122
|
+
The benchmark suite compares Sirannon against Postgres 17 on the same OLTP workloads, driving Sirannon over HTTP into its real server and Postgres over its socket, both in resource-capped containers at matched durability, under an open-loop load generator that corrects for coordinated omission. The harness is a Python project at the repository root under `benchmarks/server`. See [`BENCHMARKS.md`](../../BENCHMARKS.md) for the methodology and the latest results.
|
|
988
1123
|
|
|
989
1124
|
## Development
|
|
990
1125
|
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { e as SQLiteConnection, f as BackupScheduleOptions } from '../types-D_hQW1hr.js';
|
|
2
|
+
|
|
3
|
+
declare class BackupManager {
|
|
4
|
+
backup(conn: SQLiteConnection, destPath: string): Promise<void>;
|
|
5
|
+
generateFilename(): string;
|
|
6
|
+
rotate(dir: string, maxFiles: number): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Runs a backup while the caller's writer lock is held, so it never shares the writer connection with another write. */
|
|
10
|
+
type RunExclusive = (op: () => Promise<void>) => Promise<void>;
|
|
11
|
+
declare class BackupScheduler {
|
|
12
|
+
private readonly manager;
|
|
13
|
+
constructor(manager?: BackupManager);
|
|
14
|
+
schedule(conn: SQLiteConnection, options: BackupScheduleOptions, runExclusive?: RunExclusive): () => void;
|
|
15
|
+
private run;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { BackupScheduleOptions, BackupScheduler };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { BackupScheduler } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
export { BackupScheduler } from '../chunk-VLTICJOD.mjs';
|
|
2
|
+
import '../chunk-YPYVQJ4C.mjs';
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { C as ChangeEvent } from './types-D-74JiXb.js';
|
|
1
|
+
import { e as SQLiteConnection, C as ChangeEvent } from './types-D_hQW1hr.js';
|
|
3
2
|
|
|
4
3
|
interface ChangeTrackerOptions {
|
|
5
4
|
retention?: number;
|
|
@@ -70,6 +69,17 @@ declare class ChangeTracker {
|
|
|
70
69
|
*/
|
|
71
70
|
pruneDroppedTables(conn: SQLiteConnection, tables: readonly string[]): Promise<void>;
|
|
72
71
|
poll(conn: SQLiteConnection): Promise<ChangeEvent[]>;
|
|
72
|
+
/** The highest seq already polled; live subscribers receive events beyond it. */
|
|
73
|
+
get cursor(): bigint;
|
|
74
|
+
/**
|
|
75
|
+
* Reads retained changes for one table with seq in `(afterSeq, upToSeq]`,
|
|
76
|
+
* ordered ascending and capped at `limit`. Used to replay history to a
|
|
77
|
+
* resuming subscriber without disturbing the shared poll cursor.
|
|
78
|
+
*/
|
|
79
|
+
readSince(conn: SQLiteConnection, table: string, afterSeq: bigint, upToSeq: bigint, limit: number): Promise<ChangeEvent[]>;
|
|
80
|
+
/** The lowest retained seq, or `null` when the change log is empty. */
|
|
81
|
+
getMinSeq(conn: SQLiteConnection): Promise<bigint | null>;
|
|
82
|
+
private rowToEvent;
|
|
73
83
|
advanceToLatest(conn: SQLiteConnection): Promise<void>;
|
|
74
84
|
cleanup(conn: SQLiteConnection): Promise<number>;
|
|
75
85
|
setPruneBoundary(seq: bigint): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/core/worker/protocol.ts
|
|
2
|
+
var WORKER_CANCELLED_CODE = "WRITER_WORKER_CANCELLED";
|
|
3
|
+
function serializeError(err) {
|
|
4
|
+
if (err instanceof Error) {
|
|
5
|
+
const code = err.code;
|
|
6
|
+
return {
|
|
7
|
+
message: err.message,
|
|
8
|
+
name: err.name,
|
|
9
|
+
...typeof code === "string" ? { code } : {}
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
return { message: String(err) };
|
|
13
|
+
}
|
|
14
|
+
function deserializeError(error) {
|
|
15
|
+
const err = new Error(error.message);
|
|
16
|
+
if (error.name) err.name = error.name;
|
|
17
|
+
if (error.code) err.code = error.code;
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { WORKER_CANCELLED_CODE, deserializeError, serializeError };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SirannonError } from './chunk-YPYVQJ4C.mjs';
|
|
2
|
+
|
|
3
|
+
// src/core/driver/define.ts
|
|
4
|
+
function defineDriver(config) {
|
|
5
|
+
if (!config.capabilities || typeof config.open !== "function") {
|
|
6
|
+
throw new SirannonError("Driver must define capabilities and open()", "INVALID_DRIVER");
|
|
7
|
+
}
|
|
8
|
+
if (config.worker !== void 0 && typeof config.worker.specifier !== "string") {
|
|
9
|
+
throw new SirannonError("Driver worker entry must define a string specifier", "INVALID_DRIVER");
|
|
10
|
+
}
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
capabilities: Object.freeze({ ...config.capabilities }),
|
|
13
|
+
open: config.open,
|
|
14
|
+
...config.worker ? { worker: Object.freeze({ ...config.worker }) } : {},
|
|
15
|
+
...config.startWriterHost ? { startWriterHost: config.startWriterHost } : {},
|
|
16
|
+
...config.createWriterContext ? { createWriterContext: config.createWriterContext } : {},
|
|
17
|
+
...config.createBackupEngine ? { createBackupEngine: config.createBackupEngine } : {},
|
|
18
|
+
...config.resolveExtensionPath ? { resolveExtensionPath: config.resolveExtensionPath } : {}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { defineDriver };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SirannonError } from './chunk-YPYVQJ4C.mjs';
|
|
2
|
+
|
|
3
|
+
// src/core/driver/synchronous.ts
|
|
4
|
+
var DEFAULT_SYNCHRONOUS = "normal";
|
|
5
|
+
var SYNCHRONOUS_PRAGMA_VALUES = {
|
|
6
|
+
off: "OFF",
|
|
7
|
+
normal: "NORMAL",
|
|
8
|
+
full: "FULL",
|
|
9
|
+
extra: "EXTRA"
|
|
10
|
+
};
|
|
11
|
+
function synchronousPragmaValue(level) {
|
|
12
|
+
const resolved = level ?? DEFAULT_SYNCHRONOUS;
|
|
13
|
+
const value = SYNCHRONOUS_PRAGMA_VALUES[resolved];
|
|
14
|
+
if (value === void 0) {
|
|
15
|
+
throw new SirannonError(
|
|
16
|
+
`Invalid synchronous level '${String(resolved)}': expected 'off', 'normal', 'full', or 'extra'`,
|
|
17
|
+
"INVALID_SYNCHRONOUS"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function isSynchronousLevel(value) {
|
|
23
|
+
return value === "off" || value === "normal" || value === "full" || value === "extra";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { DEFAULT_SYNCHRONOUS, isSynchronousLevel, synchronousPragmaValue };
|