@delali/sirannon-db 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +655 -80
  2. package/dist/backup-scheduler/index.d.ts +3 -0
  3. package/dist/backup-scheduler/index.mjs +2 -0
  4. package/dist/change-tracker-CFTQ9TSn.d.ts +89 -0
  5. package/dist/chunk-3MCMONVP.mjs +115 -0
  6. package/dist/chunk-74UN4DIE.mjs +14 -0
  7. package/dist/chunk-ER7ODTDA.mjs +23 -0
  8. package/dist/chunk-FB2U2Q3Y.mjs +21 -0
  9. package/dist/chunk-GS7T5YMI.mjs +51 -0
  10. package/dist/chunk-O7BHI3CF.mjs +90 -0
  11. package/dist/chunk-PXKAKK2V.mjs +124 -0
  12. package/dist/chunk-UTO3ZAFS.mjs +514 -0
  13. package/dist/chunk-UVMVN3OT.mjs +111 -0
  14. package/dist/client/index.d.ts +137 -44
  15. package/dist/client/index.mjs +726 -26
  16. package/dist/core/index.d.ts +32 -241
  17. package/dist/core/index.mjs +294 -568
  18. package/dist/database-BVY1GqE7.d.ts +95 -0
  19. package/dist/driver/better-sqlite3.d.ts +8 -0
  20. package/dist/driver/better-sqlite3.mjs +63 -0
  21. package/dist/driver/bun.mjs +61 -0
  22. package/dist/driver/expo.mjs +55 -0
  23. package/dist/driver/node.d.ts +8 -0
  24. package/dist/driver/node.mjs +60 -0
  25. package/dist/driver/wa-sqlite.d.ts +34 -0
  26. package/dist/driver/wa-sqlite.mjs +141 -0
  27. package/dist/errors-C00ed08Q.d.ts +101 -0
  28. package/dist/file-migrations/index.d.ts +16 -0
  29. package/dist/file-migrations/index.mjs +128 -0
  30. package/dist/index-CLdNrcPz.d.ts +16 -0
  31. package/dist/replication/coordinator/etcd.d.ts +44 -0
  32. package/dist/replication/coordinator/etcd.mjs +650 -0
  33. package/dist/replication/index.d.ts +491 -0
  34. package/dist/replication/index.mjs +3784 -0
  35. package/dist/server/index.d.ts +121 -54
  36. package/dist/server/index.mjs +347 -114
  37. package/dist/sirannon-Cd-lK6T0.d.ts +31 -0
  38. package/dist/transport/grpc.d.ts +316 -0
  39. package/dist/transport/grpc.mjs +3341 -0
  40. package/dist/transport/memory.d.ts +221 -0
  41. package/dist/transport/memory.mjs +337 -0
  42. package/dist/types-B2byqt0B.d.ts +273 -0
  43. package/dist/types-BEu1I_9_.d.ts +139 -0
  44. package/dist/types-BFSsG77t.d.ts +29 -0
  45. package/dist/types-BeozgNPr.d.ts +26 -0
  46. package/dist/{types-DArCObcu.d.ts → types-D-74JiXb.d.ts} +80 -1
  47. package/dist/vfs-INWQ5DTE.mjs +2 -0
  48. package/package.json +106 -11
  49. package/dist/chunk-VI4UP4RR.mjs +0 -417
  50. package/dist/protocol-BX1H-_Mz.d.ts +0 -104
  51. package/dist/sirannon-BJ8Yd1Uf.d.ts +0 -148
@@ -1,6 +1,7 @@
1
- import { ChangeTracker, SubscriptionManager, SirannonError } from '../chunk-VI4UP4RR.mjs';
1
+ import { ChangeTracker, SubscriptionManager } from '../chunk-UTO3ZAFS.mjs';
2
+ import '../chunk-GS7T5YMI.mjs';
3
+ import { SirannonError } from '../chunk-O7BHI3CF.mjs';
2
4
  import uWS from 'uWebSockets.js';
3
- import SqliteDatabase from 'better-sqlite3';
4
5
 
5
6
  // src/server/protocol.ts
6
7
  function toExecuteResponse(result) {
@@ -19,7 +20,7 @@ function handleLiveness() {
19
20
  });
20
21
  };
21
22
  }
