@delali/sirannon-db 0.1.7 → 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 CHANGED
@@ -469,7 +469,7 @@ const db = await sirannon.open('app', './data/app.db', {
469
469
  })
470
470
  ```
471
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.
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
473
 
474
474
  ### HTTP routes
475
475
 
@@ -480,6 +480,7 @@ const db = await sirannon.open('app', './data/app.db', {
480
480
  | `POST` | `/db/:id/transaction` | Execute many statements atomically in one transaction, returns `{ results }` |
481
481
  | `POST` | `/db/:id/batch` | Apply one statement over many parameter sets in one transaction, returns `{ results }` |
482
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 |
483
484
  | `GET` | `/health` | Liveness check |
484
485
  | `GET` | `/health/ready` | Readiness check with per-database status |
485
486
 
@@ -494,11 +495,19 @@ Connect to `ws://host:port/db/:id` and send JSON messages. Every message carries
494
495
  | `transaction` | `statements`, `writeConcern?` | `{ type: 'result', data: { results } }` |
495
496
  | `batch` | `sql`, `paramsBatch`, `writeConcern?` | `{ type: 'result', data: { results } }` |
496
497
  | `load` | `sql`, `paramsBatch`, `durability?`, `checkpoint?` | `{ type: 'result', data: { rowsLoaded, changes } }` |
497
- | `subscribe` | `table`, `filter?` | `{ type: 'subscribed' }` then `change` events |
498
+ | `subscribe` | `table`, `filter?`, `sinceSeq?`, `epoch?` | `{ type: 'subscribed', seq?, epoch?, resync? }` then `change` events |
498
499
  | `unsubscribe` | - | `{ type: 'unsubscribed' }` |
499
500
 
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.
501
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).
510
+
502
511
  ## Client SDK
503
512
 
504
513
  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.
@@ -997,6 +1006,15 @@ All errors extend `SirannonError` with a machine-readable `code` property:
997
1006
 
998
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.
999
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
+
1000
1018
  ```ts
1001
1019
  import { QueryError } from '@delali/sirannon-db'
1002
1020
 
@@ -1041,6 +1059,8 @@ try {
1041
1059
  | `port` | `number` | `9876` | Listen port |
1042
1060
  | `cors` | `boolean \| CorsOptions` | `false` | CORS configuration |
1043
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` |
1044
1064
  | `onRequest` | `OnRequestHook` | - | Middleware hook for auth, rate limiting, and request validation |
1045
1065
 
1046
1066
  ### `ClientOptions`
@@ -1,4 +1,5 @@
1
1
  // src/core/worker/protocol.ts
