@delali/sirannon-db 0.1.7 → 0.2.0
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 +117 -969
- package/dist/backup-scheduler/index.d.ts +1 -1
- package/dist/backup-scheduler/index.mjs +2 -2
- package/dist/baseline-Br77Fnhb.d.ts +6 -0
- package/dist/{change-tracker-CbmaMO-N.d.ts → change-tracker-DKRVUC3l.d.ts} +5 -8
- package/dist/{chunk-DJLX6CAE.mjs → chunk-4ISB7XMA.mjs} +2 -1
- package/dist/chunk-5NOIGN5Y.mjs +18 -0
- package/dist/chunk-67M7KAH6.mjs +90 -0
- package/dist/{chunk-GEZUUIKV.mjs → chunk-D7LAYTKN.mjs} +5 -2
- package/dist/{chunk-4IGMIJQK.mjs → chunk-EBJXPQQO.mjs} +59 -20
- package/dist/{chunk-BNUTBHHH.mjs → chunk-FHWTZFI4.mjs} +1 -1
- package/dist/chunk-GVCNMPOS.mjs +130 -0
- package/dist/chunk-H237TXZW.mjs +1407 -0
- package/dist/{chunk-HHRMRFFR.mjs → chunk-H6PIVVDN.mjs} +353 -142
- package/dist/chunk-HR5CWTLC.mjs +661 -0
- package/dist/chunk-JU64Y7HM.mjs +299 -0
- package/dist/{chunk-CW6S3WL5.mjs → chunk-JZGINXTN.mjs} +7 -5
- package/dist/{chunk-CJLYFDP5.mjs → chunk-LFZ37BSX.mjs} +1 -1
- package/dist/chunk-LNY2VVHE.mjs +18 -0
- package/dist/chunk-NVQS53NT.mjs +665 -0
- package/dist/{chunk-H5AB6NIR.mjs → chunk-O7SLN3GI.mjs} +2 -15
- package/dist/chunk-OQVZBEBY.mjs +283 -0
- package/dist/{chunk-VLTICJOD.mjs → chunk-PIKHN33N.mjs} +1 -1
- package/dist/chunk-TJF5GZSV.mjs +187 -0
- package/dist/chunk-TUD5CJ76.mjs +126 -0
- package/dist/{chunk-YPYVQJ4C.mjs → chunk-UC3SCMIN.mjs} +9 -1
- package/dist/client/index.d.ts +216 -321
- package/dist/client/index.mjs +1192 -1342
- package/dist/client/topology.d.ts +61 -0
- package/dist/client/topology.mjs +682 -0
- package/dist/client-base-CLWmH5Ln.d.ts +234 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.mjs +11 -0
- package/dist/codegen/index.d.ts +30 -0
- package/dist/codegen/index.mjs +5 -0
- package/dist/core/index.d.ts +37 -16
- package/dist/core/index.mjs +2322 -298
- package/dist/core/writer-worker.mjs +28 -6
- package/dist/{database-DuGp0Rtr.d.ts → database-BY0L5Q2n.d.ts} +59 -14
- package/dist/driver/better-sqlite3.d.ts +1 -1
- package/dist/driver/better-sqlite3.mjs +6 -6
- package/dist/driver/node.d.ts +1 -1
- package/dist/driver/node.mjs +6 -6
- package/dist/driver/wa-sqlite.d.ts +1 -136
- package/dist/{errors-5Nf5ZAEC.d.ts → errors-Bw5MdNCu.d.ts} +5 -1
- package/dist/file-migrations/index.d.ts +7 -5
- package/dist/file-migrations/index.mjs +42 -37
- package/dist/operation-registry-9DcvxcE5.d.ts +45 -0
- package/dist/primary-wins-DPAm2AKG.d.ts +61 -0
- package/dist/protocol-6KrSq2Hy.d.ts +66 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/index.mjs +137 -0
- package/dist/replication/coordinator/etcd.d.ts +5 -3
- package/dist/replication/coordinator/etcd.mjs +554 -462
- package/dist/replication/index.d.ts +51 -105
- package/dist/replication/index.mjs +1415 -2061
- package/dist/server/index.d.ts +111 -87
- package/dist/server/index.mjs +1917 -489
- package/dist/server-options-1JHu8pid.d.ts +191 -0
- package/dist/{sirannon-4SspRvP5.d.ts → sirannon-DaQSyhbJ.d.ts} +7 -2
- package/dist/transport/grpc.d.ts +7 -4
- package/dist/transport/grpc.mjs +191 -97
- package/dist/transport/memory.d.ts +5 -134
- package/dist/transport/memory.mjs +3 -1
- package/dist/{types-BsjobKbl.d.ts → types-B7gmEsZW.d.ts} +8 -61
- package/dist/{types-BEu1I_9_.d.ts → types-BsVabqSI.d.ts} +1 -1
- package/dist/types-CL6piSnD.d.ts +43 -0
- package/dist/types-C_D8IhpO.d.ts +60 -0
- package/dist/{types-D_hQW1hr.d.ts → types-zhnRXrsb.d.ts} +55 -165
- package/package.json +44 -6
- package/dist/chunk-TKGHYWQ6.mjs +0 -35
- package/dist/types-D4p4UyDK.d.ts +0 -26
package/README.md
CHANGED
|
@@ -6,57 +6,39 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/@delali/sirannon-db)
|
|
7
7
|
[](https://github.com/assetcorp/sirannon-db/blob/main/LICENSE)
|
|
8
8
|
|
|
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,
|
|
9
|
+
Build a networked SQLite service with connection pooling, change data capture, live queries, migrations, backups, device sync, and a client SDK. Applications reach Sirannon over HTTP or WebSocket, and Sirannon nodes replicate primary-owned changes over gRPC.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**Read the full documentation at [sirannon.sondelali.com/docs](https://sirannon.sondelali.com/docs).** This page gets you running, and the [guides](#documentation) hold the reference depth. Benchmarks against Postgres 17 are in [BENCHMARKS.md](../../BENCHMARKS.md).
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The core engine, server, client, and primary-replica replication are stable. Coordinator-backed failover, device sync, and the Bun and Expo drivers are experimental.
|
|
14
14
|
|
|
15
15
|
> *sirannon* means 'gate-stream' in Sindarin.
|
|
16
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
|
-
|
|
31
17
|
## Install
|
|
32
18
|
|
|
33
19
|
```bash
|
|
34
20
|
pnpm add -E @delali/sirannon-db
|
|
35
21
|
```
|
|
36
22
|
|
|
37
|
-
Then
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
pnpm add -E better-sqlite3 # Node.js
|
|
41
|
-
pnpm add -E wa-sqlite # Browser (IndexedDB persistence)
|
|
42
|
-
pnpm add -E expo-sqlite # React Native (Expo)
|
|
43
|
-
# Node 22+ built-in sqlite and Bun need no extra package
|
|
44
|
-
```
|
|
23
|
+
Then add the SQLite driver for your runtime:
|
|
45
24
|
|
|
46
|
-
|
|
25
|
+
| Driver | Import | Runtime | Install |
|
|
26
|
+
| --- | --- | --- | --- |
|
|
27
|
+
| better-sqlite3 | `@delali/sirannon-db/driver/better-sqlite3` | Node.js | `pnpm add -E better-sqlite3` |
|
|
28
|
+
| Node built-in | `@delali/sirannon-db/driver/node` | Node.js >= 22 | None (flag-free from 22.13.0 and 23.4.0) |
|
|
29
|
+
| wa-sqlite | `@delali/sirannon-db/driver/wa-sqlite` | Browser (IndexedDB) | `pnpm add -E wa-sqlite` |
|
|
30
|
+
| Bun | `@delali/sirannon-db/driver/bun` | Bun | None (uses `bun:sqlite`) |
|
|
31
|
+
| Expo | `@delali/sirannon-db/driver/expo` | React Native | `pnpm add -E expo-sqlite` |
|
|
47
32
|
|
|
48
|
-
|
|
33
|
+
Write a custom driver by passing `capabilities` and an `open` function to `defineDriver`.
|
|
49
34
|
|
|
50
|
-
|
|
51
|
-
pnpm add -E @delali/sirannon-db better-sqlite3
|
|
52
|
-
```
|
|
35
|
+
## Quick start
|
|
53
36
|
|
|
54
37
|
```ts
|
|
55
38
|
import { Sirannon } from '@delali/sirannon-db'
|
|
56
39
|
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
|
|
57
40
|
|
|
58
|
-
const
|
|
59
|
-
const sirannon = new Sirannon({ driver })
|
|
41
|
+
const sirannon = new Sirannon({ driver: betterSqlite3() })
|
|
60
42
|
const db = await sirannon.open('app', './data/app.db')
|
|
61
43
|
|
|
62
44
|
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)')
|
|
@@ -65,199 +47,57 @@ await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', 'ada@e
|
|
|
65
47
|
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')
|
|
66
48
|
```
|
|
67
49
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
import { nodeSqlite } from '@delali/sirannon-db/driver/node'
|
|
72
|
-
|
|
73
|
-
const driver = nodeSqlite()
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### Browser
|
|
77
|
-
|
|
78
|
-
```bash
|
|
79
|
-
pnpm add -E @delali/sirannon-db wa-sqlite
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
The browser driver persists data to IndexedDB through a WebAssembly SQLite build. Use `Database.create` directly since `Sirannon` registries are designed for server-side use.
|
|
50
|
+
In the browser, open the database directly and use one read connection, because the `Sirannon` registry is built for server-side use:
|
|
83
51
|
|
|
84
52
|
```ts
|
|
85
53
|
import { Database } from '@delali/sirannon-db'
|
|
86
54
|
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
|
|
87
55
|
|
|
88
56
|
const driver = waSqlite({ vfs: 'IDBBatchAtomicVFS' })
|
|
89
|
-
const db = await Database.create('app', '/app.db', driver, {
|
|
90
|
-
readPoolSize: 1,
|
|
91
|
-
walMode: false,
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)')
|
|
95
|
-
await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', 'ada@example.com'])
|
|
96
|
-
|
|
97
|
-
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
### React Native (Expo)
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
pnpm add -E @delali/sirannon-db expo-sqlite
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
```ts
|
|
107
|
-
import { Sirannon } from '@delali/sirannon-db'
|
|
108
|
-
import { expoSqlite } from '@delali/sirannon-db/driver/expo'
|
|
109
|
-
|
|
110
|
-
const driver = expoSqlite()
|
|
111
|
-
const sirannon = new Sirannon({ driver })
|
|
112
|
-
const db = await sirannon.open('app', 'app.db', {
|
|
113
|
-
readPoolSize: 1,
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
await db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)')
|
|
117
|
-
await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Ada', 'ada@example.com'])
|
|
118
|
-
|
|
119
|
-
const users = await db.query<{ id: number; name: string }>('SELECT * FROM users')
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
### Bun
|
|
123
|
-
|
|
124
|
-
You need no extra dependency, because Bun includes `bun:sqlite`.
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
import { Sirannon } from '@delali/sirannon-db'
|
|
128
|
-
import { bunSqlite } from '@delali/sirannon-db/driver/bun'
|
|
129
|
-
|
|
130
|
-
const driver = bunSqlite()
|
|
131
|
-
const sirannon = new Sirannon({ driver })
|
|
132
|
-
const db = await sirannon.open('app', './data/app.db')
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
### Standalone databases
|
|
136
|
-
|
|
137
|
-
You can create databases without a `Sirannon` registry on any platform:
|
|
138
|
-
|
|
139
|
-
```ts
|
|
140
|
-
const db = await Database.create('app', './data/app.db', driver)
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
## Pluggable drivers
|
|
144
|
-
|
|
145
|
-
Sirannon-db separates the database engine from the library. You pick the driver that fits your runtime, and the rest of the API stays the same.
|
|
146
|
-
|
|
147
|
-
| Driver | Import | Runtime | Install |
|
|
148
|
-
| --- | --- | --- | --- |
|
|
149
|
-
| better-sqlite3 | `@delali/sirannon-db/driver/better-sqlite3` | Node.js | `pnpm add -E better-sqlite3` |
|
|
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) |
|
|
151
|
-
| wa-sqlite | `@delali/sirannon-db/driver/wa-sqlite` | Browser | `pnpm add -E wa-sqlite` |
|
|
152
|
-
| Bun | `@delali/sirannon-db/driver/bun` | Bun | None (uses `bun:sqlite`) |
|
|
153
|
-
| Expo | `@delali/sirannon-db/driver/expo` | React Native | `pnpm add -E expo-sqlite` |
|
|
154
|
-
|
|
155
|
-
```ts
|
|
156
|
-
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
|
|
157
|
-
const driver = betterSqlite3()
|
|
158
|
-
|
|
159
|
-
// or for Node 22's built-in sqlite:
|
|
160
|
-
import { nodeSqlite } from '@delali/sirannon-db/driver/node'
|
|
161
|
-
const driver = nodeSqlite()
|
|
162
|
-
|
|
163
|
-
// or for browser with IndexedDB persistence:
|
|
164
|
-
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
|
|
165
|
-
const driver = waSqlite({ vfs: 'IDBBatchAtomicVFS' })
|
|
57
|
+
const db = await Database.create('app', '/app.db', driver, { readPoolSize: 1, walMode: false })
|
|
166
58
|
```
|
|
167
59
|
|
|
168
|
-
|
|
60
|
+
React Native uses the same shape through `expoSqlite()` with `readPoolSize: 1`.
|
|
169
61
|
|
|
170
62
|
## Package exports
|
|
171
63
|
|
|
172
|
-
The package provides independent exports so you only bundle what you need:
|
|
173
|
-
|
|
174
64
|
| Import | What you get |
|
|
175
65
|
| --- | --- |
|
|
176
|
-
| `@delali/sirannon-db` | Core library: queries, transactions, CDC, migrations, backups, hooks, metrics, lifecycle |
|
|
177
|
-
| `@delali/sirannon-db/driver/*` | SQLite driver adapters (see table above) |
|
|
178
|
-
| `@delali/sirannon-db/file-migrations` | Load `.up.sql`
|
|
179
|
-
| `@delali/sirannon-db/backup-scheduler` | Cron-scheduled backup runner with file rotation
|
|
180
|
-
| `@delali/sirannon-db/server` | HTTP
|
|
181
|
-
| `@delali/sirannon-db/client` |
|
|
66
|
+
| `@delali/sirannon-db` | Core library: queries, transactions, CDC, live queries, migrations, backups, hooks, metrics, lifecycle |
|
|
67
|
+
| `@delali/sirannon-db/driver/*` | SQLite driver adapters (see the table above) |
|
|
68
|
+
| `@delali/sirannon-db/file-migrations` | Load `.up.sql` and `.down.sql` files from a directory |
|
|
69
|
+
| `@delali/sirannon-db/backup-scheduler` | Cron-scheduled backup runner with file rotation |
|
|
70
|
+
| `@delali/sirannon-db/server` | HTTP and WebSocket server powered by uWebSockets.js |
|
|
71
|
+
| `@delali/sirannon-db/client` | Client SDK with auto-reconnect, subscription restore, and device sync |
|
|
72
|
+
| `@delali/sirannon-db/client/topology` | Topology-aware client that routes across a replication group |
|
|
73
|
+
| `@delali/sirannon-db/react` | `useLiveQuery` and `useCommand` hooks |
|
|
74
|
+
| `@delali/sirannon-db/codegen` | Typed operation references generated from your server's registry |
|
|
182
75
|
| `@delali/sirannon-db/replication` | Replication engine, conflict resolvers, topologies, HLC |
|
|
183
|
-
| `@delali/sirannon-db/replication/coordinator/etcd` | etcd-backed
|
|
76
|
+
| `@delali/sirannon-db/replication/coordinator/etcd` | etcd-backed coordinator for primary authority and failover |
|
|
184
77
|
| `@delali/sirannon-db/transport/grpc` | gRPC replication transport with TLS support |
|
|
185
78
|
| `@delali/sirannon-db/transport/memory` | In-memory transport for testing |
|
|
186
79
|
|
|
187
|
-
##
|
|
188
|
-
|
|
189
|
-
### Queries and transactions
|
|
80
|
+
## Queries and transactions
|
|
190
81
|
|
|
191
82
|
```ts
|
|
192
83
|
const row = await db.queryOne<{ count: number }>('SELECT count(*) as count FROM users')
|
|
193
84
|
|
|
194
|
-
const result = await db.execute(
|
|
195
|
-
'INSERT INTO users (name, email) VALUES (?, ?)',
|
|
196
|
-
['Grace', 'grace@example.com'],
|
|
197
|
-
)
|
|
198
|
-
// result.changes === 1, result.lastInsertRowId === 2
|
|
85
|
+
const result = await db.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['Grace', 'grace@example.com'])
|
|
199
86
|
|
|
200
|
-
await db.executeBatch('INSERT INTO tags (label) VALUES (?)', [
|
|
201
|
-
['typescript'],
|
|
202
|
-
['sqlite'],
|
|
203
|
-
['realtime'],
|
|
204
|
-
])
|
|
87
|
+
await db.executeBatch('INSERT INTO tags (label) VALUES (?)', [['typescript'], ['sqlite'], ['realtime']])
|
|
205
88
|
|
|
206
|
-
const
|
|
89
|
+
const balance = await db.transaction(async tx => {
|
|
207
90
|
await tx.execute('UPDATE accounts SET balance = balance - 100 WHERE id = ?', [1])
|
|
208
91
|
await tx.execute('UPDATE accounts SET balance = balance + 100 WHERE id = ?', [2])
|
|
209
|
-
|
|
210
|
-
return row
|
|
92
|
+
return tx.queryOne<{ balance: number }>('SELECT balance FROM accounts WHERE id = ?', [2])
|
|
211
93
|
})
|
|
212
94
|
```
|
|
213
95
|
|
|
214
|
-
|
|
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
|
-
|
|
247
|
-
### Connection pooling
|
|
248
|
-
|
|
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.
|
|
250
|
-
|
|
251
|
-
```ts
|
|
252
|
-
const db = await sirannon.open('analytics', './data/analytics.db', {
|
|
253
|
-
readPoolSize: 8,
|
|
254
|
-
walMode: true,
|
|
255
|
-
})
|
|
256
|
-
```
|
|
96
|
+
A large import runs faster through `bulkLoad`, which trades durability for speed inside one transaction and restores the configured level afterwards. The [core engine guide](../../docs/core.md) covers it, along with migrations, backups, hooks, metrics, and the multi-tenant lifecycle.
|
|
257
97
|
|
|
258
|
-
|
|
98
|
+
## Change data capture and live queries
|
|
259
99
|
|
|
260
|
-
|
|
100
|
+
A subscription reports the rows that changed:
|
|
261
101
|
|
|
262
102
|
```ts
|
|
263
103
|
await db.watch('orders')
|
|
@@ -265,842 +105,150 @@ await db.watch('orders')
|
|
|
265
105
|
const subscription = db
|
|
266
106
|
.on('orders')
|
|
267
107
|
.filter({ status: 'shipped' })
|
|
268
|
-
.subscribe(event =>
|
|
269
|
-
// event.type: 'insert' | 'update' | 'delete'
|
|
270
|
-
// event.row: the current row
|
|
271
|
-
// event.oldRow: previous row (updates and deletes)
|
|
272
|
-
// event.seq: monotonic sequence number
|
|
273
|
-
console.log(`Order ${event.row.id} was ${event.type}d`)
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
// Stop listening:
|
|
277
|
-
subscription.unsubscribe()
|
|
278
|
-
|
|
279
|
-
// Stop tracking entirely:
|
|
280
|
-
await db.unwatch('orders')
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
### Migrations
|
|
284
|
-
|
|
285
|
-
Place numbered SQL files in a directory using the `.up.sql` / `.down.sql` convention. Each migration runs inside a transaction and is tracked in a `_sirannon_migrations` table so it only applies once. Down files are optional; rollback throws if a down file is missing for a version being rolled back.
|
|
286
|
-
|
|
287
|
-
```txt
|
|
288
|
-
migrations/
|
|
289
|
-
001_create_users.up.sql
|
|
290
|
-
001_create_users.down.sql
|
|
291
|
-
002_add_email_index.up.sql
|
|
292
|
-
003_create_orders.up.sql
|
|
293
|
-
003_create_orders.down.sql
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
Timestamp-based versioning works the same way:
|
|
297
|
-
|
|
298
|
-
```txt
|
|
299
|
-
migrations/
|
|
300
|
-
1709312400_create_users.up.sql
|
|
301
|
-
1709312400_create_users.down.sql
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
#### File-based migrations
|
|
305
|
-
|
|
306
|
-
```ts
|
|
307
|
-
import { loadMigrations } from '@delali/sirannon-db/file-migrations'
|
|
308
|
-
|
|
309
|
-
const migrations = loadMigrations('./migrations')
|
|
310
|
-
const result = await db.migrate(migrations)
|
|
311
|
-
// result.applied: entries that ran this time
|
|
312
|
-
// result.skipped: number of entries already applied
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
#### Rollback
|
|
316
|
-
|
|
317
|
-
```ts
|
|
318
|
-
const migrations = loadMigrations('./migrations')
|
|
319
|
-
await db.rollback(migrations) // undo the last applied migration
|
|
320
|
-
await db.rollback(migrations, 2) // undo all migrations after version 2
|
|
321
|
-
await db.rollback(migrations, 0) // undo everything
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
#### Programmatic migrations
|
|
325
|
-
|
|
326
|
-
Pass an array of migration objects instead of loading from files:
|
|
327
|
-
|
|
328
|
-
```ts
|
|
329
|
-
const migrations = [
|
|
330
|
-
{
|
|
331
|
-
version: 1,
|
|
332
|
-
name: 'create_users',
|
|
333
|
-
up: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)',
|
|
334
|
-
down: 'DROP TABLE users',
|
|
335
|
-
},
|
|
336
|
-
]
|
|
337
|
-
|
|
338
|
-
await db.migrate(migrations)
|
|
339
|
-
await db.rollback(migrations) // undo last migration
|
|
340
|
-
await db.rollback(migrations, 0) // undo everything
|
|
108
|
+
.subscribe(event => console.log(event.type, event.table, event.row, event.oldRow, event.seq))
|
|
341
109
|
```
|
|
342
110
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
One-shot backups use `VACUUM INTO` for a consistent snapshot. Scheduled backups run on a cron expression with automatic file rotation.
|
|
111
|
+
A live query reports the current answer, updating the rows it holds from those same events:
|
|
346
112
|
|
|
347
113
|
```ts
|
|
348
|
-
await db.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
destDir: './backups',
|
|
353
|
-
maxFiles: 10, // keep the 10 most recent
|
|
354
|
-
timezone: 'America/New_York', // optional; defaults to the host timezone
|
|
355
|
-
onError: err => console.error('Backup failed:', err),
|
|
356
|
-
})
|
|
357
|
-
```
|
|
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
|
-
|
|
361
|
-
### Hooks
|
|
362
|
-
|
|
363
|
-
Hooks run before or after key operations. Throwing from a before-hook denies the operation.
|
|
364
|
-
|
|
365
|
-
```ts
|
|
366
|
-
sirannon.onBeforeQuery(ctx => {
|
|
367
|
-
if (ctx.sql.includes('DROP')) {
|
|
368
|
-
throw new Error('DROP statements are not allowed')
|
|
369
|
-
}
|
|
370
|
-
})
|
|
371
|
-
|
|
372
|
-
sirannon.onAfterQuery(ctx => {
|
|
373
|
-
console.log(`[${ctx.databaseId}] ${ctx.sql} took ${ctx.durationMs}ms`)
|
|
374
|
-
})
|
|
114
|
+
const pending = await db.live<{ id: number; total: number }>(
|
|
115
|
+
'SELECT id, total FROM orders WHERE status = ? ORDER BY id',
|
|
116
|
+
['pending'],
|
|
117
|
+
)
|
|
375
118
|
|
|
376
|
-
|
|
377
|
-
console.log(`Opened ${ctx.databaseId} at ${ctx.path}`)
|
|
378
|
-
})
|
|
119
|
+
pending.subscribe(() => render(pending.getState()))
|
|
379
120
|
```
|
|
380
121
|
|
|
381
|
-
|
|
122
|
+
The [live queries guide](../../docs/live-queries.md) covers the update kinds, the statements a live query maintains, and the React hooks.
|
|
382
123
|
|
|
383
|
-
|
|
124
|
+
## Serve it over the network
|
|
384
125
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
Plug in callbacks to collect query timing, connection events, and CDC activity.
|
|
126
|
+
A server accepts no SQL from the network by default. Register the reads and writes it runs, and callers invoke them by name:
|
|
388
127
|
|
|
389
128
|
```ts
|
|
390
|
-
|
|
391
|
-
driver,
|
|
392
|
-
metrics: {
|
|
393
|
-
onQueryComplete: m => histogram.observe(m.durationMs),
|
|
394
|
-
onConnectionOpen: m => gauge.inc({ db: m.databaseId }),
|
|
395
|
-
onConnectionClose: m => gauge.dec({ db: m.databaseId }),
|
|
396
|
-
onCDCEvent: m => counter.inc({ table: m.table, op: m.operation }),
|
|
397
|
-
},
|
|
398
|
-
})
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
### Lifecycle management
|
|
402
|
-
|
|
403
|
-
For multi-tenant setups, the lifecycle manager handles auto-opening, idle timeouts, and LRU eviction so you don't have to manage database handles yourself.
|
|
129
|
+
import { createServer } from '@delali/sirannon-db/server'
|
|
404
130
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
131
|
+
const server = createServer(sirannon, {
|
|
132
|
+
port: 9876,
|
|
133
|
+
operations: {
|
|
134
|
+
app: {
|
|
135
|
+
reads: {
|
|
136
|
+
activeUsers: {
|
|
137
|
+
columns: ['id', 'name'],
|
|
138
|
+
statement: () => ({ sql: 'SELECT id, name FROM users WHERE active = 1' }),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
writes: {
|
|
142
|
+
addUser: {
|
|
143
|
+
args: ['name'],
|
|
144
|
+
statements: ({ name }) => ({ sql: 'INSERT INTO users (name) VALUES (?)', params: [name] }),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
411
147
|
},
|
|
412
|
-
idleTimeout: 300_000, // close after 5 minutes of inactivity
|
|
413
|
-
maxOpen: 50, // evict least-recently-used when full
|
|
414
148
|
},
|
|
415
149
|
})
|
|
416
150
|
|
|
417
|
-
// Databases resolve on first access:
|
|
418
|
-
const db = await sirannon.resolve('tenant-42') // opens /data/tenants/tenant-42.db
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
## Server
|
|
422
|
-
|
|
423
|
-
Expose any `Sirannon` instance over HTTP and WebSocket with a single function call. The server uses uWebSockets.js for high throughput.
|
|
424
|
-
|
|
425
|
-
```ts
|
|
426
|
-
import { Sirannon } from '@delali/sirannon-db'
|
|
427
|
-
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
|
|
428
|
-
import { createServer } from '@delali/sirannon-db/server'
|
|
429
|
-
|
|
430
|
-
const driver = betterSqlite3()
|
|
431
|
-
const sirannon = new Sirannon({ driver })
|
|
432
|
-
await sirannon.open('app', './data/app.db')
|
|
433
|
-
|
|
434
|
-
const server = createServer(sirannon, { port: 9876 })
|
|
435
151
|
await server.listen()
|
|
436
152
|
```
|
|
437
153
|
|
|
438
|
-
See the [Security](#security) section for authentication, TLS, and CORS configuration.
|
|
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` rejects the caller when a single operation stalls past it, so a hung flush fails loudly instead of hanging a client; the worker keeps running, since a thread inside a synchronous SQLite call cannot be interrupted safely, so a stalled write's outcome is indeterminate and a genuinely dead disk keeps rejecting writes until you restart the process. Raise it only for unusually large single operations. `maxRestarts` caps how many times the worker is respawned after it crashes on its own before writes fail permanently.
|
|
473
|
-
|
|
474
|
-
### HTTP routes
|
|
475
|
-
|
|
476
|
-
| Method | Path | Description |
|
|
477
|
-
| --- | --- | --- |
|
|
478
|
-
| `POST` | `/db/:id/query` | Execute a SELECT, returns `{ rows }` |
|
|
479
|
-
| `POST` | `/db/:id/execute` | Execute a mutation, returns `{ changes, lastInsertRowId }` |
|
|
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` | `/health` | Liveness check |
|
|
484
|
-
| `GET` | `/health/ready` | Readiness check with per-database status |
|
|
485
|
-
|
|
486
|
-
### WebSocket protocol
|
|
487
|
-
|
|
488
|
-
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.
|
|
489
|
-
|
|
490
|
-
| Inbound `type` | Fields | Reply |
|
|
491
|
-
| --- | --- | --- |
|
|
492
|
-
| `query` | `sql`, `params?` | `{ type: 'result', data: { rows } }` |
|
|
493
|
-
| `execute` | `sql`, `params?` | `{ type: 'result', data: { changes, lastInsertRowId } }` |
|
|
494
|
-
| `transaction` | `statements`, `writeConcern?` | `{ type: 'result', data: { results } }` |
|
|
495
|
-
| `batch` | `sql`, `paramsBatch`, `writeConcern?` | `{ type: 'result', data: { results } }` |
|
|
496
|
-
| `load` | `sql`, `paramsBatch`, `durability?`, `checkpoint?` | `{ type: 'result', data: { rowsLoaded, changes } }` |
|
|
497
|
-
| `subscribe` | `table`, `filter?` | `{ type: 'subscribed' }` then `change` events |
|
|
498
|
-
| `unsubscribe` | - | `{ type: 'unsubscribed' }` |
|
|
499
|
-
|
|
500
|
-
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.
|
|
501
|
-
|
|
502
|
-
## Client SDK
|
|
503
|
-
|
|
504
|
-
The client SDK mirrors the core `Database` API with async methods. It supports both HTTP and WebSocket transports, with automatic reconnection and subscription restoration on the WebSocket transport.
|
|
505
|
-
|
|
506
154
|
```ts
|
|
507
155
|
import { SirannonClient } from '@delali/sirannon-db/client'
|
|
156
|
+
import { operationRef } from '@delali/sirannon-db'
|
|
508
157
|
|
|
509
|
-
const
|
|
510
|
-
transport: 'websocket',
|
|
511
|
-
autoReconnect: true,
|
|
512
|
-
reconnectInterval: 1000,
|
|
513
|
-
})
|
|
158
|
+
const activeUsers = operationRef<Record<string, never>, { id: number; name: string }>('activeUsers')
|
|
514
159
|
|
|
160
|
+
const client = new SirannonClient('http://localhost:9876', { transport: 'websocket', autoReconnect: true })
|
|
515
161
|
const db = client.database('app')
|
|
516
162
|
|
|
517
|
-
const users = await db.query
|
|
518
|
-
|
|
519
|
-
await db.execute('INSERT INTO users (name) VALUES (?)', ['Turing'])
|
|
520
|
-
|
|
521
|
-
const sub = await db.on('users').subscribe(event => {
|
|
522
|
-
console.log('User changed:', event)
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
// Cleanup:
|
|
526
|
-
sub.unsubscribe()
|
|
527
|
-
client.close()
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
Transactions use the HTTP transport:
|
|
531
|
-
|
|
532
|
-
```ts
|
|
533
|
-
const httpClient = new SirannonClient('http://localhost:9876', {
|
|
534
|
-
transport: 'http',
|
|
535
|
-
})
|
|
536
|
-
|
|
537
|
-
const httpDb = httpClient.database('app')
|
|
538
|
-
|
|
539
|
-
await httpDb.transaction([
|
|
540
|
-
{ sql: 'UPDATE accounts SET balance = balance - 50 WHERE id = ?', params: [1] },
|
|
541
|
-
{ sql: 'UPDATE accounts SET balance = balance + 50 WHERE id = ?', params: [2] },
|
|
542
|
-
])
|
|
543
|
-
|
|
544
|
-
httpClient.close()
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
## Distributed replication
|
|
548
|
-
|
|
549
|
-
<p align="center">
|
|
550
|
-
<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">
|
|
551
|
-
</p>
|
|
552
|
-
|
|
553
|
-
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.
|
|
554
|
-
|
|
555
|
-
```ts
|
|
556
|
-
import { ReplicationEngine } from '@delali/sirannon-db/replication'
|
|
557
|
-
import { InMemoryTransport, MemoryBus } from '@delali/sirannon-db/transport/memory'
|
|
558
|
-
import { GrpcReplicationTransport } from '@delali/sirannon-db/transport/grpc'
|
|
559
|
-
```
|
|
560
|
-
|
|
561
|
-
### Client and replication transports
|
|
562
|
-
|
|
563
|
-
Sirannon has two transport interfaces with different responsibilities:
|
|
564
|
-
|
|
565
|
-
| Traffic | Interface | Built-in network transport |
|
|
566
|
-
| --- | --- | --- |
|
|
567
|
-
| Application queries, writes, and CDC subscriptions | Client `Transport` | HTTP or WebSocket |
|
|
568
|
-
| Change batches, acknowledgements, write forwarding, and first sync between Sirannon nodes | `ReplicationTransport` | gRPC |
|
|
569
|
-
|
|
570
|
-
`WebSocketTransport` conforms to the client `Transport` interface. It connects an application to the Sirannon server and does not conform to `ReplicationTransport`. Use `GrpcReplicationTransport` for production node-to-node replication, or `InMemoryTransport` for tests and single-process scenarios.
|
|
571
|
-
|
|
572
|
-
### Primary-replica setup
|
|
573
|
-
|
|
574
|
-
One node accepts writes and pushes changes to read replicas. Replicas forward writes to the primary when `writeForwarding` is enabled.
|
|
575
|
-
|
|
576
|
-
```ts
|
|
577
|
-
import { ReplicationEngine, PrimaryReplicaTopology } from '@delali/sirannon-db/replication'
|
|
578
|
-
import { GrpcReplicationTransport } from '@delali/sirannon-db/transport/grpc'
|
|
579
|
-
|
|
580
|
-
const transport = new GrpcReplicationTransport({
|
|
581
|
-
host: '0.0.0.0',
|
|
582
|
-
port: 4200,
|
|
583
|
-
tlsCert: './certs/primary.crt',
|
|
584
|
-
tlsKey: './certs/primary.key',
|
|
585
|
-
tlsCaCert: './certs/ca.crt',
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
const engine = new ReplicationEngine(db, writerConn, {
|
|
589
|
-
nodeId: 'primary-us-east-1',
|
|
590
|
-
topology: new PrimaryReplicaTopology('primary'),
|
|
591
|
-
transport,
|
|
592
|
-
snapshotConnectionFactory: () => driver.open(dbPath, { readonly: true }),
|
|
593
|
-
changeTracker: tracker,
|
|
594
|
-
})
|
|
595
|
-
|
|
596
|
-
await engine.start()
|
|
597
|
-
|
|
598
|
-
await engine.execute('INSERT INTO orders (id, total) VALUES (?, ?)', [1, 4999])
|
|
599
|
-
|
|
600
|
-
const rows = await engine.query<{ id: number }>('SELECT * FROM orders')
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
On the replica side:
|
|
604
|
-
|
|
605
|
-
```ts
|
|
606
|
-
const replicaEngine = new ReplicationEngine(replicaDb, replicaConn, {
|
|
607
|
-
nodeId: 'replica-eu-west-1',
|
|
608
|
-
topology: new PrimaryReplicaTopology('replica'),
|
|
609
|
-
transport: replicaTransport,
|
|
610
|
-
transportConfig: { endpoints: ['primary.example.com:4200'] },
|
|
611
|
-
writeForwarding: true,
|
|
612
|
-
changeTracker: replicaTracker,
|
|
613
|
-
})
|
|
614
|
-
|
|
615
|
-
await replicaEngine.start()
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
When `initialSync` is `true` (the default), a new replica automatically pulls a full snapshot from the primary before accepting reads. The replica blocks reads and writes until the sync completes and incremental catch-up reaches the configured lag threshold.
|
|
619
|
-
|
|
620
|
-
### Coordinator-backed failover
|
|
621
|
-
|
|
622
|
-
Coordinator mode stores primary authority, node sessions, replication-group state, and the in-sync set in a `ClusterCoordinator`. The TypeScript package includes an etcd adapter:
|
|
623
|
-
|
|
624
|
-
```ts
|
|
625
|
-
import { readFileSync } from 'node:fs'
|
|
626
|
-
import { createEtcdCoordinator } from '@delali/sirannon-db/replication/coordinator/etcd'
|
|
627
|
-
|
|
628
|
-
const coordinator = createEtcdCoordinator({
|
|
629
|
-
hosts: ['https://etcd-1.internal:2379', 'https://etcd-2.internal:2379'],
|
|
630
|
-
keyPrefix: '/sirannon/orders',
|
|
631
|
-
credentials: {
|
|
632
|
-
rootCertificate: readFileSync('./certs/etcd-ca.crt'),
|
|
633
|
-
privateKey: readFileSync('./certs/orders-node.key'),
|
|
634
|
-
certChain: readFileSync('./certs/orders-node.crt'),
|
|
635
|
-
},
|
|
636
|
-
})
|
|
637
|
-
|
|
638
|
-
const engine = new ReplicationEngine(db, writerConn, {
|
|
639
|
-
nodeId: 'orders-node-a',
|
|
640
|
-
topology: new PrimaryReplicaTopology('primary'),
|
|
641
|
-
transport,
|
|
642
|
-
transportConfig: {
|
|
643
|
-
endpoints: ['orders-node-b.internal:4200', 'orders-node-c.internal:4200'],
|
|
644
|
-
protocolVersion: '1',
|
|
645
|
-
},
|
|
646
|
-
changeTracker: tracker,
|
|
647
|
-
snapshotConnectionFactory: () => driver.open(dbPath, { readonly: true }),
|
|
648
|
-
writeForwarding: true,
|
|
649
|
-
coordinator: {
|
|
650
|
-
clusterId: 'commerce-production',
|
|
651
|
-
groupId: 'orders',
|
|
652
|
-
endpoint: 'https://orders-node-a.internal/db/orders',
|
|
653
|
-
coordinator,
|
|
654
|
-
votingDataBearingNodeIds: ['orders-node-a', 'orders-node-b', 'orders-node-c'],
|
|
655
|
-
controller: true,
|
|
656
|
-
},
|
|
657
|
-
})
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
Every coordinator-mode node needs a stable, persisted `nodeId`. `votingDataBearingNodeIds` creates the replication group when it does not exist; later nodes read the registered group from etcd. Production coordinator access requires HTTPS plus an authenticated identity. The in-memory coordinator and `allowInsecure: true` are for tests and local development.
|
|
661
|
-
|
|
662
|
-
Automatic write failover needs at least three voting data-bearing nodes. With fewer than three voters, the controller cannot prove majority authority after losing a node and keeps writes unavailable.
|
|
663
|
-
|
|
664
|
-
### Conflict resolution
|
|
665
|
-
|
|
666
|
-
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.
|
|
667
|
-
|
|
668
|
-
The replication module includes three built-in strategies:
|
|
669
|
-
|
|
670
|
-
| Strategy | Class | Behaviour |
|
|
671
|
-
| --- | --- | --- |
|
|
672
|
-
| Last-Writer-Wins | `LWWResolver` | Selects the change with the higher HLC timestamp. Ties break by node ID. |
|
|
673
|
-
| Field-Level Merge | `FieldMergeResolver` | Merges non-overlapping columns and uses per-column HLC metadata for overlapping columns. Falls back to whole-row LWW when column metadata is unavailable. |
|
|
674
|
-
| Primary Wins | `PrimaryWinsResolver` | Selects the version authored by a configured primary node ID. Falls back to LWW when neither version came from that node. |
|
|
675
|
-
|
|
676
|
-
Custom resolvers can be built by creating a class with a `resolve(ctx: ConflictContext): ConflictResolution` method.
|
|
677
|
-
|
|
678
|
-
Coordinator mode quarantines a returning former primary when it contains local-only writes. It does not merge that history into the current primary or expose a force-promotion or high-level repair API. An operator must rebuild, restore, or otherwise remediate the faulted node before rejoining it.
|
|
679
|
-
|
|
680
|
-
### First sync
|
|
681
|
-
|
|
682
|
-
When a new node joins a running cluster, it needs the full dataset before it can process incremental changes. The sync protocol handles this automatically:
|
|
683
|
-
|
|
684
|
-
1. The joiner connects and sends a sync request to the source
|
|
685
|
-
2. The source opens a consistent read-only snapshot and sends schema DDL (CREATE TABLE, CREATE INDEX)
|
|
686
|
-
3. The source streams table data in configurable batches (default 10,000 rows) with per-batch checksums
|
|
687
|
-
4. After all data is transferred, the source sends a manifest with row counts and primary-key hashes
|
|
688
|
-
5. The joiner verifies the manifest, transitions to catch-up mode, and applies incremental changes accumulated during the transfer
|
|
689
|
-
6. Once the replication lag drops below `maxSyncLagBeforeReady`, the joiner starts serving reads
|
|
690
|
-
|
|
691
|
-
The state machine is: `pending` -> `syncing` -> `catching-up` -> `ready`. You can monitor it via `engine.status().syncState`.
|
|
692
|
-
|
|
693
|
-
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.
|
|
694
|
-
|
|
695
|
-
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:
|
|
696
|
-
|
|
697
|
-
```ts
|
|
698
|
-
const engine = new ReplicationEngine(db, writerConn, {
|
|
699
|
-
initialSync: false,
|
|
700
|
-
resumeFromSeq: 50000n,
|
|
701
|
-
// ...
|
|
702
|
-
})
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
### Write concerns
|
|
706
|
-
|
|
707
|
-
Control how many replicas must acknowledge a write before it returns:
|
|
708
|
-
|
|
709
|
-
```ts
|
|
710
|
-
await engine.execute(
|
|
711
|
-
'INSERT INTO orders (id, total) VALUES (?, ?)',
|
|
712
|
-
[1, 4999],
|
|
713
|
-
{ writeConcern: { level: 'majority', timeoutMs: 5000 } },
|
|
714
|
-
)
|
|
163
|
+
const users = await db.query(activeUsers, {})
|
|
164
|
+
const sub = await db.on('users').subscribe(event => console.log('User changed:', event))
|
|
715
165
|
```
|
|
716
166
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
In coordinator mode, `majority` is calculated from configured voting data-bearing nodes in the replication group, including the primary's local durable commit. It is not calculated from the peers currently connected to this process. A successful coordinator-mode `majority` write survives automatic primary failover when only the failed primary is lost and an eligible in-sync replica remains.
|
|
720
|
-
|
|
721
|
-
### Replication FAQ
|
|
722
|
-
|
|
723
|
-
#### Is this SQLite over a shared network file system?
|
|
724
|
-
|
|
725
|
-
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.
|
|
726
|
-
|
|
727
|
-
#### What is replicated?
|
|
728
|
-
|
|
729
|
-
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.
|
|
730
|
-
|
|
731
|
-
#### Is the protocol row-based, statement-based, operation-log based, or CRDT-like?
|
|
732
|
-
|
|
733
|
-
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.
|
|
734
|
-
|
|
735
|
-
#### What ordering model does it use?
|
|
736
|
-
|
|
737
|
-
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`.
|
|
738
|
-
|
|
739
|
-
#### What happens under partitions?
|
|
740
|
-
|
|
741
|
-
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.
|
|
742
|
-
|
|
743
|
-
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.
|
|
744
|
-
|
|
745
|
-
#### What topology do I need for automatic write failover?
|
|
746
|
-
|
|
747
|
-
Use at least three voting data-bearing Sirannon nodes in one replication group. One node has no failover. Two nodes can replicate, but one survivor cannot prove majority authority after the other node is lost.
|
|
748
|
-
|
|
749
|
-
#### Does Sirannon support schema changes across replicas?
|
|
750
|
-
|
|
751
|
-
Yes, with a safety allowlist. Replicated DDL supports `CREATE TABLE`, `ALTER TABLE ... ADD COLUMN`, `DROP TABLE`, `CREATE INDEX`, and `DROP INDEX`. The receiver rejects multiple statements, `AS SELECT`, extension loading, `ATTACH`, dangerous file functions, and DDL outside the allowlist.
|
|
752
|
-
|
|
753
|
-
#### How do foreign keys and unique constraints behave?
|
|
754
|
-
|
|
755
|
-
SQLite enforces constraints on each node. The primary serialises normal writes, which prevents normal concurrent unique-key conflicts. First sync orders tables by foreign-key dependency, and controlled resync disables foreign keys only while wiping tables before reloading from the sync source. Incoming replicated data still has to satisfy the receiving database's constraints.
|
|
756
|
-
|
|
757
|
-
#### Are reads consistent after writes?
|
|
758
|
-
|
|
759
|
-
Read concern controls this. `local` reads the selected node's local state. `majority` reads data that has reached the replication group's majority commit point. `linearizable` reads from the current primary after it proves live authority for the current primary term. If the requested read concern cannot be satisfied, the read fails rather than returning a weaker result.
|
|
760
|
-
|
|
761
|
-
#### Is this local-first or multi-writer today?
|
|
762
|
-
|
|
763
|
-
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.
|
|
764
|
-
|
|
765
|
-
### Transport options
|
|
766
|
-
|
|
767
|
-
| Transport | Import | Use case |
|
|
768
|
-
| --- | --- | --- |
|
|
769
|
-
| gRPC | `@delali/sirannon-db/transport/grpc` | Production Node.js multi-node replication over the network with TLS support. |
|
|
770
|
-
| In-Memory | `@delali/sirannon-db/transport/memory` | Testing and single-process multi-node scenarios. Messages delivered via microtask scheduling. |
|
|
771
|
-
| Custom | Build your own | Any transport that satisfies the `ReplicationTransport` interface (Redis, NATS, MQTT, TCP, etc). |
|
|
772
|
-
|
|
773
|
-
`ReplicationEngine.start()` derives `TransportConfig.localRole` from `topology.role`. In coordinator mode, it also supplies the current `groupId`, `primaryTerm`, and protocol version to the transport. Set these fields yourself only when you connect a `ReplicationTransport` without `ReplicationEngine`.
|
|
774
|
-
|
|
775
|
-
`TransportConfig` accepts these fields:
|
|
776
|
-
|
|
777
|
-
| Option | Type | Description |
|
|
778
|
-
| --- | --- | --- |
|
|
779
|
-
| `endpoints` | `string[]` | Peer addresses used to establish replication connections |
|
|
780
|
-
| `localRole` | `'primary' \| 'replica'` | Local topology role; `ReplicationEngine` supplies this value |
|
|
781
|
-
| `groupId` | `string` | Replication group carried in coordinator-mode handshakes; the engine supplies it from coordinator configuration |
|
|
782
|
-
| `primaryTerm` | `bigint` | Current fencing term; the engine supplies it from coordinator state |
|
|
783
|
-
| `protocolVersion` | `string` | Replication protocol version advertised to peers |
|
|
784
|
-
| `metadata` | `Record<string, unknown>` | Optional custom transport metadata |
|
|
785
|
-
|
|
786
|
-
### Replication configuration reference
|
|
787
|
-
|
|
788
|
-
| Option | Type | Default | Description |
|
|
789
|
-
| --- | --- | --- | --- |
|
|
790
|
-
| `nodeId` | `string` | auto-generated in static mode | Unique node identifier. Coordinator mode requires a stable, persisted value. |
|
|
791
|
-
| `topology` | `Topology` | required | `PrimaryReplicaTopology` |
|
|
792
|
-
| `transport` | `ReplicationTransport` | required | Transport for inter-node communication |
|
|
793
|
-
| `transportConfig` | `TransportConfig` | `{}` | Peer endpoints and transport metadata. The engine supplies role and coordinator fencing fields when it starts. |
|
|
794
|
-
| `writeForwarding` | `boolean` | `false` | Forward writes from replicas to the primary |
|
|
795
|
-
| `defaultConflictResolver` | `ConflictResolver` | `LWWResolver` | Default conflict resolution strategy |
|
|
796
|
-
| `conflictResolvers` | `Record<string, ConflictResolver>` | - | Per-table conflict resolution overrides |
|
|
797
|
-
| `batchSize` | `number` | `100` | Changes per replication batch |
|
|
798
|
-
| `batchIntervalMs` | `number` | `100` | Sender loop interval in ms |
|
|
799
|
-
| `maxClockDriftMs` | `number` | `60000` | Maximum tolerated HLC drift before rejecting a batch |
|
|
800
|
-
| `maxPendingBatches` | `number` | `10` | In-flight batches per peer before backpressure |
|
|
801
|
-
| `maxBatchChanges` | `number` | `1000` | Maximum accepted changes in one inbound batch |
|
|
802
|
-
| `ackTimeoutMs` | `number` | `5000` | Replication batch ack timeout |
|
|
803
|
-
| `initialSync` | `boolean` | `true` | Pull a full snapshot when joining a cluster |
|
|
804
|
-
| `syncBatchSize` | `number` | `10000` | Rows per sync batch during first sync |
|
|
805
|
-
| `maxConcurrentSyncs` | `number` | `2` | Maximum simultaneous sync sessions on the source |
|
|
806
|
-
| `maxSyncDurationMs` | `number` | `1800000` | Source aborts sync after this duration (30 min) |
|
|
807
|
-
| `maxSyncLagBeforeReady` | `number` | `100` | Catch-up lag threshold (in sequences) to transition to ready |
|
|
808
|
-
| `syncAckTimeoutMs` | `number` | `30000` | Per-batch ack timeout during sync (30s) |
|
|
809
|
-
| `catchUpDeadlineMs` | `number` | `600000` | Max time in catch-up phase before transitioning to ready (10 min) |
|
|
810
|
-
| `resumeFromSeq` | `bigint` | - | Start replication from a specific sequence (out-of-band sync) |
|
|
811
|
-
| `snapshotConnectionFactory` | `() => Promise<SQLiteConnection>` | - | Factory for read-only connections used during sync serving |
|
|
812
|
-
| `changeTracker` | `ChangeTracker` | - | CDC trigger manager, required for first sync |
|
|
813
|
-
| `flowControl` | `{ maxLagSeconds?, onLagExceeded? }` | - | Replication lag monitoring callbacks |
|
|
814
|
-
| `onBeforeForwardedQuery` | `(sql, params?) => void` | - | Validation or authorisation hook called before the primary executes each forwarded statement |
|
|
815
|
-
| `coordinator` | `CoordinatorModeConfig` | - | Enables coordinator-backed authority and failover |
|
|
816
|
-
| `snapshotThreshold` | `number` | - | Reserved configuration field; the current engine does not read it |
|
|
817
|
-
|
|
818
|
-
### Coordinator configuration reference
|
|
819
|
-
|
|
820
|
-
| Option | Type | Default | Description |
|
|
821
|
-
| --- | --- | --- | --- |
|
|
822
|
-
| `clusterId` | `string` | required | Coordinator namespace for the Sirannon cluster |
|
|
823
|
-
| `groupId` | `string` | required | Replication group containing copies of one database |
|
|
824
|
-
| `endpoint` | `string` | - | Application endpoint advertised for client discovery |
|
|
825
|
-
| `votingDataBearingNodeIds` | `string[]` | - | Voter set used to create an unregistered group and calculate coordinator write concerns |
|
|
826
|
-
| `coordinator` | `ClusterCoordinator` | required | Coordinator adapter, such as the etcd adapter |
|
|
827
|
-
| `sessionTtlMs` | `number` | `10000` | Node-session lease lifetime |
|
|
828
|
-
| `controller` | `boolean \| CoordinatorControllerConfig` | enabled | Enables the controller loop or configures its lease holder, TTL, and tick interval |
|
|
829
|
-
| `compatibility` | `CoordinatorCompatibilityMetadata` | - | Package, specification, and protocol versions used for promotion compatibility checks |
|
|
830
|
-
|
|
831
|
-
`CoordinatorControllerConfig` accepts `enabled`, `holderId`, `leaseTtlMs`, and `tickIntervalMs`. The lease TTL defaults to 10,000 ms, and the controller tick interval defaults to 1,000 ms.
|
|
832
|
-
|
|
833
|
-
### Replication errors
|
|
834
|
-
|
|
835
|
-
| Error | Code | When |
|
|
836
|
-
| --- | --- | --- |
|
|
837
|
-
| `ReplicationError` | `REPLICATION_ERROR` | Base class for replication failures |
|
|
838
|
-
| `SyncError` | `SYNC_ERROR` | First sync failures (node not ready, timeout, integrity mismatch) |
|
|
839
|
-
| `ConflictError` | `CONFLICT_ERROR` | Unresolvable write conflict |
|
|
840
|
-
| `TransportError` | `TRANSPORT_ERROR` | Inter-node communication failure |
|
|
841
|
-
| `BatchValidationError` | `BATCH_VALIDATION_ERROR` | Checksum mismatch, clock drift, or oversized batch |
|
|
842
|
-
| `TopologyError` | `TOPOLOGY_ERROR` | Write on a read-only node without forwarding |
|
|
843
|
-
| `WriteConcernError` | `WRITE_CONCERN_ERROR` | Quorum not reached within timeout |
|
|
167
|
+
Run `sirannon-codegen` to generate those references from the registry instead of writing them by hand, and set `acceptSql: true` when you want the server to run statements a client sends. The [registered operations guide](../../docs/operations.md) covers both, the [server guide](../../docs/server.md) lists the routes and messages, and the [client guide](../../docs/client.md) covers the transports.
|
|
844
168
|
|
|
845
169
|
## Security
|
|
846
170
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
### Built-in protections
|
|
850
|
-
|
|
851
|
-
- **Parameterised values** - Query and execute calls pass parameters through the driver layer. Keep user input in `params`; never concatenate user input into SQL strings.
|
|
852
|
-
- **Identifier validation** - CDC table and column names are validated against a strict allowlist regex (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`) and escaped with double quotes.
|
|
853
|
-
- **Path traversal prevention** - Migration and backup paths reject null bytes, `..` segments, and control characters before filesystem access.
|
|
854
|
-
- **Request size limits** - HTTP bodies and WebSocket payloads are capped at 1 MB to reduce memory-exhaustion risk.
|
|
855
|
-
- **Error isolation** - Remote errors use a machine-readable code and message. Stack traces and internal details are not returned to clients.
|
|
856
|
-
- **Connection isolation** - Read and write operations use separate connection pools. Read-only databases enforce immutability at the connection level.
|
|
857
|
-
|
|
858
|
-
### Deployment boundary
|
|
859
|
-
|
|
860
|
-
For production, put Sirannon behind one of these boundaries:
|
|
861
|
-
|
|
862
|
-
- A server-side application layer that exposes domain actions, not arbitrary SQL.
|
|
863
|
-
- A private network boundary where only trusted services can reach the Sirannon server.
|
|
864
|
-
- A custom `resolveExecutionTarget` or hook layer that allows only specific statements, tenants, and tables.
|
|
865
|
-
|
|
866
|
-
Do not expose unrestricted `/db/:id/query`, `/db/:id/execute`, `/db/:id/transaction`, or `/db/:id` WebSocket routes directly to untrusted browsers.
|
|
867
|
-
|
|
868
|
-
### HTTP authentication
|
|
869
|
-
|
|
870
|
-
Use `onRequest` to authenticate HTTP database routes. The hook runs before database routes and can deny requests by returning `{ status, code, message }`. Health endpoints bypass this hook.
|
|
871
|
-
|
|
872
|
-
```ts
|
|
873
|
-
const server = createServer(sirannon, {
|
|
874
|
-
port: 9876,
|
|
875
|
-
onRequest: ({ headers }) => {
|
|
876
|
-
if (headers.authorization !== `Bearer ${process.env.SIRANNON_API_TOKEN}`) {
|
|
877
|
-
return { status: 401, code: 'UNAUTHORIZED', message: 'Invalid or missing token' }
|
|
878
|
-
}
|
|
879
|
-
},
|
|
880
|
-
})
|
|
881
|
-
```
|
|
882
|
-
|
|
883
|
-
Send HTTP credentials through `headers` on the client:
|
|
884
|
-
|
|
885
|
-
```ts
|
|
886
|
-
const client = new SirannonClient('https://db.example.com', {
|
|
887
|
-
transport: 'http',
|
|
888
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
889
|
-
})
|
|
890
|
-
```
|
|
891
|
-
|
|
892
|
-
### WebSocket authentication
|
|
171
|
+
Registered operations keep SQL on the server, so a caller reaches only the reads and writes you defined. Turning on `acceptSql` gives every client the run of the database, so put such a server behind an application layer, a private network boundary, or a `resolveExecutionTarget` that allows only known statements.
|
|
893
172
|
|
|
894
|
-
|
|
173
|
+
Authenticate every request through the `authenticate` hook. Return the caller's identity, which registered operations read through `fromIdentity`, and throw to refuse:
|
|
895
174
|
|
|
896
175
|
```ts
|
|
897
|
-
|
|
898
|
-
const expectedProtocol = process.env.SIRANNON_WS_PROTOCOL
|
|
176
|
+
import { RequestDeniedError } from '@delali/sirannon-db'
|
|
899
177
|
|
|
900
|
-
const server = createServer(sirannon, {
|
|
178
|
+
const server = createServer<Identity>(sirannon, {
|
|
901
179
|
port: 9876,
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
if (
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const protocols = (headers['sec-websocket-protocol'] ?? '').split(',').map(value => value.trim())
|
|
913
|
-
if (!expectedProtocol || !protocols.includes(expectedProtocol)) {
|
|
914
|
-
return { status: 401, code: 'UNAUTHORIZED', message: 'Invalid WebSocket credentials' }
|
|
915
|
-
}
|
|
180
|
+
cors: { origin: ['https://app.example.com'] },
|
|
181
|
+
operations,
|
|
182
|
+
authenticate: ({ headers }) => {
|
|
183
|
+
const offered = (headers['sec-websocket-protocol'] ?? '').split(',').map(value => value.trim())
|
|
184
|
+
const ticket = offered.find(value => value.startsWith('sirannon.ticket.'))
|
|
185
|
+
const identity = verifyBearerToken(headers.authorization) ?? verifyTicket(ticket)
|
|
186
|
+
if (!identity) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Invalid or missing token')
|
|
187
|
+
return identity
|
|
916
188
|
},
|
|
917
189
|
})
|
|
918
190
|
```
|
|
919
191
|
|
|
920
|
-
|
|
192
|
+
A Node client attaches `headers` to the WebSocket upgrade as well as to HTTP requests, so the hook reads `headers.authorization` on both transports:
|
|
921
193
|
|
|
922
194
|
```ts
|
|
923
|
-
const client = new SirannonClient('https://
|
|
924
|
-
|
|
925
|
-
webSocketProtocols: [wsProtocol],
|
|
195
|
+
const client = new SirannonClient('https://api.example.com', {
|
|
196
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
926
197
|
})
|
|
927
198
|
```
|
|
928
199
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
### TLS and transport security
|
|
932
|
-
|
|
933
|
-
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.
|
|
934
|
-
|
|
935
|
-
### CORS and browser access
|
|
936
|
-
|
|
937
|
-
CORS is disabled by default. Enable it only if browser clients need direct HTTP access, and restrict origins to trusted domains:
|
|
200
|
+
A browser attaches no header to `new WebSocket(...)`, so a browser client carries a short-lived ticket in `webSocketProtocols` instead. A browser client built with `headers` alone on the WebSocket transport fails at construction with `INVALID_ARGUMENT`, because that credential would never reach the server:
|
|
938
201
|
|
|
939
202
|
```ts
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
cors: {
|
|
943
|
-
origin: ['https://app.example.com'],
|
|
944
|
-
},
|
|
203
|
+
const client = new SirannonClient('https://api.example.com', {
|
|
204
|
+
webSocketProtocols: [`sirannon.ticket.${ticket}`],
|
|
945
205
|
})
|
|
946
206
|
```
|
|
947
207
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
### SQL access control
|
|
951
|
-
|
|
952
|
-
Authentication only identifies the caller. It does not make arbitrary SQL safe. For internet-facing systems, enforce one of these patterns:
|
|
953
|
-
|
|
954
|
-
- Prefer application endpoints that perform domain operations such as `createOrder`, `reserveInventory`, or `markPaid`.
|
|
955
|
-
- If clients must use the Sirannon data API, wrap the database with `resolveExecutionTarget` and allow only known SQL statements and parameter shapes.
|
|
956
|
-
- Use hooks for additional query checks, but do not rely on naive substring matching as a SQL firewall.
|
|
957
|
-
- Keep tenant identifiers and ownership rules on the server side.
|
|
208
|
+
Pass both options when a browser client needs each of them, as the [entitlements example](examples/distributed-entitlements) does: the topology client sends `headers` on its coordinator discovery request to `GET /db/{id}/cluster` and the ticket on the socket handshake.
|
|
958
209
|
|
|
959
|
-
|
|
210
|
+
The client offers the plain `sirannon.v1` identifier ahead of your values and the server selects that identifier, so the ticket never comes back in the handshake response. Check the `Origin` header in the same hook. When the hook refuses an upgrade with status 401 or 403, the server closes the connection with code 4401 or 4403, and the client raises `UNAUTHORIZED` or `FORBIDDEN` and leaves that connection closed.
|
|
960
211
|
|
|
961
|
-
-
|
|
962
|
-
-
|
|
963
|
-
-
|
|
964
|
-
-
|
|
212
|
+
- Bind to `127.0.0.1` or a private interface unless a proxy enforces TLS and access control.
|
|
213
|
+
- Use HTTPS and WSS for non-local traffic, because the built-in server binds plain HTTP.
|
|
214
|
+
- Authenticate every HTTP database route and every WebSocket upgrade, and check `Origin` against an allowlist.
|
|
215
|
+
- Keep user input in parameters, which the driver binds rather than splicing into the SQL text.
|
|
216
|
+
- Restrict CORS to known origins; `cors: true` allows every origin and belongs in local development.
|
|
217
|
+
- Keep long-lived secrets out of browser-visible configuration, and redact credentials from access logs.
|
|
218
|
+
- Add rate limits, audit logs, and abuse monitoring at the application or edge layer.
|
|
965
219
|
|
|
966
|
-
|
|
220
|
+
The [security guide](https://sirannon.sondelali.com/docs) covers each of these in full.
|
|
967
221
|
|
|
968
|
-
|
|
969
|
-
- Use HTTPS/WSS for non-local traffic.
|
|
970
|
-
- Authenticate every HTTP database route and every WebSocket upgrade.
|
|
971
|
-
- Validate WebSocket `Origin` against an explicit allowlist.
|
|
972
|
-
- Keep SQL behind application actions or a strict allowlist.
|
|
973
|
-
- Keep user input in SQL parameters, not interpolated strings.
|
|
974
|
-
- Restrict CORS to known origins.
|
|
975
|
-
- Add rate limits, audit logs, and abuse monitoring at the application or edge layer for public deployments.
|
|
222
|
+
## Documentation
|
|
976
223
|
|
|
977
|
-
|
|
224
|
+
| Guide | What it covers |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| [Core engine](../../docs/core.md) | Bulk load, live queries, migrations, backups, hooks, metrics, and the multi-tenant lifecycle |
|
|
227
|
+
| [Server](../../docs/server.md) | HTTP routes, WebSocket messages, authentication, write shapes, the writer worker, and value encoding |
|
|
228
|
+
| [Registered operations](../../docs/operations.md) | Naming the statements a server runs, identity-filled arguments, capabilities, and code generation |
|
|
229
|
+
| [Live queries](../../docs/live-queries.md) | Maintained query results locally, over the network, and in React |
|
|
230
|
+
| [Client SDK](../../docs/client.md) | Transports, subscriptions, topology-aware routing, and read concern |
|
|
231
|
+
| [Device sync](../../docs/device-sync.md) | Offline-first two-way sync between a device's local database and a server |
|
|
232
|
+
| [Distributed replication](../../docs/replication.md) | Replication, first sync, write and read concerns, coordinator failover, resolvers, and transports |
|
|
233
|
+
| [Configuration reference](../../docs/configuration.md) | Every option table, from `SirannonOptions` to `GrpcReplicationOptions` |
|
|
234
|
+
| [Errors](../../docs/errors.md) | Every code, when it happens, whether the call is safe to retry, and its HTTP status |
|
|
978
235
|
|
|
979
|
-
|
|
236
|
+
The [specification](../spec/) defines the wire formats, value encodings, and replication invariants every implementation follows.
|
|
980
237
|
|
|
981
|
-
|
|
238
|
+
## Example projects
|
|
982
239
|
|
|
983
|
-
|
|
|
240
|
+
| Example | Runtime | What it demonstrates |
|
|
984
241
|
| --- | --- | --- |
|
|
985
|
-
| `
|
|
986
|
-
| `
|
|
987
|
-
| `
|
|
988
|
-
| `
|
|
989
|
-
| `TransactionError` | `TRANSACTION_ERROR` | Transaction commit/rollback failure |
|
|
990
|
-
| `MigrationError` | `MIGRATION_ERROR` | Migration step failure |
|
|
991
|
-
| `HookDeniedError` | `HOOK_DENIED` | Before-hook rejected the operation |
|
|
992
|
-
| `CDCError` | `CDC_ERROR` | Change tracking pipeline failure |
|
|
993
|
-
| `BackupError` | `BACKUP_ERROR` | Backup operation failure |
|
|
994
|
-
| `ConnectionPoolError` | `CONNECTION_POOL_ERROR` | Pool closed or misconfigured |
|
|
995
|
-
| `MaxDatabasesError` | `MAX_DATABASES` | Capacity limit reached |
|
|
996
|
-
| `ExtensionError` | `EXTENSION_ERROR` | SQLite extension load failure |
|
|
997
|
-
|
|
998
|
-
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.
|
|
999
|
-
|
|
1000
|
-
```ts
|
|
1001
|
-
import { QueryError } from '@delali/sirannon-db'
|
|
1002
|
-
|
|
1003
|
-
try {
|
|
1004
|
-
await db.execute('INSERT INTO users (id) VALUES (?)', [1])
|
|
1005
|
-
} catch (err) {
|
|
1006
|
-
if (err instanceof QueryError) {
|
|
1007
|
-
console.error(`SQL failed [${err.code}]: ${err.message}`)
|
|
1008
|
-
console.error(`Statement: ${err.sql}`)
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
```
|
|
1012
|
-
|
|
1013
|
-
## Configuration reference
|
|
1014
|
-
|
|
1015
|
-
### `SirannonOptions`
|
|
1016
|
-
|
|
1017
|
-
| Option | Type | Required | Description |
|
|
1018
|
-
| --- | --- | --- | --- |
|
|
1019
|
-
| `driver` | `SQLiteDriver` | Yes | The SQLite driver adapter to use |
|
|
1020
|
-
| `hooks` | `HookConfig` | No | Before/after hooks for queries, connections, subscriptions |
|
|
1021
|
-
| `metrics` | `MetricsConfig` | No | Callbacks for query timing, connection events, CDC activity |
|
|
1022
|
-
| `lifecycle` | `LifecycleConfig` | No | Auto-open resolver, idle timeout, max open databases |
|
|
1023
|
-
|
|
1024
|
-
### `DatabaseOptions`
|
|
1025
|
-
|
|
1026
|
-
| Option | Type | Default | Description |
|
|
1027
|
-
| --- | --- | --- | --- |
|
|
1028
|
-
| `readOnly` | `boolean` | `false` | Open in read-only mode |
|
|
1029
|
-
| `readPoolSize` | `number` | `4` | Number of read connections |
|
|
1030
|
-
| `walMode` | `boolean` | `true` | Enable WAL mode |
|
|
1031
|
-
| `synchronous` | `'off' \| 'normal' \| 'full' \| 'extra'` | `'normal'` | Writer durability (`PRAGMA synchronous`); this is the level a bulk load restores when it finishes |
|
|
1032
|
-
| `cdcPollInterval` | `number` | `50` | CDC polling interval in ms |
|
|
1033
|
-
| `cdcRetention` | `number` | `3_600_000` | CDC retention period in ms (1 hour) |
|
|
1034
|
-
| `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) |
|
|
1035
|
-
|
|
1036
|
-
### `ServerOptions`
|
|
1037
|
-
|
|
1038
|
-
| Option | Type | Default | Description |
|
|
1039
|
-
| --- | --- | --- | --- |
|
|
1040
|
-
| `host` | `string` | `'127.0.0.1'` | Bind address |
|
|
1041
|
-
| `port` | `number` | `9876` | Listen port |
|
|
1042
|
-
| `cors` | `boolean \| CorsOptions` | `false` | CORS configuration |
|
|
1043
|
-
| `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` |
|
|
1044
|
-
| `onRequest` | `OnRequestHook` | - | Middleware hook for auth, rate limiting, and request validation |
|
|
1045
|
-
|
|
1046
|
-
### `ClientOptions`
|
|
1047
|
-
|
|
1048
|
-
| Option | Type | Default | Description |
|
|
1049
|
-
| --- | --- | --- | --- |
|
|
1050
|
-
| `transport` | `'websocket' \| 'http'` | `'websocket'` | Transport protocol |
|
|
1051
|
-
| `headers` | `Record<string, string>` | - | Custom HTTP headers; browser WebSocket handshakes do not use this option |
|
|
1052
|
-
| `webSocketProtocols` | `string \| string[]` | - | WebSocket subprotocols sent during the upgrade handshake |
|
|
1053
|
-
| `autoReconnect` | `boolean` | `true` | Reconnect on WebSocket disconnect |
|
|
1054
|
-
| `reconnectInterval` | `number` | `1000` | Reconnect delay in ms |
|
|
1055
|
-
|
|
1056
|
-
## Examples
|
|
1057
|
-
|
|
1058
|
-
Self-contained example projects live in [`examples/`](examples/) and cover the current Node.js, browser, client-server, and distributed paths:
|
|
1059
|
-
|
|
1060
|
-
| Example | Runtime | Driver | What it demonstrates |
|
|
1061
|
-
| --- | --- | --- | --- |
|
|
1062
|
-
| [`node`](examples/node/) | Node.js >= 22 | better-sqlite3 or built-in `node:sqlite` | All core features: schema, migrations, CRUD, transactions, CDC, connection pools, metrics, multi-tenant, hooks, backup, shutdown |
|
|
1063
|
-
| [`web-wa-sqlite`](examples/web-wa-sqlite/) | Browser (Vite) | wa-sqlite + IndexedDB | CRUD, transactions, CDC subscriptions in the browser |
|
|
1064
|
-
| [`web-client`](examples/web-client/) | Browser + Node.js | better-sqlite3 (server) | Client SDK connecting to a Sirannon server over HTTP and WebSocket |
|
|
1065
|
-
| [`distributed-entitlements`](examples/distributed-entitlements/) | Node.js + browser | better-sqlite3 | Three-node coordinator-backed replication over gRPC with etcd authority, local mTLS certificates, and Toxiproxy failure controls |
|
|
1066
|
-
|
|
1067
|
-
### Running the examples
|
|
1068
|
-
|
|
1069
|
-
From the repository root:
|
|
242
|
+
| [`node`](examples/node/) | Node.js >= 22 | Schema, migrations, CRUD, transactions, CDC, live queries, pools, metrics, multi-tenant lifecycle, hooks, backup, shutdown |
|
|
243
|
+
| [`web-wa-sqlite`](examples/web-wa-sqlite/) | Browser and Node.js | Offline-first device sync: a local database in the browser, snapshot load, offline writes, conflict resolution, and a local live query |
|
|
244
|
+
| [`web-client`](examples/web-client/) | Browser and Node.js | Registered operations, code generation, remote live queries, and the React hooks |
|
|
245
|
+
| [`distributed-entitlements`](examples/distributed-entitlements/) | Node.js and browser | Three-node coordinator-backed replication over gRPC with etcd authority, mTLS, and Toxiproxy failure controls |
|
|
1070
246
|
|
|
1071
247
|
```bash
|
|
1072
|
-
pnpm install
|
|
1073
|
-
|
|
248
|
+
pnpm install && pnpm --filter @delali/sirannon-db build
|
|
249
|
+
cd packages/ts/examples/node && pnpm start
|
|
1074
250
|
```
|
|
1075
251
|
|
|
1076
|
-
Then pick an example:
|
|
1077
|
-
|
|
1078
|
-
```bash
|
|
1079
|
-
# Node.js with better-sqlite3, the default driver
|
|
1080
|
-
cd packages/ts/examples/node
|
|
1081
|
-
pnpm start
|
|
1082
|
-
|
|
1083
|
-
# Node.js with built-in sqlite
|
|
1084
|
-
cd packages/ts/examples/node
|
|
1085
|
-
pnpm run start:node-native
|
|
1086
|
-
|
|
1087
|
-
# Browser with wa-sqlite (opens Vite dev server)
|
|
1088
|
-
cd packages/ts/examples/web-wa-sqlite
|
|
1089
|
-
pnpm run dev
|
|
1090
|
-
|
|
1091
|
-
# Client-server (starts both Sirannon server and Vite client)
|
|
1092
|
-
cd packages/ts/examples/web-client
|
|
1093
|
-
pnpm run dev
|
|
1094
|
-
|
|
1095
|
-
# Distributed entitlements (starts the Docker cluster and dashboard)
|
|
1096
|
-
cd packages/ts/examples/distributed-entitlements
|
|
1097
|
-
pnpm run dev
|
|
1098
|
-
```
|
|
1099
|
-
|
|
1100
|
-
## Benchmarks
|
|
1101
|
-
|
|
1102
|
-
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.
|
|
1103
|
-
|
|
1104
252
|
## Development
|
|
1105
253
|
|
|
1106
254
|
```bash
|