22
- function handleReadiness(sirannon) {
23
+ function handleReadiness(sirannon, getReplicationStatus) {
23
24
  return (res) => {
24
25
  const dbs = sirannon.databases();
25
26
  const databases = [];
@@ -37,12 +38,43 @@ function handleReadiness(sirannon) {
37
38
  status: degraded ? "degraded" : "ok",
38
39
  databases
39
40
  };
41
+ if (getReplicationStatus) {
42
+ const replStatus = getReplicationStatus();
43
+ if (replStatus) {
44
+ body.replication = {
45
+ role: replStatus.role,
46
+ writeForwarding: replStatus.writeForwarding,
47
+ peers: replStatus.peers,
48
+ localSeq: replStatus.localSeq.toString(),
49
+ replicationGroupId: replStatus.replicationGroupId,
50
+ primaryTerm: replStatus.primaryTerm?.toString(),
51
+ currentPrimary: replStatus.currentPrimary,
52
+ coordinator: replStatus.coordinator,
53
+ controller: replStatus.controller,
54
+ inSyncReplicas: replStatus.inSyncReplicas,
55
+ laggingReplicas: replStatus.laggingReplicas,
56
+ syncState: replStatus.syncState,
57
+ readAvailability: replStatus.readAvailability,
58
+ writeAvailability: replStatus.writeAvailability
59
+ };
60
+ body.status = readinessStatusForReplication(replStatus, body.status);
61
+ }
62
+ }
40
63
  const payload = JSON.stringify(body);
41
64
  res.cork(() => {
42
65
  res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
43
66
  });
44
67
  };
45
68
  }
69
+ function readinessStatusForReplication(replication, current) {
70
+ if (replication.syncState === "syncing" || replication.syncState === "catching-up") return "syncing";
71
+ if (replication.controller?.state === "active" && replication.writeAvailability === "unavailable")
72
+ return "failing_over";
73
+ if (replication.readAvailability === "unavailable" && replication.writeAvailability === "unavailable")
74
+ return "unavailable";
75
+ if ((replication.laggingReplicas?.length ?? 0) > 0) return "degraded";
76
+ return current;
77
+ }
46
78
 
47
79
  // src/server/http-handler.ts
48
80
  function initAbortHandler(res) {
@@ -116,8 +148,8 @@ function sendJson(res, data) {
116
148
  res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
117
149
  });
118
150
  }