2
+ var WORKER_CANCELLED_CODE = "WRITER_WORKER_CANCELLED";
2
3
  function serializeError(err) {
3
4
  if (err instanceof Error) {
4
5
  const code = err.code;
@@ -17,4 +18,4 @@ function deserializeError(error) {
17
18
  return err;
18
19
  }
19
20
 
20
- export { deserializeError, serializeError };
21
+ export { WORKER_CANCELLED_CODE, deserializeError, serializeError };
@@ -1,4 +1,4 @@
1
- import { deserializeError } from './chunk-DJLX6CAE.mjs';
1
+ import { WORKER_CANCELLED_CODE, deserializeError } from './chunk-4ISB7XMA.mjs';
2
2
  import { BackupManager, BackupScheduler } from './chunk-VLTICJOD.mjs';
3
3
  import { SirannonError } from './chunk-YPYVQJ4C.mjs';
4
4
  import { Worker } from 'worker_threads';
@@ -142,24 +142,61 @@ var WriterWorker = class _WriterWorker {
142
142
  const entry = this.pending.get(res.id);
143
143
  if (!entry) return;
144
144
  this.pending.delete(res.id);
145
- if (entry.timer) clearTimeout(entry.timer);
146
- if (res.ok) entry.resolve(res.value);
147
- else entry.reject(deserializeError(res.error));
145
+ clearPendingTimers(entry);
146
+ if (res.ok) {
147
+ entry.resolve(res.value);
148
+ return;
149
+ }
150
+ if (res.error.code === WORKER_CANCELLED_CODE) {
151
+ entry.reject(
152
+ new SirannonError(
153
+ `The writer worker could not take this operation within ${this.timeoutMs}ms; it was not applied and is safe to retry`,
154
+ "WRITE_OVERLOADED"
155
+ )
156
+ );
157
+ return;
158
+ }
159
+ entry.reject(deserializeError(res.error));
148
160
  }
149
161
  rejectPending(id, err) {
150
162
  const entry = this.pending.get(id);
151
163
  if (!entry) return;
152
164
  this.pending.delete(id);
153
- if (entry.timer) clearTimeout(entry.timer);
165
+ clearPendingTimers(entry);
154
166
  entry.reject(err);
155
167
  }
168
+ unresponsiveError(waitedMs) {
169
+ return new SirannonError(
170
+ `Writer worker did not respond within ${waitedMs}ms; the operation's outcome is unknown`,
171
+ "WRITER_WORKER_TIMEOUT"
172
+ );
173
+ }
174
+ onDeadline(id) {
175
+ const entry = this.pending.get(id);
176
+ if (!entry) return;
177
+ const worker = this.worker;
178
+ if (!entry.cancellable || !worker) {
179
+ this.rejectPending(id, this.unresponsiveError(this.timeoutMs));
180
+ return;
181
+ }
182
+ try {
183
+ worker.postMessage({ kind: "cancel", id });
184
+ } catch {
185
+ this.rejectPending(id, this.unresponsiveError(this.timeoutMs));
186
+ return;
187
+ }
188
+ entry.graceTimer = setTimeout(() => {
189
+ this.rejectPending(id, this.unresponsiveError(this.timeoutMs * 2));
190
+ }, this.timeoutMs);
191
+ entry.graceTimer.unref?.();
192
+ }
156
193
  fault(errLike) {
157
194
  if (this.closed || this.fatal) return;
158
195
  const err = errLike instanceof Error ? errLike : new SirannonError(String(errLike), "WRITER_WORKER_ERROR");
159
196
  const dead = this.worker;
160
197
  this.worker = null;
161
198
  for (const entry of this.pending.values()) {
162
- if (entry.timer) clearTimeout(entry.timer);
199
+ clearPendingTimers(entry);
163
200
  entry.reject(err);
164
201
  }
165
202
  this.pending.clear();
@@ -197,18 +234,11 @@ var WriterWorker = class _WriterWorker {
197
234
  return new Promise((resolve2, reject) => {
198
235
  let timer = null;
199
236
  if (this.timeoutMs > 0) {
200
- timer = setTimeout(() => {
201
- this.rejectPending(
202
- id,
203
- new SirannonError(
204
- `Writer worker did not respond within ${this.timeoutMs}ms; the operation's outcome is unknown`,
205
- "WRITER_WORKER_TIMEOUT"
206
- )
207
- );
208
- }, this.timeoutMs);
237
+ timer = setTimeout(() => this.onDeadline(id), this.timeoutMs);
209
238
  timer.unref?.();
210
239
  }
211
- this.pending.set(id, { resolve: resolve2, reject, timer });
240
+ const cancellable = request.kind !== "open" && request.kind !== "close";
241
+ this.pending.set(id, { resolve: resolve2, reject, timer, graceTimer: null, cancellable });
212
242
  try {
213
243
  worker.postMessage(message);
214
244
  } catch (err) {
@@ -289,12 +319,16 @@ var WriterWorker = class _WriterWorker {
289
319
  }
290
320
  this.worker = null;
291
321
  for (const entry of this.pending.values()) {
292
- if (entry.timer) clearTimeout(entry.timer);
322
+ clearPendingTimers(entry);
293
323
  entry.reject(new SirannonError("Writer worker is closed", "WRITER_WORKER_CLOSED"));
294
324
  }
295
325
  this.pending.clear();
296
326
  }
297
327
  };
328
+ function clearPendingTimers(entry) {
329
+ if (entry.timer) clearTimeout(entry.timer);
330
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
331
+ }
298
332
  function nodeWriterContext() {
299
333
  const held = new AsyncLocalStorage();
300
334
  return {
@@ -1,4 +1,4 @@
1
- import { serializeError } from '../chunk-DJLX6CAE.mjs';
1
+ import { WORKER_CANCELLED_CODE, serializeError } from '../chunk-4ISB7XMA.mjs';
2
2
  import { executeGroup } from '../chunk-CW6S3WL5.mjs';
3
3
  import '../chunk-GEZUUIKV.mjs';
4
4
  import { SirannonError } from '../chunk-YPYVQJ4C.mjs';
@@ -93,7 +93,22 @@ async function dispatch(req) {
93
93
  return void 0;
94
94
  }
95
95
  }
96
+ var cancelledIds = /* @__PURE__ */ new Set();
97
+ var latestDispatchedId = 0;
96
98
  async function handle(req) {
99
+ latestDispatchedId = req.id;
100
+ if (cancelledIds.delete(req.id)) {
101
+ port.postMessage({
102
+ id: req.id,
103
+ ok: false,
104
+ error: {
105
+ message: "The caller abandoned this operation before the worker reached it, so it was not run",
106
+ name: "SirannonError",
107
+ code: WORKER_CANCELLED_CODE
108
+ }
109
+ });
110
+ return;
111
+ }
97
112
  try {
98
113
  const value = await dispatch(req);
99
114
  port.postMessage({ id: req.id, ok: true, value });
@@ -101,7 +116,14 @@ async function handle(req) {
101
116
  port.postMessage({ id: req.id, ok: false, error: serializeError(err) });
102
117
  }
103
118
  }
119
+ function settleDeliveredMessages() {
120
+ return new Promise((resolve) => setImmediate(resolve));
121
+ }
104
122
  var tail = Promise.resolve();
105
- port.on("message", (req) => {
106
- tail = tail.then(() => handle(req));
123
+ port.on("message", (msg) => {
124
+ if (msg.kind === "cancel") {
125
+ if (msg.id > latestDispatchedId) cancelledIds.add(msg.id);
126
+ return;
127
+ }
128
+ tail = tail.then(settleDeliveredMessages).then(() => handle(msg));
107
129
  });
@@ -1,6 +1,6 @@
1
- import { nodeResolveExtensionPath, nodeBackupEngine, nodeWriterContext, WriterWorker, createStatementCache, narrowSafeBigInt, narrowRowIntegers, narrowRowsIntegers } from '../chunk-4IGMIJQK.mjs';
1
+ import { nodeResolveExtensionPath, nodeBackupEngine, nodeWriterContext, WriterWorker, createStatementCache, narrowSafeBigInt, narrowRowIntegers, narrowRowsIntegers } from '../chunk-UXLAO6ZH.mjs';
2
2
  import { defineDriver } from '../chunk-BNUTBHHH.mjs';
3
- import '../chunk-DJLX6CAE.mjs';
3
+ import '../chunk-4ISB7XMA.mjs';
4
4
  import { synchronousPragmaValue } from '../chunk-CJLYFDP5.mjs';
5
5
  import '../chunk-VLTICJOD.mjs';
6
6
  import '../chunk-YPYVQJ4C.mjs';
@@ -1,6 +1,6 @@
1
- import { nodeResolveExtensionPath, nodeBackupEngine, nodeWriterContext, createStatementCache, WriterWorker, narrowSafeBigInt, narrowRowIntegers, narrowRowsIntegers } from '../chunk-4IGMIJQK.mjs';
1
+ import { nodeResolveExtensionPath, nodeBackupEngine, nodeWriterContext, createStatementCache, WriterWorker, narrowSafeBigInt, narrowRowIntegers, narrowRowsIntegers } from '../chunk-UXLAO6ZH.mjs';
2
2
  import { defineDriver } from '../chunk-BNUTBHHH.mjs';
3
- import '../chunk-DJLX6CAE.mjs';
3
+ import '../chunk-4ISB7XMA.mjs';
4
4
  import { synchronousPragmaValue } from '../chunk-CJLYFDP5.mjs';
5
5
  import '../chunk-VLTICJOD.mjs';
6
6
  import '../chunk-YPYVQJ4C.mjs';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@delali/sirannon-db",
3
3
  "type": "module",
4
- "version": "0.1.7",
4
+ "version": "0.1.8",
5
5
  "description": "A production-grade library that turns SQLite databases into a networked data layer with real-time subscriptions.",
6
6
  "author": "Delali (https://sondelali.com)",
7
7
  "license": "Apache-2.0",