119
- function sendError(res, status, code, message) {
120
- const body = { error: { code, message } };
151
+ function sendError(res, status, code, message, details) {
152
+ const body = { error: details ? { code, message, details } : { code, message } };
121
153
  const payload = JSON.stringify(body);
122
154
  res.cork(() => {
123
155
  res.writeStatus(`${status}`).writeHeader("Content-Type", "application/json").end(payload);
@@ -132,69 +164,105 @@ function httpStatusForError(err) {
132
164
  case "QUERY_ERROR":
133
165
  case "TRANSACTION_ERROR":
134
166
  return 400;
167
+ case "STALE_PRIMARY":
168
+ case "PROTOCOL_VERSION_MISMATCH":
169
+ return 409;
135
170
  case "HOOK_DENIED":
136
171
  return 403;
137
172
  case "DATABASE_CLOSED":
138
173
  case "SHUTDOWN":
174
+ case "READ_CONCERN_ERROR":
175
+ case "COORDINATOR_UNAVAILABLE":
176
+ case "AUTHORITY_LOST":
177
+ case "NO_SAFE_PRIMARY":
178
+ case "NODE_NOT_IN_SYNC":
179
+ case "NODE_DRAINING":
180
+ case "UNSAFE_RECOVERY_REQUIRED":
139
181
  return 503;
140
182
  default:
141
183
  return 500;
142
184
  }
143
185
  }
144
- function resolveDatabase(res, sirannon, id) {
145
- const db = sirannon.get(id);
146
- if (!db) {
186
+ async function resolveExecutionTarget(res, sirannon, id, resolver) {
187
+ let target;
188
+ try {
189
+ target = resolver ? await resolver(id) : await sirannon.resolve(id);
190
+ } catch (err) {
191
+ if (err instanceof SirannonError) {
192
+ sendError(res, httpStatusForError(err), err.code, err.message);
193
+ } else {
194
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
195
+ }
196
+ return null;
197
+ }
198
+ if (!target) {
147
199
  sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${id}' not found`);
148
200
  return null;
149
201
  }
150
- return db;
202
+ return target;
151
203
  }
152
- function handleQuery(sirannon) {
153
- return (res, dbId, rawBody) => {
204
+ function handleQuery(sirannon, resolveTarget) {
205
+ return async (res, dbId, rawBody, abort) => {
154
206
  const body = parseBody(res, rawBody);
155
207
  if (!body) return;
156
208
  if (!body.sql || typeof body.sql !== "string") {
157
209
  sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
158
210
  return;
159
211
  }
160
- const db = resolveDatabase(res, sirannon, dbId);
161
- if (!db) return;
212
+ const readConcern = parseReadConcern(res, body.readConcern);
213
+ if (!readConcern.ok) return;
214
+ const target = await resolveExecutionTarget(res, sirannon, dbId, resolveTarget);
215
+ if (!target) return;
162
216
  try {
163
- const rows = db.query(body.sql, body.params);
217
+ const rows = await target.query(
218
+ body.sql,
219
+ body.params,
220
+ readConcern.value ? { readConcern: readConcern.value } : void 0
221
+ );
222
+ if (abort.aborted) return;
164
223
  sendJson(res, { rows });
165
224
  } catch (err) {
225
+ if (abort.aborted) return;
166
226
  if (err instanceof SirannonError) {
167
- sendError(res, httpStatusForError(err), err.code, err.message);
227
+ sendError(res, httpStatusForError(err), err.code, err.message, errorDetails(err));
168
228
  } else {
169
229
  sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
170
230
  }
171
231
  }
172
232
  };
173
233
  }
174
- function handleExecute(sirannon) {
175
- return (res, dbId, rawBody) => {
234
+ function handleExecute(sirannon, resolveTarget) {
235
+ return async (res, dbId, rawBody, abort) => {
176
236
  const body = parseBody(res, rawBody);
177
237
  if (!body) return;
178
238
  if (!body.sql || typeof body.sql !== "string") {
179
239
  sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
180
240
  return;
181
241
  }
182
- const db = resolveDatabase(res, sirannon, dbId);
183
- if (!db) return;
242
+ const writeConcern = parseWriteConcern(res, body.writeConcern);
243
+ if (!writeConcern.ok) return;
244
+ const target = await resolveExecutionTarget(res, sirannon, dbId, resolveTarget);
245
+ if (!target) return;
184
246
  try {
185
- const result = db.execute(body.sql, body.params);
247
+ const result = await target.execute(
248
+ body.sql,
249
+ body.params,
250
+ writeConcern.value ? { writeConcern: writeConcern.value } : void 0
251
+ );
252
+ if (abort.aborted) return;
186
253
  sendJson(res, toExecuteResponse(result));
187
254
  } catch (err) {
255
+ if (abort.aborted) return;
188
256
  if (err instanceof SirannonError) {
189
- sendError(res, httpStatusForError(err), err.code, err.message);
257
+ sendError(res, httpStatusForError(err), err.code, err.message, errorDetails(err));
190
258
  } else {
191
259
  sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
192
260
  }
193
261
  }
194
262
  };
195
263
  }
196
- function handleTransaction(sirannon) {
197
- return (res, dbId, rawBody) => {
264
+ function handleTransaction(sirannon, resolveTarget) {
265
+ return async (res, dbId, rawBody, abort) => {
198
266
  const body = parseBody(res, rawBody);
199
267
  if (!body) return;
200
268
  if (!Array.isArray(body.statements)) {
@@ -205,6 +273,8 @@ function handleTransaction(sirannon) {
205
273
  sendError(res, 400, "INVALID_REQUEST", "Transaction requires at least one statement");
206
274
  return;
207
275
  }
276
+ const writeConcern = parseWriteConcern(res, body.writeConcern);
277
+ if (!writeConcern.ok) return;
208
278
  for (let i = 0; i < body.statements.length; i++) {
209
279
  const stmt = body.statements[i];
210
280
  if (!stmt.sql || typeof stmt.sql !== "string") {
@@ -212,50 +282,138 @@ function handleTransaction(sirannon) {
212
282
  return;
213
283
  }
214
284
  }
215
- const db = resolveDatabase(res, sirannon, dbId);
216
- if (!db) return;
285
+ const target = await resolveExecutionTarget(res, sirannon, dbId, resolveTarget);
286
+ if (!target) return;
217
287
  try {
218
- const results = db.transaction((tx) => {
219
- return body.statements.map((stmt) => tx.execute(stmt.sql, stmt.params));
220
- });
288
+ const results = await target.transaction(
289
+ async (tx) => {
290
+ const txResults = [];
291
+ for (const stmt of body.statements) {
292
+ if (abort.aborted) throw new Error("Request aborted");
293
+ txResults.push(await tx.execute(stmt.sql, stmt.params));
294
+ }
295
+ return txResults;
296
+ },
297
+ writeConcern.value ? { writeConcern: writeConcern.value } : void 0
298
+ );
299
+ if (abort.aborted) return;
221
300
  sendJson(res, {
222
301
  results: results.map(toExecuteResponse)
223
302
  });
224
303
  } catch (err) {
304
+ if (abort.aborted) return;
225
305
  if (err instanceof SirannonError) {
226
- sendError(res, httpStatusForError(err), err.code, err.message);
306
+ sendError(res, httpStatusForError(err), err.code, err.message, errorDetails(err));
227
307
  } else {
228
308
  sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
229
309
  }
230
310
  }
231
311
  };
232
312
  }
313
+ function parseReadConcern(res, value) {
314
+ if (value === void 0) return { ok: true, value: void 0 };
315
+ if (!isPlainRecord(value)) {
316
+ sendError(res, 400, "INVALID_REQUEST", 'Field "readConcern" must be an object when provided');
317
+ return { ok: false };
318
+ }
319
+ const keys = Object.keys(value);
320
+ if (keys.length !== 1 || !keys.includes("level")) {
321
+ sendError(res, 400, "INVALID_REQUEST", 'Field "readConcern" must contain only "level"');
322
+ return { ok: false };
323
+ }
324
+ if (!isReadConcernLevel(value.level)) {
325
+ sendError(res, 400, "INVALID_REQUEST", 'Field "readConcern.level" is invalid');
326
+ return { ok: false };
327
+ }
328
+ return { ok: true, value: { level: value.level } };
329
+ }
330
+ function parseWriteConcern(res, value) {
331
+ if (value === void 0) return { ok: true, value: void 0 };
332
+ if (!isPlainRecord(value)) {
333
+ sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern" must be an object when provided');
334
+ return { ok: false };
335
+ }
336
+ const allowedKeys = /* @__PURE__ */ new Set(["level", "timeoutMs"]);
337
+ if (!Object.keys(value).every((key) => allowedKeys.has(key))) {
338
+ sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern" contains unsupported keys');
339
+ return { ok: false };
340
+ }
341
+ if (!isWriteConcernLevel(value.level)) {
342
+ sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern.level" is invalid');
343
+ return { ok: false };
344
+ }
345
+ const timeoutMs = value.timeoutMs;
346
+ if (timeoutMs !== void 0 && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)) {
347
+ sendError(res, 400, "INVALID_REQUEST", 'Field "writeConcern.timeoutMs" must be a positive safe integer');
348
+ return { ok: false };
349
+ }
350
+ return {
351
+ ok: true,
352
+ value: timeoutMs === void 0 ? { level: value.level } : { level: value.level, timeoutMs }
353
+ };
354
+ }
355
+ function isPlainRecord(value) {
356
+ return typeof value === "object" && value !== null && !Array.isArray(value);
357
+ }
358
+ function isReadConcernLevel(value) {
359
+ return value === "local" || value === "majority" || value === "linearizable";
360
+ }
361
+ function isWriteConcernLevel(value) {
362
+ return value === "local" || value === "majority" || value === "all";
363
+ }
364
+ function handleClusterStatus(getClusterStatus) {
365
+ return (res, dbId) => {
366
+ if (!getClusterStatus) {
367
+ sendError(res, 404, "NOT_FOUND", "Cluster status is not configured");
368
+ return;
369
+ }
370
+ const status = getClusterStatus(dbId);
371
+ if (!status) {
372
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
373
+ return;
374
+ }
375
+ sendJson(res, toClusterStatusResponse(status));
376
+ };
377
+ }
378
+ function toClusterStatusResponse(status) {
379
+ return {
380
+ ...status,
381
+ currentPrimary: status.currentPrimary ? { ...status.currentPrimary } : status.currentPrimary,
382
+ readEndpoints: status.readEndpoints?.map((endpoint) => ({
383
+ ...endpoint,
384
+ readConcerns: [...endpoint.readConcerns]
385
+ })),
386
+ primaryTerm: status.primaryTerm?.toString()
387
+ };
388
+ }
389
+ function errorDetails(err) {
390
+ const details = err.details;
391
+ return details && Object.keys(details).length > 0 ? details : void 0;
392
+ }
393
+
394
+ // src/server/ws-handler.ts
233
395
  var DEFAULT_POLL_INTERVAL_MS = 50;
234
396
  var DEFAULT_MAX_PAYLOAD_LENGTH = 1048576;
235
397
  var WSHandler = class {
236
398
  sirannon;
237
399
  maxPayloadLength;
400
+ resolveExecutionTarget;
238
401
  connections = /* @__PURE__ */ new Map();
239
402
  cdcContexts = /* @__PURE__ */ new Map();
403
+ cdcPending = /* @__PURE__ */ new Map();
240
404
  closed = false;
241
405
  constructor(sirannon, options) {
242
406
  this.sirannon = sirannon;
243
407
  this.maxPayloadLength = options?.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
408
+ this.resolveExecutionTarget = options?.resolveExecutionTarget;
244
409
  }
245
- // --- Public API ---
246
- /**
247
- * Register a new WebSocket connection for the given database.
248
- *
249
- * Sends an error and closes the connection if the database does not exist,
250
- * is closed, or the handler itself has been shut down.
251
- */
252
- handleOpen(conn, databaseId) {
410
+ async handleOpen(conn, databaseId) {
253
411
  if (this.closed) {
254
412
  this.sendError(conn, "", "HANDLER_CLOSED", "WebSocket handler is shut down");
255
413
  conn.close(1013, "Handler shutting down");
256
414
  return;
257
415
  }
258
- const database = this.sirannon.get(databaseId);
416
+ const database = await this.sirannon.resolve(databaseId);
259
417
  if (!database) {
260
418
  this.sendError(conn, "", "DATABASE_NOT_FOUND", `Database '${databaseId}' not found`);
261
419
  conn.close(1008, "Database not found");
@@ -266,18 +424,26 @@ var WSHandler = class {
266
424
  conn.close(1008, "Database closed");
267
425
  return;
268
426
  }
427
+ let executionTarget;
428
+ try {
429
+ executionTarget = await this.resolveTarget(databaseId);
430
+ } catch (err) {
431
+ this.sendSirannonError(conn, "", err);
432
+ conn.close(1011, "Execution target resolution failed");
433
+ return;
434
+ }
435
+ if (!executionTarget) {
436
+ this.sendError(conn, "", "DATABASE_NOT_FOUND", `Database '${databaseId}' not found`);
437
+ conn.close(1008, "Database not found");
438
+ return;
439
+ }
269
440
  this.connections.set(conn, {
270
441
  databaseId,
271
442
  database,
443
+ executionTarget,
272
444
  subscriptions: /* @__PURE__ */ new Map()
273
445
  });
274
446
  }
275
- /**
276
- * Process an incoming WebSocket message.
277
- *
278
- * Validates the JSON payload, extracts the message type and correlation ID,
279
- * and routes to the appropriate handler.
280
- */
281
447
  handleMessage(conn, data) {
282
448
  const state = this.connections.get(conn);
283
449
  if (!state) return;
@@ -322,12 +488,6 @@ var WSHandler = class {
322
488
  this.sendError(conn, id, "UNKNOWN_TYPE", `Unknown message type: '${msg.type}'`);
323
489
  }
324
490
  }
325
- /**
326
- * Clean up after a WebSocket connection closes.
327
- *
328
- * Unsubscribes all active CDC subscriptions for this connection and
329
- * releases the per-database CDC context if no subscribers remain.
330
- */
331
491
  handleClose(conn) {
332
492
  const state = this.connections.get(conn);
333
493
  if (!state) return;
@@ -338,17 +498,10 @@ var WSHandler = class {
338
498
  this.maybeCleanupCDC(state.databaseId);
339
499
  this.connections.delete(conn);
340
500
  }
341
- /** Number of active WebSocket connections. */
342
501
  get connectionCount() {
343
502
  return this.connections.size;
344
503
  }
345
- /**
346
- * Shut down the handler.
347
- *
348
- * Unsubscribes all CDC subscriptions, closes all WebSocket connections
349
- * with code 1001 (Going Away), and releases all CDC resources.
350
- */
351
- close() {
504
+ async close() {
352
505
  if (this.closed) return;
353
506
  this.closed = true;
354
507
  for (const [conn, state] of this.connections) {
@@ -362,14 +515,13 @@ var WSHandler = class {
362
515
  for (const ctx of this.cdcContexts.values()) {
363
516
  ctx.stopPolling();
364
517
  try {
365
- ctx.cdcDb.close();
518
+ await ctx.cdcConn.close();
366
519
  } catch {
367
520
  }
368
521
  }
369
522
  this.cdcContexts.clear();
370
523
  }
371
- // --- Private message handlers ---
372
- handleQuery(conn, state, msg, id) {
524
+ async handleQuery(conn, state, msg, id) {
373
525
  if (typeof msg.sql !== "string") {
374
526
  this.sendError(conn, id, "INVALID_MESSAGE", 'Query message requires a "sql" string field');
375
527
  return;
@@ -380,13 +532,13 @@ var WSHandler = class {
380
532
  }
381
533
  try {
382
534
  const params = msg.params ?? void 0;
383
- const rows = state.database.query(msg.sql, params);
535
+ const rows = await state.executionTarget.query(msg.sql, params);
384
536
  this.send(conn, { type: "result", id, data: { rows } });
385
537
  } catch (err) {
386
538
  this.sendSirannonError(conn, id, err);
387
539
  }
388
540
  }
389
- handleExecute(conn, state, msg, id) {
541
+ async handleExecute(conn, state, msg, id) {
390
542
  if (typeof msg.sql !== "string") {
391
543
  this.sendError(conn, id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
392
544
  return;
@@ -397,7 +549,7 @@ var WSHandler = class {
397
549
  }
398
550
  try {
399
551
  const params = msg.params ?? void 0;
400
- const result = state.database.execute(msg.sql, params);
552
+ const result = await state.executionTarget.execute(msg.sql, params);
401
553
  this.send(conn, {
402
554
  type: "result",
403
555
  id,
@@ -407,7 +559,7 @@ var WSHandler = class {
407
559
  this.sendSirannonError(conn, id, err);
408
560
  }
409
561
  }
410
- handleSubscribe(conn, state, msg, id) {
562
+ async handleSubscribe(conn, state, msg, id) {
411
563
  if (typeof msg.table !== "string") {
412
564
  this.sendError(conn, id, "INVALID_MESSAGE", 'Subscribe message requires a "table" string field');
413
565
  return;
@@ -429,15 +581,19 @@ var WSHandler = class {
429
581
  return;
430
582
  }
431
583
  const filter = msg.filter ?? void 0;
584
+ let ctx = null;
432
585
  try {
433
- const ctx = this.ensureCDC(state.databaseId, state.database);
434
- ctx.tracker.watch(ctx.cdcDb, msg.table);
586
+ ctx = await this.ensureCDC(state.databaseId, state.database);
587
+ await ctx.tracker.watch(ctx.cdcConn, msg.table);
435
588
  const sub = ctx.manager.subscribe(msg.table, filter, (event) => {
436
589
  this.sendChange(conn, id, event);
437
590
  });
438
591
  state.subscriptions.set(id, sub);
439
592
  this.send(conn, { type: "subscribed", id });
440
593
  } catch (err) {
594
+ if (ctx?.manager.size === 0) {
595
+ this.maybeCleanupCDC(state.databaseId);
596
+ }
441
597
  this.sendSirannonError(conn, id, err);
442
598
  }
443
599
  }
@@ -452,31 +608,50 @@ var WSHandler = class {
452
608
  this.send(conn, { type: "unsubscribed", id });
453
609
  this.maybeCleanupCDC(state.databaseId);
454
610
  }
455
- // --- CDC management ---
456
- /**
457
- * Get or create a CDC context for the given database.
458
- *
459
- * Opens a separate better-sqlite3 connection for trigger installation and
460
- * change polling. WAL mode and a 5-second busy timeout are set so that
461
- * the CDC connection coexists with the Database class's connection pool.
462
- */
463
- ensureCDC(databaseId, database) {
611
+ async ensureCDC(databaseId, database) {
464
612
  const existing = this.cdcContexts.get(databaseId);
465
613
  if (existing) return existing;
466
- const cdcDb = new SqliteDatabase(database.path);
467
- cdcDb.pragma("journal_mode = WAL");
468
- cdcDb.pragma("busy_timeout = 5000");
614
+ const pending = this.cdcPending.get(databaseId);
615
+ if (pending) return pending;
616
+ const promise = this.createCDCContext(database);
617
+ this.cdcPending.set(databaseId, promise);
618
+ try {
619
+ const ctx = await promise;
620
+ if (this.closed) {
621
+ ctx.stopPolling();
622
+ await ctx.cdcConn.close().catch(() => {
623
+ });
624
+ throw new SirannonError("WebSocket handler is shut down", "HANDLER_CLOSED");
625
+ }
626
+ this.cdcContexts.set(databaseId, ctx);
627
+ return ctx;
628
+ } finally {
629
+ this.cdcPending.delete(databaseId);
630
+ }
631
+ }
632
+ async createCDCContext(database) {
633
+ const cdcConn = await this.sirannon.driver.open(database.path, { walMode: true });
469
634
  const tracker = new ChangeTracker();
470
635
  const manager = new SubscriptionManager();
636
+ try {
637
+ await tracker.advanceToLatest(cdcConn);
638
+ } catch (err) {
639
+ await cdcConn.close().catch(() => {
640
+ });
641
+ throw err;
642
+ }
643
+ let polling = false;
471
644
  let consecutiveErrors = 0;
472
645
  const MAX_CONSECUTIVE_ERRORS = 10;
473
646
  const stopPolling = () => {
474
647
  clearInterval(interval);
475
648
  };
476
- const tick = () => {
649
+ const tick = async () => {
477
650
  if (manager.size === 0) return;
651
+ if (polling) return;
652
+ polling = true;
478
653
  try {
479
- const events = tracker.poll(cdcDb);
654
+ const events = await tracker.poll(cdcConn);
480
655
  if (events.length > 0) {
481
656
  manager.dispatch(events);
482
657
  }
@@ -486,35 +661,32 @@ var WSHandler = class {
486
661
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
487
662
  stopPolling();
488
663
  }
664
+ } finally {
665
+ polling = false;
489
666
  }
490
667
  };
491
668
  const interval = setInterval(tick, DEFAULT_POLL_INTERVAL_MS);
492
- if (typeof interval.unref === "function") interval.unref();
493
- const ctx = { cdcDb, tracker, manager, stopPolling };
494
- this.cdcContexts.set(databaseId, ctx);
495
- return ctx;
496
- }
497
- /**
498
- * Release CDC resources for a database if no subscribers remain.
499
- *
500
- * Stops the polling loop and closes the dedicated CDC connection.
501
- */
669
+ interval.unref?.();
670
+ return { cdcConn, tracker, manager, stopPolling };
671
+ }
502
672
  maybeCleanupCDC(databaseId) {
503
673
  const ctx = this.cdcContexts.get(databaseId);
504
674
  if (!ctx || ctx.manager.size > 0) return;
505
675
  ctx.stopPolling();
506
- try {
507
- ctx.cdcDb.close();
508
- } catch {
509
- }
676
+ ctx.cdcConn.close().catch(() => {
677
+ });
510
678
  this.cdcContexts.delete(databaseId);
511
679
  }
512
- // --- Validation ---
513
680
  isValidParams(params) {
514
681
  if (params === void 0 || params === null) return true;
515
682
  return typeof params === "object";
516
683
  }
517
- // --- Send helpers ---
684
+ async resolveTarget(databaseId) {
685
+ if (!this.resolveExecutionTarget) {
686
+ return await this.sirannon.resolve(databaseId) ?? null;
687
+ }
688
+ return await this.resolveExecutionTarget(databaseId) ?? null;
689
+ }
518
690
  send(conn, msg) {
519
691
  try {
520
692
  conn.send(JSON.stringify(msg));
@@ -578,6 +750,10 @@ function writeCorsOrigin(res, cors, requestOrigin) {
578
750
  res.writeHeader("Vary", "Origin");
579
751
  }
580
752
  }
753
+ function selectWebSocketProtocol(header) {
754
+ const [firstProtocol] = header.split(",");
755
+ return firstProtocol?.trim() ?? "";
756
+ }
581
757
  function decodeRemoteAddress(res) {
582
758
  return Buffer.from(res.getRemoteAddressAsText()).toString();
583
759
  }
@@ -604,6 +780,9 @@ var SirannonServer = class {
604
780
  port;
605
781
  cors;
606
782
  onRequestHook;
783
+ resolveExecutionTarget;
784
+ getReplicationStatus;
785
+ getClusterStatus;
607
786
  sirannon;
608
787
  wsHandler;
609
788
  constructor(sirannon, options) {
@@ -612,7 +791,10 @@ var SirannonServer = class {
612
791
  this.port = options?.port ?? 9876;
613
792
  this.cors = resolveCors(options?.cors);
614
793
  this.onRequestHook = options?.onRequest;
615
- this.wsHandler = new WSHandler(sirannon);
794
+ this.resolveExecutionTarget = options?.resolveExecutionTarget;
795
+ this.getReplicationStatus = options?.getReplicationStatus;
796
+ this.getClusterStatus = options?.getClusterStatus;
797
+ this.wsHandler = new WSHandler(sirannon, { resolveExecutionTarget: this.resolveExecutionTarget });
616
798
  this.app = uWS.App();
617
799
  this.registerRoutes();
618
800
  }
@@ -628,15 +810,15 @@ var SirannonServer = class {
628
810
  });
629
811
  });
630
812
  }
631
- close() {
632
- return new Promise((resolve) => {
633
- this.wsHandler.close();
813
+ async close() {
814
+ try {
815
+ await this.wsHandler.close();
816
+ } finally {
634
817
  if (this.listenSocket) {
635
818
  uWS.us_listen_socket_close(this.listenSocket);
636
819
  this.listenSocket = null;
637
820
  }
638
- resolve();
639
- });
821
+ }
640
822
  }
641
823
  get listeningPort() {
642
824
  if (!this.listenSocket) return -1;
@@ -655,10 +837,14 @@ var SirannonServer = class {
655
837
  });
656
838
  }
657
839
  this.app.get("/health", this.withCors(handleLiveness()));
658
- this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon)));
659
- this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon)));
660
- this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon)));
661
- this.app.post("/db/:id/transaction", this.wrapDbRoute(handleTransaction(this.sirannon)));
840
+ this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon, this.getReplicationStatus)));
841
+ this.app.get("/db/:id/cluster", this.wrapDbGetRoute(handleClusterStatus(this.getClusterStatus)));
842
+ this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
843
+ this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
844
+ this.app.post(
845
+ "/db/:id/transaction",
846
+ this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget))
847
+ );
662
848
  this.registerWebSocketRoute();
663
849
  this.app.any("/*", (res) => {
664
850
  sendError(res, 404, "NOT_FOUND", "Route not found");
@@ -677,6 +863,7 @@ var SirannonServer = class {
677
863
  const method = req.getMethod();
678
864
  const secWebSocketKey = req.getHeader("sec-websocket-key");
679
865
  const secWebSocketProtocol = req.getHeader("sec-websocket-protocol");
866
+ const selectedWebSocketProtocol = selectWebSocketProtocol(secWebSocketProtocol);
680
867
  const secWebSocketExtensions = req.getHeader("sec-websocket-extensions");
681
868
  const headers = {};
682
869
  req.forEach((key, value) => {
@@ -692,7 +879,7 @@ var SirannonServer = class {
692
879
  res.upgrade(
693
880
  { databaseId: dbId },
694
881
  secWebSocketKey,
695
- secWebSocketProtocol,
882
+ selectedWebSocketProtocol,
696
883
  secWebSocketExtensions,
697
884
  context
698
885
  );
@@ -711,7 +898,7 @@ var SirannonServer = class {
711
898
  res.upgrade(
712
899
  { databaseId: dbId },
713
900
  secWebSocketKey,
714
- secWebSocketProtocol,
901
+ selectedWebSocketProtocol,
715
902
  secWebSocketExtensions,
716
903
  context
717
904
  );
@@ -735,7 +922,8 @@ var SirannonServer = class {
735
922
  }
736
923
  };
737
924
  userData.conn = conn;
738
- wsHandler.handleOpen(conn, userData.databaseId);
925
+ wsHandler.handleOpen(conn, userData.databaseId).catch(() => {
926
+ });
739
927
  },
740
928
  message: (ws, message) => {
741
929
  const userData = ws.getUserData();
@@ -773,9 +961,15 @@ var SirannonServer = class {
773
961
  const abort = initAbortHandler(res);
774
962
  const bodyPromise = readBody(res, MAX_BODY, abort);
775
963
  if (!onRequestHook) {
776
- bodyPromise.then((rawBody) => {
964
+ bodyPromise.then(async (rawBody) => {
777
965
  if (abort.aborted) return;
778
- handler(res, dbId, rawBody);
966
+ try {
967
+ await handler(res, dbId, rawBody, abort);
968
+ } catch {
969
+ if (!abort.aborted) {
970
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
971
+ }
972
+ }
779
973
  }).catch(() => {
780
974
  });
781
975
  return;
@@ -793,9 +987,48 @@ var SirannonServer = class {
793
987
  remoteAddress
794
988
  };
795
989
  const hookPromise = runOnRequest(res, ctx, onRequestHook);
796
- Promise.all([bodyPromise, hookPromise]).then(([rawBody, allowed]) => {
990
+ Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, allowed]) => {
797
991
  if (abort.aborted || !allowed) return;
798
- handler(res, dbId, rawBody);
992
+ try {
993
+ await handler(res, dbId, rawBody, abort);
994
+ } catch {
995
+ if (!abort.aborted) {
996
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
997
+ }
998
+ }
999
+ }).catch(() => {
1000
+ });
1001
+ };
1002
+ }
1003
+ wrapDbGetRoute(handler) {
1004
+ const onRequestHook = this.onRequestHook;
1005
+ const corsHeaders = this.cors;
1006
+ return (res, req) => {
1007
+ const dbId = req.getParameter(0) ?? "";
1008
+ const method = req.getMethod();
1009
+ const path = req.getUrl();
1010
+ if (corsHeaders) {
1011
+ writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
1012
+ }
1013
+ if (!onRequestHook) {
1014
+ handler(res, dbId);
1015
+ return;
1016
+ }
1017
+ const headers = {};
1018
+ req.forEach((key, value) => {
1019
+ headers[key] = value;
1020
+ });
1021
+ const ctx = {
1022
+ headers,
1023
+ method,
1024
+ path,
1025
+ databaseId: dbId,
1026
+ remoteAddress: decodeRemoteAddress(res)
1027
+ };
1028
+ runOnRequest(res, ctx, onRequestHook).then((allowed) => {
1029
+ if (allowed) {
1030
+ handler(res, dbId);
1031
+ }
799
1032
  }).catch(() => {
800
1033
  });
801
1034
  };