@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.
Files changed (72) hide show
  1. package/README.md +117 -969
  2. package/dist/backup-scheduler/index.d.ts +1 -1
  3. package/dist/backup-scheduler/index.mjs +2 -2
  4. package/dist/baseline-Br77Fnhb.d.ts +6 -0
  5. package/dist/{change-tracker-CbmaMO-N.d.ts → change-tracker-DKRVUC3l.d.ts} +5 -8
  6. package/dist/{chunk-DJLX6CAE.mjs → chunk-4ISB7XMA.mjs} +2 -1
  7. package/dist/chunk-5NOIGN5Y.mjs +18 -0
  8. package/dist/chunk-67M7KAH6.mjs +90 -0
  9. package/dist/{chunk-GEZUUIKV.mjs → chunk-D7LAYTKN.mjs} +5 -2
  10. package/dist/{chunk-4IGMIJQK.mjs → chunk-EBJXPQQO.mjs} +59 -20
  11. package/dist/{chunk-BNUTBHHH.mjs → chunk-FHWTZFI4.mjs} +1 -1
  12. package/dist/chunk-GVCNMPOS.mjs +130 -0
  13. package/dist/chunk-H237TXZW.mjs +1407 -0
  14. package/dist/{chunk-HHRMRFFR.mjs → chunk-H6PIVVDN.mjs} +353 -142
  15. package/dist/chunk-HR5CWTLC.mjs +661 -0
  16. package/dist/chunk-JU64Y7HM.mjs +299 -0
  17. package/dist/{chunk-CW6S3WL5.mjs → chunk-JZGINXTN.mjs} +7 -5
  18. package/dist/{chunk-CJLYFDP5.mjs → chunk-LFZ37BSX.mjs} +1 -1
  19. package/dist/chunk-LNY2VVHE.mjs +18 -0
  20. package/dist/chunk-NVQS53NT.mjs +665 -0
  21. package/dist/{chunk-H5AB6NIR.mjs → chunk-O7SLN3GI.mjs} +2 -15
  22. package/dist/chunk-OQVZBEBY.mjs +283 -0
  23. package/dist/{chunk-VLTICJOD.mjs → chunk-PIKHN33N.mjs} +1 -1
  24. package/dist/chunk-TJF5GZSV.mjs +187 -0
  25. package/dist/chunk-TUD5CJ76.mjs +126 -0
  26. package/dist/{chunk-YPYVQJ4C.mjs → chunk-UC3SCMIN.mjs} +9 -1
  27. package/dist/client/index.d.ts +216 -321
  28. package/dist/client/index.mjs +1192 -1342
  29. package/dist/client/topology.d.ts +61 -0
  30. package/dist/client/topology.mjs +682 -0
  31. package/dist/client-base-CLWmH5Ln.d.ts +234 -0
  32. package/dist/codegen/cli.d.ts +2 -0
  33. package/dist/codegen/cli.mjs +11 -0
  34. package/dist/codegen/index.d.ts +30 -0
  35. package/dist/codegen/index.mjs +5 -0
  36. package/dist/core/index.d.ts +37 -16
  37. package/dist/core/index.mjs +2322 -298
  38. package/dist/core/writer-worker.mjs +28 -6
  39. package/dist/{database-DuGp0Rtr.d.ts → database-BY0L5Q2n.d.ts} +59 -14
  40. package/dist/driver/better-sqlite3.d.ts +1 -1
  41. package/dist/driver/better-sqlite3.mjs +6 -6
  42. package/dist/driver/node.d.ts +1 -1
  43. package/dist/driver/node.mjs +6 -6
  44. package/dist/driver/wa-sqlite.d.ts +1 -136
  45. package/dist/{errors-5Nf5ZAEC.d.ts → errors-Bw5MdNCu.d.ts} +5 -1
  46. package/dist/file-migrations/index.d.ts +7 -5
  47. package/dist/file-migrations/index.mjs +42 -37
  48. package/dist/operation-registry-9DcvxcE5.d.ts +45 -0
  49. package/dist/primary-wins-DPAm2AKG.d.ts +61 -0
  50. package/dist/protocol-6KrSq2Hy.d.ts +66 -0
  51. package/dist/react/index.d.ts +20 -0
  52. package/dist/react/index.mjs +137 -0
  53. package/dist/replication/coordinator/etcd.d.ts +5 -3
  54. package/dist/replication/coordinator/etcd.mjs +554 -462
  55. package/dist/replication/index.d.ts +51 -105
  56. package/dist/replication/index.mjs +1415 -2061
  57. package/dist/server/index.d.ts +111 -87
  58. package/dist/server/index.mjs +1917 -489
  59. package/dist/server-options-1JHu8pid.d.ts +191 -0
  60. package/dist/{sirannon-4SspRvP5.d.ts → sirannon-DaQSyhbJ.d.ts} +7 -2
  61. package/dist/transport/grpc.d.ts +7 -4
  62. package/dist/transport/grpc.mjs +191 -97
  63. package/dist/transport/memory.d.ts +5 -134
  64. package/dist/transport/memory.mjs +3 -1
  65. package/dist/{types-BsjobKbl.d.ts → types-B7gmEsZW.d.ts} +8 -61
  66. package/dist/{types-BEu1I_9_.d.ts → types-BsVabqSI.d.ts} +1 -1
  67. package/dist/types-CL6piSnD.d.ts +43 -0
  68. package/dist/types-C_D8IhpO.d.ts +60 -0
  69. package/dist/{types-D_hQW1hr.d.ts → types-zhnRXrsb.d.ts} +55 -165
  70. package/package.json +44 -6
  71. package/dist/chunk-TKGHYWQ6.mjs +0 -35
  72. package/dist/types-D4p4UyDK.d.ts +0 -26
@@ -1,9 +1,15 @@
1
- import { ChangeTracker, SubscriptionManager, changeMatchesFilter, isBulkLoadDurability } from '../chunk-HHRMRFFR.mjs';
2
- import '../chunk-CJLYFDP5.mjs';
3
- import { encodeTaggedValues, decodeTaggedValues, META_TABLE } from '../chunk-GEZUUIKV.mjs';
4
- import { SirannonError, CDCError, WriteOverloadError } from '../chunk-YPYVQJ4C.mjs';
1
+ import { dumpSchema, tablesInFkOrder, dumpTablePages } from '../chunk-OQVZBEBY.mjs';
2
+ import { ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription } from '../chunk-H6PIVVDN.mjs';
3
+ import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, selectMaxChangeSeq, canonicaliseForChecksum, selectDeviceCursors, selectMinForeignChangeSeqSql, IDENTIFIER_RE, deleteDeviceCursorsUpdatedBefore } from '../chunk-NVQS53NT.mjs';
4
+ import '../chunk-5NOIGN5Y.mjs';
5
+ import { encodeTaggedValues, MIGRATIONS_TABLE, decodeTaggedValues, encodeWireRowsInPlace, CHANGES_TABLE, DEVICE_CURSORS_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-D7LAYTKN.mjs';
6
+ import { createOperationSource, operationRegistryDigest, findRead, isRefusal, resolveArguments, findWrite } from '../chunk-TUD5CJ76.mjs';
7
+ import '../chunk-GVCNMPOS.mjs';
8
+ import '../chunk-LFZ37BSX.mjs';
9
+ import { SQLITE_USER_VERSION_MAX } from '../chunk-67M7KAH6.mjs';
10
+ import { SirannonError, WriteOverloadError } from '../chunk-UC3SCMIN.mjs';
5
11
  import uWS from 'uWebSockets.js';
6
- import { randomBytes } from 'crypto';
12
+ import { createHash } from 'crypto';
7
13
 
8
14
  // src/server/protocol.ts
9
15
  function toExecuteResponse(result) {
@@ -124,102 +130,8 @@ function paramsBatchValidationError(value) {
124
130
  return null;
125
131
  }
126
132
 
127
- // src/server/cors.ts
128
- function resolveCors(cors) {
129
- if (!cors) return null;
130
- if (cors === true) {
131
- return {
132
- origin: "*",
133
- methods: "GET, POST, OPTIONS",
134
- headers: "Content-Type, Authorization"
135
- };
136
- }
137
- return {
138
- origin: cors.origin ?? "*",
139
- methods: cors.methods?.join(", ") ?? "GET, POST, OPTIONS",
140
- headers: cors.headers?.join(", ") ?? "Content-Type, Authorization"
141
- };
142
- }
143
- function matchOrigin(cors, requestOrigin) {
144
- if (typeof cors.origin === "string") return cors.origin;
145
- if (cors.origin.includes(requestOrigin)) return requestOrigin;
146
- return null;
147
- }
148
- function writeCorsOrigin(res, cors, requestOrigin) {
149
- const allowed = matchOrigin(cors, requestOrigin);
150
- if (!allowed) return;
151
- res.writeHeader("Access-Control-Allow-Origin", allowed);
152
- if (allowed !== "*") {
153
- res.writeHeader("Vary", "Origin");
154
- }
155
- }
156
-
157
- // src/server/health.ts
158
- function handleLiveness() {
159
- const payload = JSON.stringify({ status: "ok" });
160
- return (res) => {
161
- res.cork(() => {
162
- res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
163
- });
164
- };
165
- }
166
- function handleReadiness(sirannon, getReplicationStatus) {
167
- return (res) => {
168
- const dbs = sirannon.databases();
169
- const databases = [];
170
- let degraded = false;
171
- for (const [id, db] of dbs) {
172
- const entry = {
173
- id,
174
- readOnly: db.readOnly,
175
- closed: db.closed
176
- };
177
- databases.push(entry);
178
- if (db.closed) degraded = true;
179
- }
180
- const body = {
181
- status: degraded ? "degraded" : "ok",
182
- databases
183
- };
184
- if (getReplicationStatus) {
185
- const replStatus = getReplicationStatus();
186
- if (replStatus) {
187
- body.replication = {
188
- role: replStatus.role,
189
- writeForwarding: replStatus.writeForwarding,
190
- peers: replStatus.peers,
191
- localSeq: replStatus.localSeq.toString(),
192
- replicationGroupId: replStatus.replicationGroupId,
193
- primaryTerm: replStatus.primaryTerm?.toString(),
194
- currentPrimary: replStatus.currentPrimary,
195
- coordinator: replStatus.coordinator,
196
- controller: replStatus.controller,
197
- inSyncReplicas: replStatus.inSyncReplicas,
198
- laggingReplicas: replStatus.laggingReplicas,
199
- syncState: replStatus.syncState,
200
- readAvailability: replStatus.readAvailability,
201
- writeAvailability: replStatus.writeAvailability
202
- };
203
- body.status = readinessStatusForReplication(replStatus, body.status);
204
- }
205
- }
206
- const payload = JSON.stringify(body);
207
- res.cork(() => {
208
- res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
209
- });
210
- };
211
- }
212
- function readinessStatusForReplication(replication, current) {
213
- if (replication.syncState === "syncing" || replication.syncState === "catching-up") return "syncing";
214
- if (replication.controller?.state === "active" && replication.writeAvailability === "unavailable")
215
- return "failing_over";
216
- if (replication.readAvailability === "unavailable" && replication.writeAvailability === "unavailable")
217
- return "unavailable";
218
- if ((replication.laggingReplicas?.length ?? 0) > 0) return "degraded";
219
- return current;
220
- }
221
-
222
133
  // src/server/http-common.ts
134
+ var SQL_NOT_ACCEPTED_MESSAGE = "This server does not accept SQL statements over the wire; call a registered operation by name";
223
135
  function initAbortHandler(res) {
224
136
  const listeners = [];
225
137
  let aborted = false;
@@ -312,6 +224,8 @@ function sendError(res, status, code, message, details, headers) {
312
224
  });
313
225
  }
314
226
  function httpStatusForError(err) {
227
+ const explicit = err.status;
228
+ if (typeof explicit === "number") return explicit;
315
229
  switch (err.code) {
316
230
  case "DATABASE_NOT_FOUND":
317
231
  return 404;
@@ -322,6 +236,7 @@ function httpStatusForError(err) {
322
236
  case "TRANSACTION_ERROR":
323
237
  case "INVALID_DURABILITY":
324
238
  case "INVALID_SYNCHRONOUS":
239
+ case "BATCH_VALIDATION_ERROR":
325
240
  return 400;
326
241
  case "STALE_PRIMARY":
327
242
  case "PROTOCOL_VERSION_MISMATCH":
@@ -393,6 +308,251 @@ function parseWriteConcern(res, value) {
393
308
  return validation;
394
309
  }
395
310
 
311
+ // src/server/capabilities.ts
312
+ var REQUIRED_DEVICE_SYNC_CAPABILITIES = [
313
+ "sync.push",
314
+ "sync.echo-suppression",
315
+ "sync.ack",
316
+ "sync.resume",
317
+ "sync.snapshot",
318
+ "sync.migrations",
319
+ "sync.schema-gate",
320
+ "sync.stream-apply"
321
+ ];
322
+ var STAGED_STREAM_CAPABILITY = "sync.staged-stream";
323
+ var SERVER_CAPABILITIES = [...REQUIRED_DEVICE_SYNC_CAPABILITIES, STAGED_STREAM_CAPABILITY];
324
+ var NAMED_QUERY_CAPABILITY = "query.named";
325
+ var SQL_QUERY_CAPABILITY = "query.sql";
326
+ function buildCapabilitiesResponse(options) {
327
+ const capabilities = [...SERVER_CAPABILITIES];
328
+ if (options?.registryDigest !== void 0) capabilities.push(NAMED_QUERY_CAPABILITY);
329
+ if (options?.acceptSql !== false) capabilities.push(SQL_QUERY_CAPABILITY);
330
+ const response = { capabilities };
331
+ if (options?.registryDigest !== void 0) response.registry = { digest: options.registryDigest };
332
+ return response;
333
+ }
334
+ function handleCapabilities(options) {
335
+ const response = buildCapabilitiesResponse(options);
336
+ return (res) => {
337
+ sendJson(res, response);
338
+ };
339
+ }
340
+
341
+ // src/server/cors.ts
342
+ function resolveCors(cors) {
343
+ if (!cors) return null;
344
+ if (cors === true) {
345
+ return {
346
+ origin: "*",
347
+ methods: "GET, POST, OPTIONS",
348
+ headers: "Content-Type, Authorization"
349
+ };
350
+ }
351
+ return {
352
+ origin: cors.origin ?? "*",
353
+ methods: cors.methods?.join(", ") ?? "GET, POST, OPTIONS",
354
+ headers: cors.headers?.join(", ") ?? "Content-Type, Authorization"
355
+ };
356
+ }
357
+ function matchOrigin(cors, requestOrigin) {
358
+ if (typeof cors.origin === "string") return cors.origin;
359
+ if (cors.origin.includes(requestOrigin)) return requestOrigin;
360
+ return null;
361
+ }
362
+ function writeCorsOrigin(res, cors, requestOrigin) {
363
+ const allowed = matchOrigin(cors, requestOrigin);
364
+ if (!allowed) return;
365
+ res.writeHeader("Access-Control-Allow-Origin", allowed);
366
+ if (allowed !== "*") {
367
+ res.writeHeader("Vary", "Origin");
368
+ }
369
+ }
370
+
371
+ // src/server/health.ts
372
+ function handleLiveness() {
373
+ const payload = JSON.stringify({ status: "ok" });
374
+ return (res) => {
375
+ res.cork(() => {
376
+ res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
377
+ });
378
+ };
379
+ }
380
+ function handleReadiness(sirannon, getReplicationStatus) {
381
+ return (res) => {
382
+ const dbs = sirannon.databases();
383
+ const databases = [];
384
+ let degraded = false;
385
+ for (const [id, db] of dbs) {
386
+ const entry = {
387
+ id,
388
+ readOnly: db.readOnly,
389
+ closed: db.closed
390
+ };
391
+ databases.push(entry);
392
+ if (db.closed) degraded = true;
393
+ }
394
+ const body = {
395
+ status: degraded ? "degraded" : "ok",
396
+ databases
397
+ };
398
+ if (getReplicationStatus) {
399
+ const replStatus = getReplicationStatus();
400
+ if (replStatus) {
401
+ body.replication = {
402
+ role: replStatus.role,
403
+ writeForwarding: replStatus.writeForwarding,
404
+ peers: replStatus.peers,
405
+ localSeq: replStatus.localSeq.toString(),
406
+ replicationGroupId: replStatus.replicationGroupId,
407
+ primaryTerm: replStatus.primaryTerm?.toString(),
408
+ currentPrimary: replStatus.currentPrimary,
409
+ coordinator: replStatus.coordinator,
410
+ controller: replStatus.controller,
411
+ inSyncReplicas: replStatus.inSyncReplicas,
412
+ laggingReplicas: replStatus.laggingReplicas,
413
+ syncState: replStatus.syncState,
414
+ healthReason: replStatus.health.reason,
415
+ readAvailability: replStatus.health.canRead ? "available" : "unavailable",
416
+ writeAvailability: replStatus.health.canWrite ? "available" : "unavailable"
417
+ };
418
+ body.status = readinessStatusForReplication(replStatus, body.status);
419
+ }
420
+ }
421
+ const payload = JSON.stringify(body);
422
+ res.cork(() => {
423
+ res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
424
+ });
425
+ };
426
+ }
427
+ function readinessStatusForReplication(replication, current) {
428
+ if (replication.health.state !== "healthy") return replication.health.state;
429
+ return current;
430
+ }
431
+
432
+ // src/server/sync-protocol.ts
433
+ var MAX_SYNC_BATCH_CHANGES = 1e3;
434
+ var NODE_ID_RE = /^[0-9a-f]{32}$/;
435
+ var TABLE_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
436
+ var OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
437
+ function isValidDeviceId(candidate) {
438
+ return typeof candidate === "string" && NODE_ID_RE.test(candidate);
439
+ }
440
+ function isValidSchemaVersion(candidate) {
441
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0 && candidate <= SQLITE_USER_VERSION_MAX;
442
+ }
443
+ function schemaVersionValidationError(candidate) {
444
+ if (candidate === void 0) return null;
445
+ if (!isValidSchemaVersion(candidate)) {
446
+ return `"schemaVersion" must be an integer from 0 to ${SQLITE_USER_VERSION_MAX}`;
447
+ }
448
+ return null;
449
+ }
450
+ function schemaVersionGateRefusal(deviceVersion, serverVersion) {
451
+ if (deviceVersion < serverVersion) {
452
+ return {
453
+ code: "MIGRATION_REQUIRED",
454
+ message: `Device schema version ${deviceVersion} is behind server version ${serverVersion}; apply the missing migrations before syncing`
455
+ };
456
+ }
457
+ if (deviceVersion > serverVersion) {
458
+ return {
459
+ code: "SCHEMA_AHEAD",
460
+ message: `Device schema version ${deviceVersion} is ahead of server version ${serverVersion}; the server must be migrated before this device can sync`
461
+ };
462
+ }
463
+ return null;
464
+ }
465
+ function isPlainObject(value) {
466
+ return typeof value === "object" && value !== null && !Array.isArray(value);
467
+ }
468
+ function changeValidationError(change, index) {
469
+ if (!isPlainObject(change)) return `changes[${index}] must be an object`;
470
+ if (typeof change.table !== "string" || !TABLE_RE.test(change.table)) {
471
+ return `changes[${index}].table must be a valid table name`;
472
+ }
473
+ if (typeof change.operation !== "string" || !OPERATIONS.has(change.operation)) {
474
+ return `changes[${index}].operation must be one of insert, update, delete`;
475
+ }
476
+ if (typeof change.rowId !== "string" || change.rowId.length === 0) {
477
+ return `changes[${index}].rowId must be a non-empty string`;
478
+ }
479
+ if (!isPlainObject(change.primaryKey)) return `changes[${index}].primaryKey must be an object`;
480
+ if (typeof change.hlc !== "string" || change.hlc.length === 0) {
481
+ return `changes[${index}].hlc must be a non-empty string`;
482
+ }
483
+ if (typeof change.txId !== "string" || change.txId.length === 0) {
484
+ return `changes[${index}].txId must be a non-empty string`;
485
+ }
486
+ if (!isValidDeviceId(change.nodeId)) return `changes[${index}].nodeId must be a 32-hex node id`;
487
+ if (change.newData !== null && !isPlainObject(change.newData)) {
488
+ return `changes[${index}].newData must be an object or null`;
489
+ }
490
+ if (change.oldData !== null && !isPlainObject(change.oldData)) {
491
+ return `changes[${index}].oldData must be an object or null`;
492
+ }
493
+ return null;
494
+ }
495
+ function syncBatchValidationError(raw) {
496
+ if (!isPlainObject(raw)) return 'Field "batch" is required and must be an object';
497
+ if (!isValidDeviceId(raw.sourceNodeId)) return '"batch.sourceNodeId" must be a 32-hex node id';
498
+ if (typeof raw.batchId !== "string" || raw.batchId.length === 0 || raw.batchId.length > 128) {
499
+ return '"batch.batchId" must be a non-empty string of at most 128 characters';
500
+ }
501
+ if (typeof raw.fromSeq !== "string" || !SEQ_STRING_RE.test(raw.fromSeq)) {
502
+ return '"batch.fromSeq" must be a positive integer string';
503
+ }
504
+ if (typeof raw.toSeq !== "string" || !SEQ_STRING_RE.test(raw.toSeq)) {
505
+ return '"batch.toSeq" must be a positive integer string';
506
+ }
507
+ if (BigInt(raw.fromSeq) > BigInt(raw.toSeq)) return '"batch.fromSeq" must not exceed "batch.toSeq"';
508
+ if (!isPlainObject(raw.hlcRange) || typeof raw.hlcRange.min !== "string" || typeof raw.hlcRange.max !== "string") {
509
+ return '"batch.hlcRange" must carry string "min" and "max" fields';
510
+ }
511
+ if (!Array.isArray(raw.changes) || raw.changes.length === 0) {
512
+ return '"batch.changes" must be a non-empty array';
513
+ }
514
+ if (raw.changes.length > MAX_SYNC_BATCH_CHANGES) {
515
+ return `"batch.changes" must not exceed ${MAX_SYNC_BATCH_CHANGES} changes`;
516
+ }
517
+ if (typeof raw.checksum !== "string" || raw.checksum.length === 0) {
518
+ return '"batch.checksum" must be a non-empty string';
519
+ }
520
+ for (let i = 0; i < raw.changes.length; i++) {
521
+ const changeError = changeValidationError(raw.changes[i], i);
522
+ if (changeError !== null) return changeError;
523
+ const change = raw.changes[i];
524
+ if (change.nodeId !== raw.sourceNodeId) {
525
+ return `changes[${i}].nodeId must match "batch.sourceNodeId"`;
526
+ }
527
+ }
528
+ return null;
529
+ }
530
+ function decodeSyncBatch(body) {
531
+ const changes = body.changes.map((change) => ({
532
+ table: change.table,
533
+ operation: change.operation,
534
+ rowId: change.rowId,
535
+ primaryKey: decodeTaggedValues(change.primaryKey),
536
+ hlc: change.hlc,
537
+ txId: change.txId,
538
+ nodeId: change.nodeId,
539
+ newData: change.newData === null ? null : decodeTaggedValues(change.newData),
540
+ oldData: change.oldData === null ? null : decodeTaggedValues(change.oldData)
541
+ }));
542
+ return {
543
+ sourceNodeId: body.sourceNodeId,
544
+ batchId: body.batchId,
545
+ fromSeq: BigInt(body.fromSeq),
546
+ toSeq: BigInt(body.toSeq),
547
+ hlcRange: { min: body.hlcRange.min, max: body.hlcRange.max },
548
+ changes,
549
+ checksum: body.checksum
550
+ };
551
+ }
552
+ function toChangesResponse(result) {
553
+ return { applied: result.applied, skipped: result.skipped, conflicts: result.conflicts };
554
+ }
555
+
396
556
  // src/server/wire-rows.ts
397
557
  async function queryWireRows(target, sql, params, options) {
398
558
  if (target.queryForWire) {
@@ -589,10 +749,76 @@ function handleLoad(sirannon, resolveTarget) {
589
749
  }
590
750
  };
591
751
  }
592
- function handleClusterStatus(getClusterStatus) {
593
- return (res, dbId) => {
594
- if (!getClusterStatus) {
595
- sendError(res, 404, "NOT_FOUND", "Cluster status is not configured");
752
+ function handleChanges(sirannon, resolveTarget) {
753
+ return async (res, dbId, rawBody, abort) => {
754
+ const body = parseBody(res, rawBody);
755
+ if (!body) return;
756
+ const batchError = syncBatchValidationError(body.batch);
757
+ if (batchError !== null) {
758
+ sendError(res, 400, "INVALID_REQUEST", batchError);
759
+ return;
760
+ }
761
+ const schemaVersionError = schemaVersionValidationError(body.schemaVersion);
762
+ if (schemaVersionError !== null) {
763
+ sendError(res, 400, "INVALID_REQUEST", schemaVersionError);
764
+ return;
765
+ }
766
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
767
+ if (!target) return;
768
+ const appliedMigrations = target.appliedMigrations;
769
+ if (typeof appliedMigrations === "function") {
770
+ try {
771
+ const serverVersion = highestMigrationVersion(await appliedMigrations.call(target));
772
+ const refusal = schemaVersionGateRefusal(body.schemaVersion ?? 0, serverVersion);
773
+ if (refusal !== null) {
774
+ if (abort.aborted) return;
775
+ sendError(res, 409, refusal.code, refusal.message, { serverVersion });
776
+ return;
777
+ }
778
+ } catch (err) {
779
+ sendCaughtError(res, abort, err);
780
+ return;
781
+ }
782
+ }
783
+ const applyChanges = target.applyChanges;
784
+ if (typeof applyChanges !== "function") {
785
+ sendError(
786
+ res,
787
+ 501,
788
+ "SYNC_UNSUPPORTED",
789
+ "The execution target for this database does not support applying sync changes"
790
+ );
791
+ return;
792
+ }
793
+ try {
794
+ const result = await applyChanges.call(target, decodeSyncBatch(body.batch));
795
+ if (abort.aborted) return;
796
+ sendJson(res, toChangesResponse(result));
797
+ } catch (err) {
798
+ sendCaughtError(res, abort, err);
799
+ }
800
+ };
801
+ }
802
+ function sendClusterUnavailable(res, dbId) {
803
+ sendError(res, 404, "NOT_FOUND", `Cluster status is not available for database '${dbId}'`);
804
+ }
805
+ function handleClusterStatus(getClusterStatus, authorizeClusterStatus) {
806
+ return async (res, dbId, ctx, abort) => {
807
+ if (!getClusterStatus || !authorizeClusterStatus) {
808
+ sendClusterUnavailable(res, dbId);
809
+ return;
810
+ }
811
+ let allowed;
812
+ try {
813
+ allowed = await authorizeClusterStatus(ctx);
814
+ } catch {
815
+ if (abort.aborted) return;
816
+ sendError(res, 500, "HOOK_ERROR", "authorizeClusterStatus threw an error");
817
+ return;
818
+ }
819
+ if (abort.aborted) return;
820
+ if (!allowed) {
821
+ sendClusterUnavailable(res, dbId);
596
822
  return;
597
823
  }
598
824
  const status = getClusterStatus(dbId);
@@ -615,38 +841,514 @@ function toClusterStatusResponse(status) {
615
841
  };
616
842
  }
617
843
 
618
- // src/server/request-hook.ts
619
- function decodeRemoteAddress(res) {
620
- return Buffer.from(res.getRemoteAddressAsText()).toString();
621
- }
622
- function isRequestDenial(value) {
623
- return typeof value === "object" && value !== null && "status" in value;
624
- }
625
- async function runOnRequest(res, abort, ctx, hook) {
844
+ // src/server/http-migrations.ts
845
+ function handleMigrationList(sirannon) {
846
+ return async (res, dbId, rawBody, abort) => {
847
+ const body = parseBody(res, rawBody);
848
+ if (!body) return;
849
+ const afterError = schemaVersionValidationError(body.after);
850
+ if (afterError !== null) {
851
+ sendError(res, 400, "INVALID_REQUEST", afterError.replace('"schemaVersion"', '"after"'));
852
+ return;
853
+ }
854
+ const after = body.after ?? 0;
855
+ let database;
856
+ try {
857
+ database = await sirannon.resolve(dbId);
858
+ } catch (err) {
859
+ sendCaughtError(res, abort, err);
860
+ return;
861
+ }
862
+ if (abort.aborted) return;
863
+ if (!database) {
864
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
865
+ return;
866
+ }
867
+ try {
868
+ const applied = await database.appliedMigrations();
869
+ const registry = new Map((await sirannon.registryMigrations()).map((migration) => [migration.version, migration]));
870
+ const migrations = applied.map((row) => {
871
+ const entry = { version: row.version, name: row.name, checksum: row.checksum };
872
+ if (row.version > after && row.checksum !== null) {
873
+ const source = registry.get(row.version);
874
+ if (source !== void 0 && typeof source.up === "string" && migrationChecksum(source.up) === row.checksum) {
875
+ entry.up = source.up;
876
+ }
877
+ }
878
+ return entry;
879
+ });
880
+ if (abort.aborted) return;
881
+ const response = { serverVersion: highestMigrationVersion(applied), migrations };
882
+ sendJson(res, response);
883
+ } catch (err) {
884
+ sendCaughtError(res, abort, err);
885
+ }
886
+ };
887
+ }
888
+
889
+ // src/server/http-operations.ts
890
+ function parseOperationBody(res, rawBody) {
891
+ if (rawBody.length === 0) return {};
892
+ return parseBody(res, rawBody);
893
+ }
894
+ function decodeArguments(res, raw) {
895
+ if (raw === void 0) return {};
896
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
897
+ sendError(res, 400, "INVALID_REQUEST", 'Field "args" must be an object');
898
+ return null;
899
+ }
900
+ const decoded = decodeBoundParams(raw, "args");
901
+ if (!decoded.ok) {
902
+ sendError(res, 400, "INVALID_REQUEST", decoded.message);
903
+ return null;
904
+ }
905
+ return decoded.value ?? {};
906
+ }
907
+ function handleOperationQuery(sirannon, registry, resolveTarget) {
908
+ return async (res, dbId, name, identity, rawBody, abort) => {
909
+ const operation = findRead(registry, dbId, name);
910
+ if (isRefusal(operation)) {
911
+ sendError(res, operation.status, operation.code, operation.message);
912
+ return;
913
+ }
914
+ const body = parseOperationBody(res, rawBody);
915
+ if (!body) return;
916
+ const readConcern = parseReadConcern(res, body.readConcern);
917
+ if (!readConcern.ok) return;
918
+ const supplied = decodeArguments(res, body.args);
919
+ if (!supplied) return;
920
+ const args = resolveArguments(operation, supplied, identity);
921
+ if (!args.ok) {
922
+ sendError(res, args.refusal.status, args.refusal.code, args.refusal.message);
923
+ return;
924
+ }
925
+ let statement;
926
+ try {
927
+ statement = operation.statement(args.value);
928
+ } catch (err) {
929
+ sendCaughtError(res, abort, err);
930
+ return;
931
+ }
932
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
933
+ if (!target) return;
934
+ try {
935
+ const rows = await queryWireRows(
936
+ target,
937
+ statement.sql,
938
+ statement.params,
939
+ readConcern.value ? { readConcern: readConcern.value } : void 0
940
+ );
941
+ if (abort.aborted) return;
942
+ sendJson(res, { rows });
943
+ } catch (err) {
944
+ sendCaughtError(res, abort, err);
945
+ }
946
+ };
947
+ }
948
+ function handleOperationExecute(sirannon, registry, resolveTarget) {
949
+ return async (res, dbId, name, identity, rawBody, abort) => {
950
+ const operation = findWrite(registry, dbId, name);
951
+ if (isRefusal(operation)) {
952
+ sendError(res, operation.status, operation.code, operation.message);
953
+ return;
954
+ }
955
+ const body = parseOperationBody(res, rawBody);
956
+ if (!body) return;
957
+ const writeConcern = parseWriteConcern(res, body.writeConcern);
958
+ if (!writeConcern.ok) return;
959
+ const supplied = decodeArguments(res, body.args);
960
+ if (!supplied) return;
961
+ const args = resolveArguments(operation, supplied, identity);
962
+ if (!args.ok) {
963
+ sendError(res, args.refusal.status, args.refusal.code, args.refusal.message);
964
+ return;
965
+ }
966
+ let statements;
967
+ try {
968
+ const produced = operation.statements(args.value);
969
+ statements = Array.isArray(produced) ? produced : [produced];
970
+ } catch (err) {
971
+ sendCaughtError(res, abort, err);
972
+ return;
973
+ }
974
+ if (statements.length === 0) {
975
+ sendError(res, 500, "INTERNAL_ERROR", `Operation '${name}' produced no statements`);
976
+ return;
977
+ }
978
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
979
+ if (!target) return;
980
+ const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
981
+ try {
982
+ const results = target.executeTransaction ? await target.executeTransaction(statements, txOptions) : await target.transaction(async (tx) => {
983
+ const txResults = [];
984
+ for (const statement of statements) {
985
+ txResults.push(await tx.execute(statement.sql, statement.params));
986
+ }
987
+ return txResults;
988
+ }, txOptions);
989
+ if (abort.aborted) return;
990
+ sendJson(res, { results: results.map(toExecuteResponse) });
991
+ } catch (err) {
992
+ sendCaughtError(res, abort, err);
993
+ }
994
+ };
995
+ }
996
+
997
+ // src/server/snapshot-protocol.ts
998
+ var SNAPSHOT_MAX_PAGE_ROWS = 1e3;
999
+ var SNAPSHOT_DEFAULT_PAGE_ROWS = 500;
1000
+ var SNAPSHOT_PAGE_BYTE_CAP = 8 * 1024 * 1024;
1001
+ function isDumpableTableName(candidate) {
1002
+ return typeof candidate === "string" && IDENTIFIER_RE.test(candidate) && !candidate.startsWith(INTERNAL_TABLE_PREFIX) && !candidate.toLowerCase().startsWith("sqlite_");
1003
+ }
1004
+ function snapshotPageValidationError(raw) {
1005
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1006
+ return "Request body must be an object";
1007
+ }
1008
+ const record = raw;
1009
+ if (!isDumpableTableName(record.table)) {
1010
+ return 'Field "table" is required and must be a dumpable table name';
1011
+ }
1012
+ if (record.afterKey !== void 0) {
1013
+ if (!Array.isArray(record.afterKey) || record.afterKey.length === 0 || record.afterKey.length > 16) {
1014
+ return '"afterKey" must be a non-empty array of at most 16 key values';
1015
+ }
1016
+ }
1017
+ if (record.limit !== void 0) {
1018
+ if (typeof record.limit !== "number" || !Number.isInteger(record.limit) || record.limit < 1) {
1019
+ return '"limit" must be a positive integer';
1020
+ }
1021
+ if (record.limit > SNAPSHOT_MAX_PAGE_ROWS) {
1022
+ return `"limit" must not exceed ${SNAPSHOT_MAX_PAGE_ROWS}`;
1023
+ }
1024
+ }
1025
+ return null;
1026
+ }
1027
+
1028
+ // src/server/http-snapshot.ts
1029
+ async function resolveSnapshotDatabase(res, abort, sirannon, dbId) {
1030
+ let database;
626
1031
  try {
627
- const result = await hook(ctx);
628
- if (isRequestDenial(result)) {
629
- if (abort.claim()) sendError(res, result.status, result.code, result.message);
630
- return false;
1032
+ database = await sirannon.resolve(dbId);
1033
+ } catch (err) {
1034
+ if (abort.aborted) return null;
1035
+ sendCaughtError(res, abort, err);
1036
+ return null;
1037
+ }
1038
+ if (abort.aborted) return null;
1039
+ if (!database) {
1040
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
1041
+ return null;
1042
+ }
1043
+ if (database.path === ":memory:") {
1044
+ sendError(res, 400, "SNAPSHOT_UNSUPPORTED", "Snapshots require file-based databases");
1045
+ return null;
1046
+ }
1047
+ return database;
1048
+ }
1049
+ async function changeLogStartSeq(conn) {
1050
+ if (!await selectTableExists(conn, CHANGES_TABLE)) return 0n;
1051
+ return selectMaxChangeSeq(conn);
1052
+ }
1053
+ function handleSnapshotManifest(sirannon) {
1054
+ return async (res, dbId, _rawBody, abort) => {
1055
+ const database = await resolveSnapshotDatabase(res, abort, sirannon, dbId);
1056
+ if (!database) return;
1057
+ try {
1058
+ let epoch = "";
1059
+ await database.runCdcMaintenance(async (writer) => {
1060
+ epoch = await ensureCdcEpoch(writer);
1061
+ });
1062
+ const conn = await sirannon.driver.open(database.path, { walMode: true });
1063
+ try {
1064
+ const startSeq = await changeLogStartSeq(conn);
1065
+ const schema = await dumpSchema(conn);
1066
+ const tableNames = await tablesInFkOrder(conn);
1067
+ const tables = [];
1068
+ for (const name of tableNames) {
1069
+ tables.push({ name, rowCount: await selectCountTableRows(conn, name) });
1070
+ }
1071
+ const migrations = await selectTableExists(conn, MIGRATIONS_TABLE) ? await selectAppliedMigrations(conn) : [];
1072
+ if (abort.aborted) return;
1073
+ const manifest = {
1074
+ databaseId: dbId,
1075
+ startSeq: startSeq.toString(),
1076
+ epoch,
1077
+ schema,
1078
+ tables,
1079
+ migrations
1080
+ };
1081
+ sendJson(res, manifest);
1082
+ } finally {
1083
+ await conn.close();
1084
+ }
1085
+ } catch (err) {
1086
+ sendCaughtError(res, abort, err);
631
1087
  }
632
- return true;
1088
+ };
1089
+ }
1090
+ function trimPageToByteCap(rows, checksum, isLast) {
1091
+ let kept = rows;
1092
+ let keptChecksum = checksum;
1093
+ let keptIsLast = isLast;
1094
+ let canonical = canonicaliseForChecksum(kept);
1095
+ while (canonical.length > SNAPSHOT_PAGE_BYTE_CAP && kept.length > 1) {
1096
+ kept = kept.slice(0, Math.ceil(kept.length / 2));
1097
+ canonical = canonicaliseForChecksum(kept);
1098
+ keptChecksum = createHash("sha256").update(canonical).digest("hex");
1099
+ keptIsLast = false;
1100
+ }
1101
+ return { rows: kept, checksum: keptChecksum, isLast: keptIsLast };
1102
+ }
1103
+ function handleSnapshotPage(sirannon) {
1104
+ return async (res, dbId, rawBody, abort) => {
1105
+ const body = parseBody(res, rawBody);
1106
+ if (!body) return;
1107
+ const validationError = snapshotPageValidationError(body);
1108
+ if (validationError !== null) {
1109
+ sendError(res, 400, "INVALID_REQUEST", validationError);
1110
+ return;
1111
+ }
1112
+ const database = await resolveSnapshotDatabase(res, abort, sirannon, dbId);
1113
+ if (!database) return;
1114
+ try {
1115
+ const conn = await sirannon.driver.open(database.path, { walMode: true });
1116
+ try {
1117
+ if (!await selectTableExists(conn, body.table)) {
1118
+ sendError(res, 404, "TABLE_NOT_FOUND", `Table '${body.table}' not found`);
1119
+ return;
1120
+ }
1121
+ const pkResolver = new PkResolver(conn);
1122
+ const pkColumns = await pkResolver.forTableOnConnection(conn, body.table);
1123
+ let startAfter;
1124
+ if (body.afterKey !== void 0) {
1125
+ if (body.afterKey.length !== pkColumns.length) {
1126
+ sendError(
1127
+ res,
1128
+ 400,
1129
+ "INVALID_REQUEST",
1130
+ `"afterKey" must carry ${pkColumns.length} value(s) for table '${body.table}'`
1131
+ );
1132
+ return;
1133
+ }
1134
+ startAfter = decodeTaggedValues(body.afterKey);
1135
+ }
1136
+ const limit = body.limit ?? SNAPSHOT_DEFAULT_PAGE_ROWS;
1137
+ const pages = dumpTablePages(conn, pkResolver, body.table, limit, startAfter);
1138
+ const first = await pages.next();
1139
+ await pages.return(void 0);
1140
+ if (abort.aborted) return;
1141
+ if (first.done || first.value === void 0) {
1142
+ const empty = { rows: [], checksum: "", done: true, nextKey: null };
1143
+ sendJson(res, empty);
1144
+ return;
1145
+ }
1146
+ const page = trimPageToByteCap(first.value.rows, first.value.checksum, first.value.isLast);
1147
+ let nextKey = null;
1148
+ if (!page.isLast && page.rows.length > 0) {
1149
+ const lastRow = page.rows[page.rows.length - 1];
1150
+ nextKey = encodeTaggedValues(pkColumns.map((column) => lastRow[column]));
1151
+ }
1152
+ const response = {
1153
+ rows: encodeWireRowsInPlace(page.rows),
1154
+ checksum: page.checksum,
1155
+ done: page.isLast,
1156
+ nextKey
1157
+ };
1158
+ sendJson(res, response);
1159
+ } finally {
1160
+ await conn.close();
1161
+ }
1162
+ } catch (err) {
1163
+ sendCaughtError(res, abort, err);
1164
+ }
1165
+ };
1166
+ }
1167
+
1168
+ // src/core/ws-handshake.ts
1169
+ var SIRANNON_WS_SUBPROTOCOL = "sirannon.v1";
1170
+ var WS_CLOSE_UNAUTHENTICATED = 4401;
1171
+ var WS_CLOSE_FORBIDDEN = 4403;
1172
+ function selectSubprotocol(header) {
1173
+ const offered = header.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
1174
+ if (offered.length === 0) return { ok: true, protocol: "" };
1175
+ if (offered.includes(SIRANNON_WS_SUBPROTOCOL)) return { ok: true, protocol: SIRANNON_WS_SUBPROTOCOL };
1176
+ return { ok: false };
1177
+ }
1178
+
1179
+ // src/server/request-hook.ts
1180
+ function decodeRemoteAddress(res) {
1181
+ return Buffer.from(res.getRemoteAddressAsText()).toString();
1182
+ }
1183
+ var CLOSE_REASON_MAX_BYTES = 123;
1184
+ var RETURNED_REFUSAL_MESSAGE = "The authenticate hook returned a refusal object rather than an identity. A hook refuses a request by throwing a SirannonError; every value it returns becomes the caller identity.";
1185
+ function looksLikeRefusal(value) {
1186
+ if (typeof value !== "object" || value === null) return false;
1187
+ const candidate = value;
1188
+ return typeof candidate.status === "number" && typeof candidate.code === "string" && typeof candidate.message === "string";
1189
+ }
1190
+ async function invokeAuthenticate(ctx, hook) {
1191
+ try {
1192
+ const identity = await hook(ctx);
1193
+ if (looksLikeRefusal(identity)) return { status: "returned-refusal" };
1194
+ return { status: "identity", identity };
1195
+ } catch (error) {
1196
+ return { status: "threw", error };
1197
+ }
1198
+ }
1199
+ function sendRefusal(res, abort, outcome) {
1200
+ if (!abort.claim()) return;
1201
+ if (outcome.status === "returned-refusal") {
1202
+ sendError(res, 500, "HOOK_ERROR", RETURNED_REFUSAL_MESSAGE);
1203
+ return;
1204
+ }
1205
+ if (outcome.status === "threw" && outcome.error instanceof SirannonError) {
1206
+ sendCaughtError(res, abort, outcome.error);
1207
+ return;
1208
+ }
1209
+ sendError(res, 500, "HOOK_ERROR", "authenticate hook threw an error");
1210
+ }
1211
+ function truncateCloseReason(reason) {
1212
+ if (Buffer.byteLength(reason, "utf-8") <= CLOSE_REASON_MAX_BYTES) return reason;
1213
+ let truncated = reason.slice(0, CLOSE_REASON_MAX_BYTES);
1214
+ while (truncated.length > 0 && Buffer.byteLength(truncated, "utf-8") > CLOSE_REASON_MAX_BYTES) {
1215
+ truncated = truncated.slice(0, -1);
1216
+ }
1217
+ return truncated;
1218
+ }
1219
+ function upgradeRefusalFor(error) {
1220
+ if (!(error instanceof SirannonError)) return void 0;
1221
+ const status = httpStatusForError(error);
1222
+ if (status !== 401 && status !== 403) return void 0;
1223
+ return {
1224
+ code: status === 403 ? WS_CLOSE_FORBIDDEN : WS_CLOSE_UNAUTHENTICATED,
1225
+ reason: truncateCloseReason(`${error.code}: ${error.message}`)
1226
+ };
1227
+ }
1228
+ async function runAuthenticate(res, abort, ctx, hook) {
1229
+ const outcome = await invokeAuthenticate(ctx, hook);
1230
+ if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
1231
+ sendRefusal(res, abort, outcome);
1232
+ return { ok: false };
1233
+ }
1234
+ async function authenticateUpgrade(res, abort, ctx, hook) {
1235
+ const outcome = await invokeAuthenticate(ctx, hook);
1236
+ if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
1237
+ if (outcome.status === "threw") {
1238
+ const refusal = upgradeRefusalFor(outcome.error);
1239
+ if (refusal) return { ok: false, refusal };
1240
+ }
1241
+ sendRefusal(res, abort, outcome);
1242
+ return { ok: false, refusal: void 0 };
1243
+ }
1244
+
1245
+ // src/server/operation-route.ts
1246
+ function decodePathSegment(value) {
1247
+ try {
1248
+ return decodeURIComponent(value);
633
1249
  } catch {
634
- if (abort.claim()) sendError(res, 500, "HOOK_ERROR", "onRequest hook threw an error");
635
- return false;
1250
+ return null;
636
1251
  }
637
1252
  }
638
- var EPOCH_KEY = "cdc_epoch";
639
- async function ensureCdcEpoch(conn) {
640
- await conn.exec(`CREATE TABLE IF NOT EXISTS "${META_TABLE}" (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
641
- const insert = await conn.prepare(`INSERT OR IGNORE INTO "${META_TABLE}" (key, value) VALUES (?, ?)`);
642
- await insert.run(EPOCH_KEY, randomBytes(16).toString("hex"));
643
- const select = await conn.prepare(`SELECT value FROM "${META_TABLE}" WHERE key = ?`);
644
- const row = await select.get(EPOCH_KEY);
645
- const value = row?.value;
646
- if (typeof value !== "string" || value.length === 0) {
647
- throw new CDCError("Failed to read the CDC epoch identifier");
1253
+ function wrapOperationRoute(deps, handler) {
1254
+ return (res, req) => {
1255
+ const dbId = req.getParameter(0) ?? "";
1256
+ const rawName = req.getParameter(1) ?? "";
1257
+ const method = req.getMethod();
1258
+ const path = req.getUrl();
1259
+ if (deps.cors) writeCorsOrigin(res, deps.cors, req.getHeader("origin"));
1260
+ const name = decodePathSegment(rawName);
1261
+ if (name === null) {
1262
+ sendError(res, 400, "INVALID_REQUEST", `Operation name '${rawName}' is not valid percent-encoding`);
1263
+ return;
1264
+ }
1265
+ const headers = {};
1266
+ req.forEach((key, value) => {
1267
+ headers[key] = value;
1268
+ });
1269
+ const abort = initAbortHandler(res);
1270
+ const bodyPromise = readBody(res, deps.maxBodyBytes, abort);
1271
+ const ctx = {
1272
+ headers,
1273
+ method,
1274
+ path,
1275
+ databaseId: dbId,
1276
+ remoteAddress: decodeRemoteAddress(res)
1277
+ };
1278
+ const run = async () => {
1279
+ const rawBody = await bodyPromise;
1280
+ let identity;
1281
+ if (deps.authenticateHook) {
1282
+ const authenticated = await runAuthenticate(res, abort, ctx, deps.authenticateHook);
1283
+ if (!authenticated.ok) return;
1284
+ identity = authenticated.identity;
1285
+ }
1286
+ if (!abort.claim()) return;
1287
+ try {
1288
+ await handler(res, dbId, name, identity, rawBody, abort);
1289
+ } catch {
1290
+ if (!abort.aborted) {
1291
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
1292
+ }
1293
+ }
1294
+ };
1295
+ run().catch(() => {
1296
+ });
1297
+ };
1298
+ }
1299
+
1300
+ // src/server/device-cursors.ts
1301
+ var DEFAULT_DEVICE_CURSOR_RETENTION_MS = 30 * 24 * 36e5;
1302
+ async function upsertDeviceAck(conn, deviceId, seq) {
1303
+ await ensureDeviceCursorsTable(conn);
1304
+ await upsertDeviceCursor(conn, deviceId, seq, Date.now() / 1e3);
1305
+ }
1306
+ function evictStaleDeviceCursors(conn, retentionMs) {
1307
+ return deleteDeviceCursorsUpdatedBefore(conn, Date.now() / 1e3 - retentionMs / 1e3);
1308
+ }
1309
+ async function effectiveMinDeviceCursor(conn, retentionMs, changesTable = CHANGES_TABLE) {
1310
+ if (!await selectTableExists(conn, DEVICE_CURSORS_TABLE)) return null;
1311
+ await evictStaleDeviceCursors(conn, retentionMs);
1312
+ const cursors = await selectDeviceCursors(conn);
1313
+ if (cursors.length === 0) return null;
1314
+ const nextForeignStmt = await conn.prepare(selectMinForeignChangeSeqSql(changesTable));
1315
+ const maxSeq = await selectMaxChangeSeq(conn, changesTable);
1316
+ let min = null;
1317
+ for (const cursor of cursors) {
1318
+ const acked = cursor.ackedSeq;
1319
+ const foreignRow = await nextForeignStmt.get(acked.toString(), cursor.deviceId);
1320
+ const nextForeign = foreignRow?.seq === null || foreignRow?.seq === void 0 ? null : BigInt(foreignRow.seq);
1321
+ const effective = nextForeign === null ? maxSeq : nextForeign - 1n;
1322
+ if (min === null || effective < min) {
1323
+ min = effective;
1324
+ }
1325
+ }
1326
+ return min;
1327
+ }
1328
+
1329
+ // src/server/ws-ack.ts
1330
+ async function handleAckMessage(deps, conn, state, msg, id) {
1331
+ if (!isValidDeviceId(msg.deviceId)) {
1332
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"deviceId" must be a 32-hex device id');
1333
+ return;
1334
+ }
1335
+ if (typeof msg.seq !== "string" || !SEQ_STRING_RE.test(msg.seq)) {
1336
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"seq" must be a non-negative integer string');
1337
+ return;
1338
+ }
1339
+ const deviceId = msg.deviceId;
1340
+ const seq = BigInt(msg.seq);
1341
+ try {
1342
+ await state.database.runCdcMaintenance((writer) => upsertDeviceAck(writer, deviceId, seq));
1343
+ for (const stream of state.deviceStreams.values()) {
1344
+ if (stream.deviceId === deviceId) {
1345
+ stream.onAck(seq);
1346
+ }
1347
+ }
1348
+ deps.sendResult(conn, id, { acked: true, seq: seq.toString() });
1349
+ } catch (err) {
1350
+ deps.sendSirannonError(conn, id, err);
648
1351
  }
649
- return value;
650
1352
  }
651
1353
 
652
1354
  // src/server/ws-cdc.ts
@@ -655,12 +1357,14 @@ var CLEANUP_INTERVAL_TICKS = 100;
655
1357
  var CdcContextRegistry = class {
656
1358
  sirannon;
657
1359
  retentionMs;
1360
+ deviceCursorRetentionMs;
658
1361
  contexts = /* @__PURE__ */ new Map();
659
1362
  pending = /* @__PURE__ */ new Map();
660
1363
  closed = false;
661
- constructor(sirannon, retentionMs) {
1364
+ constructor(sirannon, retentionMs, deviceCursorRetentionMs) {
662
1365
  this.sirannon = sirannon;
663
1366
  this.retentionMs = retentionMs;
1367
+ this.deviceCursorRetentionMs = deviceCursorRetentionMs ?? DEFAULT_DEVICE_CURSOR_RETENTION_MS;
664
1368
  }
665
1369
  async ensure(databaseId, database) {
666
1370
  const existing = this.contexts.get(databaseId);
@@ -742,11 +1446,20 @@ var CdcContextRegistry = class {
742
1446
  if (events.length > 0) {
743
1447
  manager.dispatch(events);
744
1448
  }
1449
+ manager.endBatch(tracker.pollEndedAtTxBoundary);
745
1450
  consecutiveErrors = 0;
746
1451
  tickCount++;
747
1452
  if (tickCount >= CLEANUP_INTERVAL_TICKS) {
748
1453
  tickCount = 0;
749
- await database.runCdcMaintenance((writer) => tracker.cleanup(writer)).catch(() => {
1454
+ await database.runCdcMaintenance(async (writer) => {
1455
+ const minCursor = await effectiveMinDeviceCursor(writer, this.deviceCursorRetentionMs);
1456
+ if (minCursor === null) {
1457
+ tracker.clearPruneBoundary();
1458
+ } else {
1459
+ tracker.setPruneBoundary(minCursor);
1460
+ }
1461
+ await tracker.cleanup(writer);
1462
+ }).catch(() => {
750
1463
  });
751
1464
  }
752
1465
  } catch {
@@ -764,273 +1477,965 @@ var CdcContextRegistry = class {
764
1477
  }
765
1478
  };
766
1479
 
767
- // src/server/ws-cdc-resume.ts
768
- var REPLAY_BATCH_SIZE = 1e3;
769
- var DEFAULT_PRIME_BUFFER_LIMIT = 1e4;
770
- var DEFAULT_PRIME_BUFFER_BYTES = 16 * 1048576;
771
- function approxEventBytes(event) {
772
- let bytes = 0;
773
- if (event.row) bytes += JSON.stringify(encodeTaggedValues(event.row)).length;
774
- if (event.oldRow) bytes += JSON.stringify(encodeTaggedValues(event.oldRow)).length;
775
- return bytes;
776
- }
777
- function needsResync(sinceSeq, minSeq, boundary) {
778
- if (sinceSeq >= boundary) return false;
779
- if (minSeq === null) return true;
780
- return minSeq > sinceSeq + 1n;
781
- }
782
- var PrimedSubscription = class {
783
- constructor(sinceSeq, deliver, onOverload, bufferLimit = DEFAULT_PRIME_BUFFER_LIMIT, bufferByteLimit = DEFAULT_PRIME_BUFFER_BYTES) {
784
- this.sinceSeq = sinceSeq;
785
- this.deliver = deliver;
786
- this.onOverload = onOverload;
787
- this.bufferLimit = bufferLimit;
788
- this.bufferByteLimit = bufferByteLimit;
789
- }
790
- mode = "priming";
791
- buffer = [];
792
- bufferBytes = 0;
793
- bufferLimit;
794
- bufferByteLimit;
795
- onLiveEvent(event) {
796
- if (this.mode === "stopped") return;
797
- if (this.mode === "live") {
798
- this.emit(event);
799
- return;
1480
+ // src/server/ws-connection.ts
1481
+ var WS_CLOSE_OVERLOADED = 4290;
1482
+
1483
+ // src/server/ws-device-frames.ts
1484
+ var DEVICE_FRAME_TARGET_BYTES = 65536;
1485
+ function wireChangeEvent(event) {
1486
+ return {
1487
+ type: event.type,
1488
+ table: event.table,
1489
+ row: encodeTaggedValues(event.row),
1490
+ oldRow: event.oldRow === void 0 ? void 0 : encodeTaggedValues(event.oldRow),
1491
+ seq: event.seq.toString(),
1492
+ timestamp: event.timestamp,
1493
+ ...event.hlc !== void 0 ? { hlc: event.hlc } : {},
1494
+ ...event.origin !== void 0 ? { origin: event.origin } : {},
1495
+ ...event.rowId !== void 0 ? { rowId: event.rowId } : {},
1496
+ ...event.txId !== void 0 ? { txId: event.txId } : {},
1497
+ ...event.txEnd === true ? { txEnd: true } : {}
1498
+ };
1499
+ }
1500
+ var DeviceFramePacker = class {
1501
+ constructor(subscriptionId, sendText, targetBytes = DEVICE_FRAME_TARGET_BYTES) {
1502
+ this.subscriptionId = subscriptionId;
1503
+ this.sendText = sendText;
1504
+ this.targetBytes = targetBytes;
1505
+ }
1506
+ parts = [];
1507
+ bytes = 0;
1508
+ append(event) {
1509
+ let encoded;
1510
+ try {
1511
+ encoded = JSON.stringify(wireChangeEvent(event));
1512
+ } catch {
1513
+ return "dropped";
800
1514
  }
801
- const nextBytes = this.bufferBytes + approxEventBytes(event);
802
- if (this.buffer.length >= this.bufferLimit || nextBytes > this.bufferByteLimit) {
803
- this.stop();
804
- this.onOverload();
805
- return;
1515
+ let outcome = "queued";
1516
+ if (this.parts.length > 0 && this.bytes + encoded.length > this.targetBytes) {
1517
+ outcome = this.flush();
1518
+ if (outcome === "dropped") return outcome;
806
1519
  }
807
- this.buffer.push(event);
808
- this.bufferBytes = nextBytes;
809
- }
810
- async replay(tracker, conn, table, filter, boundary) {
811
- let cursor = this.sinceSeq;
812
- while (this.mode === "priming" && cursor < boundary) {
813
- const events = await tracker.readSince(conn, table, cursor, boundary, REPLAY_BATCH_SIZE);
814
- if (events.length === 0) break;
815
- for (const event of events) {
816
- if (filter && !changeMatchesFilter(event, filter)) continue;
817
- if (!this.emit(event)) return;
818
- }
819
- const lastEvent = events[events.length - 1];
820
- if (lastEvent === void 0) break;
821
- cursor = lastEvent.seq;
822
- if (events.length < REPLAY_BATCH_SIZE) break;
1520
+ this.parts.push(encoded);
1521
+ this.bytes += encoded.length;
1522
+ if (this.bytes >= this.targetBytes) {
1523
+ outcome = worstOutcome(outcome, this.flush());
823
1524
  }
1525
+ return outcome;
824
1526
  }
825
- goLive() {
826
- if (this.mode !== "priming") return;
827
- const pending = this.buffer;
828
- this.buffer = [];
829
- for (const event of pending) {
830
- if (!this.emit(event)) return;
831
- }
832
- if (this.mode === "priming") {
833
- this.mode = "live";
1527
+ flush() {
1528
+ if (this.parts.length === 0) return "queued";
1529
+ const frame = `{"type":"changes","id":${JSON.stringify(this.subscriptionId)},"events":[${this.parts.join(",")}]}`;
1530
+ this.parts = [];
1531
+ this.bytes = 0;
1532
+ return this.sendText(frame);
1533
+ }
1534
+ clear() {
1535
+ this.parts = [];
1536
+ this.bytes = 0;
1537
+ }
1538
+ get pendingEvents() {
1539
+ return this.parts.length;
1540
+ }
1541
+ };
1542
+ var OUTCOME_SEVERITY = {
1543
+ queued: 0,
1544
+ sent: 1,
1545
+ buffered: 2,
1546
+ dropped: 3
1547
+ };
1548
+ function worstOutcome(a, b) {
1549
+ return OUTCOME_SEVERITY[a] >= OUTCOME_SEVERITY[b] ? a : b;
1550
+ }
1551
+
1552
+ // src/server/ws-device-stream.ts
1553
+ var DEFAULT_MAX_UNACKNOWLEDGED_CHANGES = 1e3;
1554
+ var CATCHUP_READ_BATCH = 1e3;
1555
+ var DeviceChangeStream = class {
1556
+ constructor(deps, baselineSeq, startMode) {
1557
+ this.deps = deps;
1558
+ this.mode = startMode;
1559
+ this.catchupFrom = baselineSeq;
1560
+ this.highestQueuedSeq = baselineSeq;
1561
+ this.processedSeq = baselineSeq;
1562
+ this.ackedSeq = baselineSeq;
1563
+ }
1564
+ mode;
1565
+ grouper = null;
1566
+ catchupFrom;
1567
+ highestQueuedSeq;
1568
+ processedSeq;
1569
+ heldSeq = null;
1570
+ ackedSeq;
1571
+ lastQueuedTxId;
1572
+ midTransaction = false;
1573
+ draining = false;
1574
+ socketWait = false;
1575
+ halted = false;
1576
+ get deviceId() {
1577
+ return this.deps.deviceId;
1578
+ }
1579
+ get stopped() {
1580
+ return this.halted;
1581
+ }
1582
+ get catchingUp() {
1583
+ return this.mode === "catchup";
1584
+ }
1585
+ start() {
1586
+ if (this.mode === "catchup") {
1587
+ void this.drain();
1588
+ }
1589
+ }
1590
+ receiveLive(event) {
1591
+ if (this.halted || this.mode !== "live") return;
1592
+ this.ensureGrouper().receive(event);
1593
+ }
1594
+ onBatchEnd(atTxBoundary) {
1595
+ if (this.halted || this.mode !== "live") return;
1596
+ if (this.grouper !== null && !this.grouper.flush(atTxBoundary)) return;
1597
+ this.settleFrame(this.deps.packer?.flush() ?? "queued");
1598
+ }
1599
+ onAck(seq) {
1600
+ if (this.halted) return;
1601
+ if (seq > this.ackedSeq) {
1602
+ this.ackedSeq = seq;
1603
+ }
1604
+ if (this.mode === "catchup") {
1605
+ void this.drain();
1606
+ }
1607
+ }
1608
+ onSocketDrain() {
1609
+ if (this.halted) return;
1610
+ this.socketWait = false;
1611
+ if (this.mode === "catchup") {
1612
+ void this.drain();
834
1613
  }
835
1614
  }
836
- emit(event) {
837
- if (this.mode === "stopped") return false;
838
- if (event.seq <= this.sinceSeq) return true;
839
- const outcome = this.deliver(event);
1615
+ stop() {
1616
+ this.halted = true;
1617
+ this.grouper = null;
1618
+ this.deps.packer?.clear();
1619
+ }
1620
+ ensureGrouper() {
1621
+ this.grouper ??= new TransactionGrouper((event) => this.deliver(event));
1622
+ return this.grouper;
1623
+ }
1624
+ windowClosed() {
1625
+ return this.highestQueuedSeq - this.ackedSeq > BigInt(this.deps.maxUnacknowledgedChanges);
1626
+ }
1627
+ deliver(event) {
1628
+ if (this.halted) return false;
1629
+ const startsTransaction = event.txId === void 0 || event.txId !== this.lastQueuedTxId;
1630
+ if (this.windowClosed() && (this.deps.pacing === "perEvent" || startsTransaction)) {
1631
+ this.enterCatchup();
1632
+ return false;
1633
+ }
1634
+ const outcome = this.deps.packer === null ? this.deps.sendEvent(event) : this.deps.packer.append(event);
840
1635
  if (outcome === "dropped") {
841
1636
  this.stop();
1637
+ this.deps.onOverload();
1638
+ return false;
1639
+ }
1640
+ this.lastQueuedTxId = event.txId;
1641
+ if (event.seq > this.highestQueuedSeq) {
1642
+ this.highestQueuedSeq = event.seq;
1643
+ }
1644
+ if (event.seq > this.processedSeq) {
1645
+ this.processedSeq = event.seq;
1646
+ }
1647
+ this.midTransaction = event.txEnd !== true && event.txId !== void 0;
1648
+ if (outcome === "buffered") {
1649
+ this.socketWait = true;
1650
+ this.enterCatchup();
842
1651
  return false;
843
1652
  }
844
1653
  return true;
845
1654
  }
846
- stop() {
847
- this.mode = "stopped";
848
- this.buffer = [];
849
- this.bufferBytes = 0;
1655
+ /**
1656
+ * Abandons the in-flight stream state and falls back to the change log.
1657
+ * The grouper's held event was never queued and its seq is above both
1658
+ * watermarks, so dropping it loses nothing. The catch-up read resumes
1659
+ * after the last position fully settled: `highestQueuedSeq` is the last
1660
+ * event that reached the socket, and `processedSeq` may run further ahead
1661
+ * of it over a span whose every event was suppressed, so a long run of
1662
+ * the device's own echoes is not re-read on every pause.
1663
+ */
1664
+ enterCatchup() {
1665
+ const flushed = this.deps.packer?.flush() ?? "queued";
1666
+ if (flushed === "dropped") {
1667
+ this.stop();
1668
+ this.deps.onOverload();
1669
+ return;
1670
+ }
1671
+ if (flushed === "buffered") {
1672
+ this.socketWait = true;
1673
+ }
1674
+ this.mode = "catchup";
1675
+ this.grouper = null;
1676
+ this.heldSeq = null;
1677
+ this.catchupFrom = this.processedSeq > this.highestQueuedSeq ? this.processedSeq : this.highestQueuedSeq;
850
1678
  }
851
- };
852
-
853
- // src/server/ws-connection.ts
854
- var WS_CLOSE_OVERLOADED = 4290;
855
-
856
- // src/server/ws-operations.ts
857
- function isValidParams(params) {
858
- if (params === void 0 || params === null) return true;
859
- return typeof params === "object";
860
- }
861
- function decodeBatchParams2(ctx, id, raw) {
862
- const decoded = [];
863
- for (const entry of raw) {
864
- const result = decodeBoundParams(entry, "paramsBatch");
865
- if (!result.ok) {
866
- ctx.sendError(id, "INVALID_MESSAGE", result.message);
867
- return null;
1679
+ /**
1680
+ * A closed window stops the catch-up read only where the window may pace
1681
+ * the stream: anywhere under `perEvent` pacing, and at a transaction
1682
+ * boundary under `perTransaction` pacing. Mid-transaction the read keeps
1683
+ * going, because a `perTransaction` device acknowledges only applied
1684
+ * whole transactions, and holding the rest of an open transaction back
1685
+ * would wait for an acknowledgement that can never arrive.
1686
+ */
1687
+ readGateClosed() {
1688
+ if (!this.windowClosed()) return false;
1689
+ return this.deps.pacing === "perEvent" || !this.midTransaction;
1690
+ }
1691
+ async drain() {
1692
+ if (this.draining || this.halted) return;
1693
+ this.draining = true;
1694
+ try {
1695
+ while (!this.halted && this.mode === "catchup") {
1696
+ if (this.socketWait || this.readGateClosed()) return;
1697
+ const upTo = this.deps.logCursor();
1698
+ if (this.catchupFrom >= upTo) {
1699
+ this.goLive();
1700
+ return;
1701
+ }
1702
+ let events;
1703
+ try {
1704
+ events = await this.deps.readLog(this.catchupFrom, upTo, CATCHUP_READ_BATCH);
1705
+ } catch (err) {
1706
+ this.halted = true;
1707
+ this.deps.onFault(err);
1708
+ return;
1709
+ }
1710
+ if (this.halted || this.mode !== "catchup") return;
1711
+ if (events.length === 0) {
1712
+ this.catchupFrom = upTo;
1713
+ this.goLive();
1714
+ return;
1715
+ }
1716
+ const grouper = this.ensureGrouper();
1717
+ for (const event of events) {
1718
+ this.catchupFrom = event.seq;
1719
+ const delivered = this.deps.transform(event);
1720
+ if (delivered === null) {
1721
+ if (this.heldSeq === null && event.seq > this.processedSeq) {
1722
+ this.processedSeq = event.seq;
1723
+ }
1724
+ continue;
1725
+ }
1726
+ if (!grouper.receive(delivered)) return;
1727
+ const settledBefore = event.seq - 1n;
1728
+ if (settledBefore > this.processedSeq) {
1729
+ this.processedSeq = settledBefore;
1730
+ }
1731
+ this.heldSeq = delivered.txId === void 0 ? null : event.seq;
1732
+ }
1733
+ }
1734
+ } finally {
1735
+ this.draining = false;
1736
+ }
1737
+ }
1738
+ /**
1739
+ * Rejoins the live feed. Runs synchronously right after a log read, so no
1740
+ * poller tick can dispatch between the caught-up check and the mode flip.
1741
+ * The grouper survives the transition: the event it holds is released by
1742
+ * the boundary flush when the poller stopped at a transaction boundary,
1743
+ * exactly as a resuming ordinary subscription does.
1744
+ */
1745
+ goLive() {
1746
+ this.mode = "live";
1747
+ const grouper = this.ensureGrouper();
1748
+ const atBoundary = this.deps.logCursorAtTxBoundary();
1749
+ if (!grouper.flush(atBoundary)) return;
1750
+ if (atBoundary) {
1751
+ this.heldSeq = null;
1752
+ }
1753
+ this.settleFrame(this.deps.packer?.flush() ?? "queued");
1754
+ }
1755
+ settleFrame(outcome) {
1756
+ if (this.halted) return;
1757
+ if (outcome === "dropped") {
1758
+ this.stop();
1759
+ this.deps.onOverload();
1760
+ return;
1761
+ }
1762
+ if (outcome === "buffered") {
1763
+ this.socketWait = true;
1764
+ if (this.mode === "live") {
1765
+ this.enterCatchup();
1766
+ }
1767
+ }
1768
+ }
1769
+ };
1770
+
1771
+ // src/server/ws-operations.ts
1772
+ function isValidParams(params) {
1773
+ if (params === void 0 || params === null) return true;
1774
+ return typeof params === "object";
1775
+ }
1776
+ function decodeBatchParams2(ctx, id, raw) {
1777
+ const decoded = [];
1778
+ for (const entry of raw) {
1779
+ const result = decodeBoundParams(entry, "paramsBatch");
1780
+ if (!result.ok) {
1781
+ ctx.sendError(id, "INVALID_MESSAGE", result.message);
1782
+ return null;
1783
+ }
1784
+ decoded.push(result.value);
1785
+ }
1786
+ return decoded;
1787
+ }
1788
+ async function handleQueryMessage(ctx, msg, id) {
1789
+ if (typeof msg.sql !== "string") {
1790
+ ctx.sendError(id, "INVALID_MESSAGE", 'Query message requires a "sql" string field');
1791
+ return;
1792
+ }
1793
+ if (!isValidParams(msg.params)) {
1794
+ ctx.sendError(id, "INVALID_MESSAGE", '"params" must be an object or array');
1795
+ return;
1796
+ }
1797
+ const params = decodeBoundParams(msg.params, "params");
1798
+ if (!params.ok) {
1799
+ ctx.sendError(id, "INVALID_MESSAGE", params.message);
1800
+ return;
1801
+ }
1802
+ const readConcern = validateReadConcern(msg.readConcern);
1803
+ if (!readConcern.ok) {
1804
+ ctx.sendError(id, "INVALID_MESSAGE", readConcern.message);
1805
+ return;
1806
+ }
1807
+ try {
1808
+ const rows = await queryWireRows(ctx.target, msg.sql, params.value, toReadOptions(readConcern.value));
1809
+ ctx.sendResult(id, { rows });
1810
+ } catch (err) {
1811
+ ctx.sendCaughtError(id, err);
1812
+ }
1813
+ }
1814
+ function toReadOptions(readConcern) {
1815
+ return readConcern ? { readConcern } : void 0;
1816
+ }
1817
+ async function handleExecuteMessage(ctx, msg, id) {
1818
+ if (typeof msg.sql !== "string") {
1819
+ ctx.sendError(id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
1820
+ return;
1821
+ }
1822
+ if (!isValidParams(msg.params)) {
1823
+ ctx.sendError(id, "INVALID_MESSAGE", '"params" must be an object or array');
1824
+ return;
1825
+ }
1826
+ const params = decodeBoundParams(msg.params, "params");
1827
+ if (!params.ok) {
1828
+ ctx.sendError(id, "INVALID_MESSAGE", params.message);
1829
+ return;
1830
+ }
1831
+ try {
1832
+ const result = await ctx.target.execute(msg.sql, params.value);
1833
+ ctx.sendResult(id, toExecuteResponse(result));
1834
+ } catch (err) {
1835
+ ctx.sendCaughtError(id, err);
1836
+ }
1837
+ }
1838
+ async function handleTransactionMessage(ctx, msg, id) {
1839
+ const statementsError = transactionStatementsValidationError(msg.statements);
1840
+ if (statementsError !== null) {
1841
+ ctx.sendError(id, "INVALID_MESSAGE", statementsError);
1842
+ return;
1843
+ }
1844
+ const writeConcern = validateWriteConcern(msg.writeConcern);
1845
+ if (!writeConcern.ok) {
1846
+ ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
1847
+ return;
1848
+ }
1849
+ const validStatements = msg.statements;
1850
+ const statements = [];
1851
+ for (const stmt of validStatements) {
1852
+ const params = decodeBoundParams(stmt.params, "params");
1853
+ if (!params.ok) {
1854
+ ctx.sendError(id, "INVALID_MESSAGE", params.message);
1855
+ return;
1856
+ }
1857
+ statements.push({ sql: stmt.sql, params: params.value });
1858
+ }
1859
+ const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
1860
+ try {
1861
+ const results = ctx.target.executeTransaction ? await ctx.target.executeTransaction(statements, txOptions) : await ctx.target.transaction(async (tx) => {
1862
+ const txResults = [];
1863
+ for (const stmt of statements) {
1864
+ txResults.push(await tx.execute(stmt.sql, stmt.params));
1865
+ }
1866
+ return txResults;
1867
+ }, txOptions);
1868
+ ctx.sendResult(id, { results: results.map(toExecuteResponse) });
1869
+ } catch (err) {
1870
+ ctx.sendCaughtError(id, err);
1871
+ }
1872
+ }
1873
+ async function handleBatchMessage(ctx, msg, id) {
1874
+ if (typeof msg.sql !== "string") {
1875
+ ctx.sendError(id, "INVALID_MESSAGE", 'Batch message requires a "sql" string field');
1876
+ return;
1877
+ }
1878
+ const paramsBatchError = paramsBatchValidationError(msg.paramsBatch);
1879
+ if (paramsBatchError !== null) {
1880
+ ctx.sendError(id, "INVALID_MESSAGE", paramsBatchError);
1881
+ return;
1882
+ }
1883
+ const writeConcern = validateWriteConcern(msg.writeConcern);
1884
+ if (!writeConcern.ok) {
1885
+ ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
1886
+ return;
1887
+ }
1888
+ const sql = msg.sql;
1889
+ const paramsBatch = decodeBatchParams2(ctx, id, msg.paramsBatch);
1890
+ if (paramsBatch === null) return;
1891
+ try {
1892
+ const results = await ctx.target.transaction(
1893
+ async (tx) => tx.executeBatch(sql, paramsBatch),
1894
+ writeConcern.value ? { writeConcern: writeConcern.value } : void 0
1895
+ );
1896
+ ctx.sendResult(id, { results: results.map(toExecuteResponse) });
1897
+ } catch (err) {
1898
+ ctx.sendCaughtError(id, err);
1899
+ }
1900
+ }
1901
+ async function handleLoadMessage(ctx, msg, id) {
1902
+ if (typeof msg.sql !== "string") {
1903
+ ctx.sendError(id, "INVALID_MESSAGE", 'Load message requires a "sql" string field');
1904
+ return;
1905
+ }
1906
+ const paramsBatchError = paramsBatchValidationError(msg.paramsBatch);
1907
+ if (paramsBatchError !== null) {
1908
+ ctx.sendError(id, "INVALID_MESSAGE", paramsBatchError);
1909
+ return;
1910
+ }
1911
+ const durabilityError = loadDurabilityValidationError(msg.durability);
1912
+ if (durabilityError !== null) {
1913
+ ctx.sendError(id, "INVALID_MESSAGE", durabilityError);
1914
+ return;
1915
+ }
1916
+ const checkpointError = loadCheckpointValidationError(msg.checkpoint);
1917
+ if (checkpointError !== null) {
1918
+ ctx.sendError(id, "INVALID_MESSAGE", checkpointError);
1919
+ return;
1920
+ }
1921
+ const bulkLoad = ctx.target.bulkLoad;
1922
+ if (typeof bulkLoad !== "function") {
1923
+ ctx.sendError(id, "BULK_LOAD_UNSUPPORTED", "The execution target for this database does not support bulk load");
1924
+ return;
1925
+ }
1926
+ const sql = msg.sql;
1927
+ const paramsBatch = decodeBatchParams2(ctx, id, msg.paramsBatch);
1928
+ if (paramsBatch === null) return;
1929
+ const options = toBulkLoadOptions({
1930
+ durability: msg.durability,
1931
+ checkpoint: msg.checkpoint
1932
+ });
1933
+ try {
1934
+ const summary = await bulkLoad.call(ctx.target, sql, paramsBatch, options);
1935
+ ctx.sendResult(id, summary);
1936
+ } catch (err) {
1937
+ ctx.sendCaughtError(id, err);
1938
+ }
1939
+ }
1940
+
1941
+ // src/server/ws-named.ts
1942
+ function readArguments(raw) {
1943
+ if (raw === void 0) return { ok: true, value: {} };
1944
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1945
+ return { ok: false, message: '"args" must be an object' };
1946
+ }
1947
+ const decoded = decodeBoundParams(raw, "args");
1948
+ if (!decoded.ok) return { ok: false, message: decoded.message };
1949
+ return { ok: true, value: decoded.value ?? {} };
1950
+ }
1951
+ async function handleNamedQueryMessage(ctx, msg, id, name) {
1952
+ const args = readArguments(msg.args);
1953
+ if (!args.ok) {
1954
+ ctx.sendError(id, "INVALID_MESSAGE", args.message);
1955
+ return;
1956
+ }
1957
+ const readConcern = validateReadConcern(msg.readConcern);
1958
+ if (!readConcern.ok) {
1959
+ ctx.sendError(id, "INVALID_MESSAGE", readConcern.message);
1960
+ return;
1961
+ }
1962
+ try {
1963
+ const resolved = ctx.operations.resolve("read", ctx.databaseId, name, args.value, ctx.identity);
1964
+ if (!resolved.ok) {
1965
+ ctx.sendError(id, resolved.refusal.code, resolved.refusal.message);
1966
+ return;
1967
+ }
1968
+ const statement = resolved.statements[0];
1969
+ const rows = await queryWireRows(ctx.target, statement.sql, statement.params, toReadOptions(readConcern.value));
1970
+ ctx.sendResult(id, { rows });
1971
+ } catch (err) {
1972
+ ctx.sendCaughtError(id, err);
1973
+ }
1974
+ }
1975
+ async function handleNamedExecuteMessage(ctx, msg, id, name) {
1976
+ const args = readArguments(msg.args);
1977
+ if (!args.ok) {
1978
+ ctx.sendError(id, "INVALID_MESSAGE", args.message);
1979
+ return;
1980
+ }
1981
+ const writeConcern = validateWriteConcern(msg.writeConcern);
1982
+ if (!writeConcern.ok) {
1983
+ ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
1984
+ return;
1985
+ }
1986
+ try {
1987
+ const resolved = ctx.operations.resolve("write", ctx.databaseId, name, args.value, ctx.identity);
1988
+ if (!resolved.ok) {
1989
+ ctx.sendError(id, resolved.refusal.code, resolved.refusal.message);
1990
+ return;
1991
+ }
1992
+ const statements = resolved.statements;
1993
+ const options = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
1994
+ const results = ctx.target.executeTransaction ? await ctx.target.executeTransaction(statements, options) : await ctx.target.transaction(async (tx) => {
1995
+ const collected = [];
1996
+ for (const statement of statements) {
1997
+ collected.push(await tx.execute(statement.sql, statement.params));
1998
+ }
1999
+ return collected;
2000
+ }, options);
2001
+ ctx.sendResult(id, { results: results.map(toExecuteResponse) });
2002
+ } catch (err) {
2003
+ ctx.sendCaughtError(id, err);
2004
+ }
2005
+ }
2006
+
2007
+ // src/server/ws-live.ts
2008
+ var LIVE_ONLY_FIELDS = ["table", "tables", "filter", "sinceSeq", "epoch", "deviceId", "schemaVersion"];
2009
+ async function handleLiveSubscribeMessage(deps, conn, state, msg, id, name) {
2010
+ const rejected = LIVE_ONLY_FIELDS.find((field) => msg[field] !== void 0);
2011
+ if (rejected !== void 0) {
2012
+ deps.sendError(
2013
+ conn,
2014
+ id,
2015
+ "INVALID_MESSAGE",
2016
+ `A subscription naming a registered read carries no "${rejected}"; the server holds the result`
2017
+ );
2018
+ return;
2019
+ }
2020
+ if (state.subscriptions.has(id)) {
2021
+ deps.sendError(conn, id, "DUPLICATE_SUBSCRIPTION", `Subscription '${id}' already exists on this connection`);
2022
+ return;
2023
+ }
2024
+ if (state.database.readOnly) {
2025
+ deps.sendError(conn, id, "READ_ONLY", "Live queries are not available on read-only databases");
2026
+ return;
2027
+ }
2028
+ if (state.database.path === ":memory:") {
2029
+ deps.sendError(conn, id, "CDC_UNSUPPORTED", "Live queries require file-based databases");
2030
+ return;
2031
+ }
2032
+ if (msg.registryDigest !== void 0) {
2033
+ if (typeof msg.registryDigest !== "string") {
2034
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"registryDigest" must be a string');
2035
+ return;
2036
+ }
2037
+ if (msg.registryDigest !== deps.operations.digest) {
2038
+ deps.sendError(
2039
+ conn,
2040
+ id,
2041
+ "REGISTRY_MISMATCH",
2042
+ "This server runs a different operation registry than the one the client generated against; re-read GET /capabilities"
2043
+ );
2044
+ return;
2045
+ }
2046
+ }
2047
+ const args = readArguments(msg.args);
2048
+ if (!args.ok) {
2049
+ deps.sendError(conn, id, "INVALID_MESSAGE", args.message);
2050
+ return;
2051
+ }
2052
+ let sql;
2053
+ let params;
2054
+ try {
2055
+ const resolved = deps.operations.resolve("read", state.databaseId, name, args.value, state.identity);
2056
+ if (!resolved.ok) {
2057
+ deps.sendError(conn, id, resolved.refusal.code, resolved.refusal.message);
2058
+ return;
2059
+ }
2060
+ sql = resolved.statements[0].sql;
2061
+ params = resolved.statements[0].params;
2062
+ } catch (err) {
2063
+ deps.sendSirannonError(conn, id, err);
2064
+ return;
2065
+ }
2066
+ let cancelled = false;
2067
+ state.subscriptions.set(id, {
2068
+ unsubscribe: () => {
2069
+ cancelled = true;
2070
+ }
2071
+ });
2072
+ let query;
2073
+ try {
2074
+ query = await state.database.live(sql, params);
2075
+ } catch (err) {
2076
+ state.subscriptions.delete(id);
2077
+ if (!cancelled) deps.sendSirannonError(conn, id, err);
2078
+ return;
2079
+ }
2080
+ if (cancelled) {
2081
+ await query.close().catch(() => {
2082
+ });
2083
+ return;
2084
+ }
2085
+ let stop = null;
2086
+ const teardown = () => {
2087
+ state.subscriptions.delete(id);
2088
+ stop?.();
2089
+ stop = null;
2090
+ query.close().catch(() => {
2091
+ });
2092
+ };
2093
+ stop = query.subscribe((update) => {
2094
+ deliver(deps, conn, id, query, update, teardown);
2095
+ });
2096
+ state.subscriptions.set(id, { unsubscribe: teardown });
2097
+ const opened = query.getState();
2098
+ if (opened.status !== "ready") {
2099
+ teardown();
2100
+ deps.sendError(conn, id, "INTERNAL_ERROR", "The live query produced no first result");
2101
+ return;
2102
+ }
2103
+ deps.sendSubscribedRows(conn, id, encodeRows(opened.rows));
2104
+ }
2105
+ function deliver(deps, conn, id, query, update, teardown) {
2106
+ if (update.kind === "ops") {
2107
+ deps.sendLive(conn, { type: "live", id, ops: update.ops.map(encodeOp) });
2108
+ return;
2109
+ }
2110
+ if (update.kind === "revalidating") {
2111
+ deps.sendLive(conn, { type: "live", id, revalidating: true });
2112
+ return;
2113
+ }
2114
+ const state = query.getState();
2115
+ if (state.status === "ready") {
2116
+ deps.sendLive(conn, { type: "live", id, rows: encodeRows(state.rows) });
2117
+ return;
2118
+ }
2119
+ if (state.status === "error") {
2120
+ teardown();
2121
+ deps.sendError(conn, id, "CDC_ERROR", state.error.message);
2122
+ }
2123
+ }
2124
+ function encodeRows(rows) {
2125
+ return rows.map((row) => encodeTaggedValues(row));
2126
+ }
2127
+ function encodeOp(op) {
2128
+ if (op.op === "delete") return { op: "delete", index: op.index };
2129
+ return {
2130
+ op: op.op === "insert" ? "insert" : "update",
2131
+ index: op.index,
2132
+ row: encodeTaggedValues(op.row)
2133
+ };
2134
+ }
2135
+
2136
+ // src/server/ws-cdc-resume.ts
2137
+ var PrimedSubscription2 = class extends PrimedSubscription {
2138
+ constructor(sinceSeq, deliver2, onOverload, bufferLimit, bufferByteLimit) {
2139
+ super(sinceSeq, (event) => deliver2(event) !== "dropped", onOverload, bufferLimit, bufferByteLimit);
2140
+ }
2141
+ };
2142
+
2143
+ // src/server/ws-device-subscribe.ts
2144
+ async function subscribeDevice(deps, conn, state, request) {
2145
+ const { id, tables, filter, sinceSeq, clientEpoch, deviceId, stagedStream } = request;
2146
+ let stream;
2147
+ let boundary;
2148
+ let resync;
2149
+ let epoch;
2150
+ try {
2151
+ const ctx = await deps.cdc.ensure(state.databaseId, state.database);
2152
+ for (const table of tables) {
2153
+ await ctx.tracker.watch(ctx.cdcConn, table);
2154
+ }
2155
+ await state.database.ensureChangeStamping();
2156
+ epoch = ctx.epoch;
2157
+ boundary = ctx.tracker.cursor;
2158
+ if (sinceSeq === void 0) {
2159
+ resync = false;
2160
+ } else {
2161
+ const minSeq = await ctx.tracker.getMinSeq(ctx.cdcConn);
2162
+ const foreignEpoch = clientEpoch !== void 0 && clientEpoch !== ctx.epoch;
2163
+ resync = foreignEpoch || needsResync(sinceSeq, minSeq, boundary);
868
2164
  }
869
- decoded.push(result.value);
2165
+ const suppress = (event) => event.origin === deviceId || event.origin === void 0;
2166
+ const resumeFrom = resync || sinceSeq === void 0 ? boundary : sinceSeq;
2167
+ stream = new DeviceChangeStream(
2168
+ {
2169
+ deviceId,
2170
+ maxUnacknowledgedChanges: deps.maxUnacknowledgedChanges,
2171
+ pacing: stagedStream ? "perEvent" : "perTransaction",
2172
+ packer: stagedStream ? new DeviceFramePacker(id, (data) => deps.sendText(conn, data)) : null,
2173
+ sendEvent: (event) => deps.sendChange(conn, id, event),
2174
+ onOverload: () => deps.handleOverload(conn),
2175
+ onFault: () => deps.closeFaulted(conn),
2176
+ readLog: (afterSeq, upToSeq, limit) => ctx.tracker.readSinceTables(ctx.cdcConn, tables, afterSeq, upToSeq, limit),
2177
+ logCursor: () => ctx.tracker.cursor,
2178
+ logCursorAtTxBoundary: () => ctx.tracker.pollEndedAtTxBoundary,
2179
+ transform: (event) => {
2180
+ if (suppress(event)) return null;
2181
+ return filter === void 0 ? event : filteredChange(event, filter);
2182
+ }
2183
+ },
2184
+ resumeFrom,
2185
+ resumeFrom < boundary ? "catchup" : "live"
2186
+ );
2187
+ const subscriptions = tables.map(
2188
+ (table) => ctx.manager.subscribe(table, filter, (event) => {
2189
+ if (suppress(event)) return;
2190
+ stream.receiveLive(event);
2191
+ })
2192
+ );
2193
+ const removeBatchEnd = ctx.manager.addBatchEndListener((atTxBoundary) => stream.onBatchEnd(atTxBoundary));
2194
+ state.subscriptions.set(id, {
2195
+ unsubscribe: () => {
2196
+ removeBatchEnd();
2197
+ stream.stop();
2198
+ state.deviceStreams.delete(id);
2199
+ for (const subscription of subscriptions) {
2200
+ subscription.unsubscribe();
2201
+ }
2202
+ }
2203
+ });
2204
+ state.deviceStreams.set(id, stream);
2205
+ } catch (err) {
2206
+ deps.cdc.maybeCleanup(state.databaseId);
2207
+ deps.sendSirannonError(conn, id, err);
2208
+ return;
870
2209
  }
871
- return decoded;
2210
+ deps.sendSubscribed(conn, id, boundary.toString(), epoch, resync, deps.maxUnacknowledgedChanges);
2211
+ stream.start();
872
2212
  }
873
- async function handleQueryMessage(ctx, msg, id) {
874
- if (typeof msg.sql !== "string") {
875
- ctx.sendError(id, "INVALID_MESSAGE", 'Query message requires a "sql" string field');
2213
+
2214
+ // src/server/ws-subscribe.ts
2215
+ var MAX_SUBSCRIBED_TABLES = 500;
2216
+ async function handleSubscribeMessage(deps, conn, state, msg, id) {
2217
+ const tables = readTableSet(msg);
2218
+ if (typeof tables === "string") {
2219
+ deps.sendError(conn, id, "INVALID_MESSAGE", tables);
876
2220
  return;
877
2221
  }
878
- if (!isValidParams(msg.params)) {
879
- ctx.sendError(id, "INVALID_MESSAGE", '"params" must be an object or array');
2222
+ if (state.subscriptions.has(id)) {
2223
+ deps.sendError(conn, id, "DUPLICATE_SUBSCRIPTION", `Subscription '${id}' already exists on this connection`);
880
2224
  return;
881
2225
  }
882
- const params = decodeBoundParams(msg.params, "params");
883
- if (!params.ok) {
884
- ctx.sendError(id, "INVALID_MESSAGE", params.message);
2226
+ if (state.database.readOnly) {
2227
+ deps.sendError(conn, id, "READ_ONLY", "Subscriptions are not available on read-only databases");
885
2228
  return;
886
2229
  }
887
- try {
888
- const rows = await queryWireRows(ctx.target, msg.sql, params.value);
889
- ctx.sendResult(id, { rows });
890
- } catch (err) {
891
- ctx.sendCaughtError(id, err);
892
- }
893
- }
894
- async function handleExecuteMessage(ctx, msg, id) {
895
- if (typeof msg.sql !== "string") {
896
- ctx.sendError(id, "INVALID_MESSAGE", 'Execute message requires a "sql" string field');
2230
+ if (state.database.path === ":memory:") {
2231
+ deps.sendError(conn, id, "CDC_UNSUPPORTED", "CDC subscriptions require file-based databases");
897
2232
  return;
898
2233
  }
899
- if (!isValidParams(msg.params)) {
900
- ctx.sendError(id, "INVALID_MESSAGE", '"params" must be an object or array');
2234
+ if (msg.filter !== void 0 && msg.filter !== null && (typeof msg.filter !== "object" || Array.isArray(msg.filter))) {
2235
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"filter" must be a plain object');
901
2236
  return;
902
2237
  }
903
- const params = decodeBoundParams(msg.params, "params");
904
- if (!params.ok) {
905
- ctx.sendError(id, "INVALID_MESSAGE", params.message);
2238
+ const decodedFilter = decodeBoundParams(msg.filter, "filter");
2239
+ if (!decodedFilter.ok) {
2240
+ deps.sendError(conn, id, "INVALID_MESSAGE", decodedFilter.message);
906
2241
  return;
907
2242
  }
908
- try {
909
- const result = await ctx.target.execute(msg.sql, params.value);
910
- ctx.sendResult(id, toExecuteResponse(result));
911
- } catch (err) {
912
- ctx.sendCaughtError(id, err);
913
- }
914
- }
915
- async function handleTransactionMessage(ctx, msg, id) {
916
- const statementsError = transactionStatementsValidationError(msg.statements);
917
- if (statementsError !== null) {
918
- ctx.sendError(id, "INVALID_MESSAGE", statementsError);
919
- return;
2243
+ const filter = decodedFilter.value;
2244
+ let sinceSeq;
2245
+ if (msg.sinceSeq !== void 0) {
2246
+ if (typeof msg.sinceSeq !== "string" || !/^\d+$/.test(msg.sinceSeq)) {
2247
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"sinceSeq" must be a non-negative integer string');
2248
+ return;
2249
+ }
2250
+ sinceSeq = BigInt(msg.sinceSeq);
920
2251
  }
921
- const writeConcern = validateWriteConcern(msg.writeConcern);
922
- if (!writeConcern.ok) {
923
- ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
924
- return;
2252
+ let clientEpoch;
2253
+ if (msg.epoch !== void 0) {
2254
+ if (typeof msg.epoch !== "string") {
2255
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"epoch" must be a string');
2256
+ return;
2257
+ }
2258
+ clientEpoch = msg.epoch;
925
2259
  }
926
- const validStatements = msg.statements;
927
- const statements = [];
928
- for (const stmt of validStatements) {
929
- const params = decodeBoundParams(stmt.params, "params");
930
- if (!params.ok) {
931
- ctx.sendError(id, "INVALID_MESSAGE", params.message);
2260
+ let deviceId;
2261
+ if (msg.deviceId !== void 0) {
2262
+ if (!isValidDeviceId(msg.deviceId)) {
2263
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"deviceId" must be a 32-hex device id');
932
2264
  return;
933
2265
  }
934
- statements.push({ sql: stmt.sql, params: params.value });
2266
+ deviceId = msg.deviceId;
935
2267
  }
936
- const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
937
- try {
938
- const results = ctx.target.executeTransaction ? await ctx.target.executeTransaction(statements, txOptions) : await ctx.target.transaction(async (tx) => {
939
- const txResults = [];
940
- for (const stmt of statements) {
941
- txResults.push(await tx.execute(stmt.sql, stmt.params));
942
- }
943
- return txResults;
944
- }, txOptions);
945
- ctx.sendResult(id, { results: results.map(toExecuteResponse) });
946
- } catch (err) {
947
- ctx.sendCaughtError(id, err);
2268
+ if (deviceId !== void 0) {
2269
+ if (msg.schemaVersion !== void 0 && !isValidSchemaVersion(msg.schemaVersion)) {
2270
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"schemaVersion" must be a non-negative integer');
2271
+ return;
2272
+ }
2273
+ let refusal;
2274
+ try {
2275
+ const serverVersion = highestMigrationVersion(await state.database.appliedMigrations());
2276
+ refusal = schemaVersionGateRefusal(msg.schemaVersion ?? 0, serverVersion);
2277
+ } catch (err) {
2278
+ deps.sendSirannonError(conn, id, err);
2279
+ return;
2280
+ }
2281
+ if (refusal !== null) {
2282
+ deps.sendError(conn, id, refusal.code, refusal.message);
2283
+ return;
2284
+ }
948
2285
  }
949
- }
950
- async function handleBatchMessage(ctx, msg, id) {
951
- if (typeof msg.sql !== "string") {
952
- ctx.sendError(id, "INVALID_MESSAGE", 'Batch message requires a "sql" string field');
2286
+ if (msg.stagedStream !== void 0 && typeof msg.stagedStream !== "boolean") {
2287
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"stagedStream" must be a boolean');
953
2288
  return;
954
2289
  }
955
- const paramsBatchError = paramsBatchValidationError(msg.paramsBatch);
956
- if (paramsBatchError !== null) {
957
- ctx.sendError(id, "INVALID_MESSAGE", paramsBatchError);
2290
+ if (deviceId !== void 0) {
2291
+ await subscribeDevice(deps, conn, state, {
2292
+ id,
2293
+ tables,
2294
+ filter,
2295
+ sinceSeq,
2296
+ clientEpoch,
2297
+ deviceId,
2298
+ stagedStream: msg.stagedStream === true
2299
+ });
958
2300
  return;
959
2301
  }
960
- const writeConcern = validateWriteConcern(msg.writeConcern);
961
- if (!writeConcern.ok) {
962
- ctx.sendError(id, "INVALID_MESSAGE", writeConcern.message);
2302
+ if (msg.stagedStream !== void 0) {
2303
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"stagedStream" requires a "deviceId"');
963
2304
  return;
964
2305
  }
965
- const sql = msg.sql;
966
- const paramsBatch = decodeBatchParams2(ctx, id, msg.paramsBatch);
967
- if (paramsBatch === null) return;
968
- try {
969
- const results = await ctx.target.transaction(
970
- async (tx) => tx.executeBatch(sql, paramsBatch),
971
- writeConcern.value ? { writeConcern: writeConcern.value } : void 0
972
- );
973
- ctx.sendResult(id, { results: results.map(toExecuteResponse) });
974
- } catch (err) {
975
- ctx.sendCaughtError(id, err);
976
- }
977
- }
978
- async function handleLoadMessage(ctx, msg, id) {
979
- if (typeof msg.sql !== "string") {
980
- ctx.sendError(id, "INVALID_MESSAGE", 'Load message requires a "sql" string field');
2306
+ if (tables.length > 1) {
2307
+ deps.sendError(conn, id, "INVALID_MESSAGE", '"tables" requires a "deviceId"');
981
2308
  return;
982
2309
  }
983
- const paramsBatchError = paramsBatchValidationError(msg.paramsBatch);
984
- if (paramsBatchError !== null) {
985
- ctx.sendError(id, "INVALID_MESSAGE", paramsBatchError);
2310
+ const table = tables[0];
2311
+ if (sinceSeq === void 0) {
2312
+ await subscribeLive(deps, conn, state, id, table, filter);
986
2313
  return;
987
2314
  }
988
- const durabilityError = loadDurabilityValidationError(msg.durability);
989
- if (durabilityError !== null) {
990
- ctx.sendError(id, "INVALID_MESSAGE", durabilityError);
991
- return;
2315
+ await subscribeResuming(deps, conn, state, id, table, filter, sinceSeq, clientEpoch);
2316
+ }
2317
+ function readTableSet(msg) {
2318
+ if (msg.tables !== void 0) {
2319
+ if (!Array.isArray(msg.tables) || msg.tables.length === 0) {
2320
+ return '"tables" must be a non-empty array of table names';
2321
+ }
2322
+ if (msg.tables.length > MAX_SUBSCRIBED_TABLES) {
2323
+ return `"tables" must hold at most ${MAX_SUBSCRIBED_TABLES} table names`;
2324
+ }
2325
+ for (const table of msg.tables) {
2326
+ if (typeof table !== "string" || table.length === 0) {
2327
+ return '"tables" must hold non-empty table names';
2328
+ }
2329
+ }
2330
+ return msg.tables;
992
2331
  }
993
- const checkpointError = loadCheckpointValidationError(msg.checkpoint);
994
- if (checkpointError !== null) {
995
- ctx.sendError(id, "INVALID_MESSAGE", checkpointError);
996
- return;
2332
+ if (typeof msg.table !== "string") {
2333
+ return 'Subscribe message requires a "table" string field';
997
2334
  }
998
- const bulkLoad = ctx.target.bulkLoad;
999
- if (typeof bulkLoad !== "function") {
1000
- ctx.sendError(id, "BULK_LOAD_UNSUPPORTED", "The execution target for this database does not support bulk load");
1001
- return;
2335
+ return [msg.table];
2336
+ }
2337
+ async function subscribeLive(deps, conn, state, id, table, filter) {
2338
+ try {
2339
+ const ctx = await deps.cdc.ensure(state.databaseId, state.database);
2340
+ await ctx.tracker.watch(ctx.cdcConn, table);
2341
+ await state.database.ensureChangeStamping();
2342
+ const grouper = new TransactionGrouper((event) => deps.sendChange(conn, id, event) !== "dropped");
2343
+ const boundary = ctx.tracker.cursor;
2344
+ const sub = ctx.manager.subscribe(table, filter, (event) => {
2345
+ grouper.receive(event);
2346
+ });
2347
+ const removeBatchEnd = ctx.manager.addBatchEndListener((atTxBoundary) => {
2348
+ grouper.flush(atTxBoundary);
2349
+ });
2350
+ state.subscriptions.set(id, {
2351
+ unsubscribe: () => {
2352
+ removeBatchEnd();
2353
+ sub.unsubscribe();
2354
+ }
2355
+ });
2356
+ deps.sendSubscribed(conn, id, boundary.toString(), ctx.epoch, false);
2357
+ } catch (err) {
2358
+ deps.cdc.maybeCleanup(state.databaseId);
2359
+ deps.sendSirannonError(conn, id, err);
1002
2360
  }
1003
- const sql = msg.sql;
1004
- const paramsBatch = decodeBatchParams2(ctx, id, msg.paramsBatch);
1005
- if (paramsBatch === null) return;
1006
- const options = toBulkLoadOptions({
1007
- durability: msg.durability,
1008
- checkpoint: msg.checkpoint
1009
- });
2361
+ }
2362
+ async function subscribeResuming(deps, conn, state, id, table, filter, sinceSeq, clientEpoch) {
2363
+ let ctx;
2364
+ let primed;
2365
+ let boundary;
2366
+ let resync;
2367
+ let goLive;
1010
2368
  try {
1011
- const summary = await bulkLoad.call(ctx.target, sql, paramsBatch, options);
1012
- ctx.sendResult(id, summary);
2369
+ ctx = await deps.cdc.ensure(state.databaseId, state.database);
2370
+ await ctx.tracker.watch(ctx.cdcConn, table);
2371
+ await state.database.ensureChangeStamping();
2372
+ const grouper = new TransactionGrouper((event) => deps.sendChange(conn, id, event) !== "dropped");
2373
+ const deliver2 = (event) => grouper.receive(event) ? "sent" : "dropped";
2374
+ boundary = ctx.tracker.cursor;
2375
+ const boundaryEndsTransaction = ctx.tracker.pollEndedAtTxBoundary;
2376
+ primed = new PrimedSubscription2(sinceSeq, deliver2, () => deps.handleOverload(conn));
2377
+ const sub = ctx.manager.subscribe(table, filter, (event) => primed.onLiveEvent(event));
2378
+ let removeBatchEnd = () => {
2379
+ };
2380
+ let cancelled = false;
2381
+ goLive = () => {
2382
+ if (cancelled) return;
2383
+ grouper.flush(boundaryEndsTransaction);
2384
+ primed.goLive();
2385
+ removeBatchEnd = ctx.manager.addBatchEndListener((atTxBoundary) => {
2386
+ grouper.flush(atTxBoundary);
2387
+ });
2388
+ };
2389
+ state.subscriptions.set(id, {
2390
+ unsubscribe: () => {
2391
+ cancelled = true;
2392
+ removeBatchEnd();
2393
+ sub.unsubscribe();
2394
+ }
2395
+ });
2396
+ const minSeq = await ctx.tracker.getMinSeq(ctx.cdcConn);
2397
+ const foreignEpoch = clientEpoch !== void 0 && clientEpoch !== ctx.epoch;
2398
+ resync = foreignEpoch || needsResync(sinceSeq, minSeq, boundary);
2399
+ deps.sendSubscribed(conn, id, boundary.toString(), ctx.epoch, resync);
1013
2400
  } catch (err) {
1014
- ctx.sendCaughtError(id, err);
2401
+ deps.cdc.maybeCleanup(state.databaseId);
2402
+ deps.sendSirannonError(conn, id, err);
2403
+ return;
2404
+ }
2405
+ if (!resync) {
2406
+ try {
2407
+ await primed.replay(ctx.tracker, ctx.cdcConn, table, filter, boundary);
2408
+ } catch {
2409
+ deps.sendSubscribed(conn, id, boundary.toString(), ctx.epoch, true);
2410
+ }
1015
2411
  }
2412
+ goLive();
1016
2413
  }
1017
2414
 
1018
2415
  // src/server/ws-handler.ts
1019
2416
  var DEFAULT_MAX_PAYLOAD_LENGTH = 1048576;
2417
+ var SQL_MESSAGE_TYPES = /* @__PURE__ */ new Set(["query", "execute", "transaction", "batch", "load"]);
2418
+ var NAMED_MESSAGE_TYPES = /* @__PURE__ */ new Set(["query", "execute", "subscribe"]);
1020
2419
  var WSHandler = class {
1021
2420
  sirannon;
1022
2421
  maxPayloadLength;
1023
2422
  resolveExecutionTarget;
1024
2423
  connections = /* @__PURE__ */ new Map();
1025
2424
  cdc;
2425
+ maxUnacknowledgedChanges;
2426
+ acceptSql;
2427
+ operations;
1026
2428
  closed = false;
1027
2429
  constructor(sirannon, options) {
1028
2430
  this.sirannon = sirannon;
2431
+ this.acceptSql = options?.acceptSql === true;
2432
+ this.operations = createOperationSource(options?.operations);
1029
2433
  this.maxPayloadLength = options?.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
2434
+ this.maxUnacknowledgedChanges = options?.maxUnacknowledgedChanges ?? DEFAULT_MAX_UNACKNOWLEDGED_CHANGES;
1030
2435
  this.resolveExecutionTarget = options?.resolveExecutionTarget;
1031
- this.cdc = new CdcContextRegistry(sirannon, options?.cdcRetentionMs);
2436
+ this.cdc = new CdcContextRegistry(sirannon, options?.cdcRetentionMs, options?.deviceCursorRetentionMs);
1032
2437
  }
1033
- async handleOpen(conn, databaseId) {
2438
+ async handleOpen(conn, databaseId, identity) {
1034
2439
  if (this.closed) {
1035
2440
  this.sendError(conn, "", "HANDLER_CLOSED", "WebSocket handler is shut down");
1036
2441
  conn.close(1013, "Handler shutting down");
@@ -1064,7 +2469,9 @@ var WSHandler = class {
1064
2469
  databaseId,
1065
2470
  database,
1066
2471
  executionTarget,
2472
+ identity,
1067
2473
  subscriptions: /* @__PURE__ */ new Map(),
2474
+ deviceStreams: /* @__PURE__ */ new Map(),
1068
2475
  overloaded: false
1069
2476
  });
1070
2477
  }
@@ -1107,12 +2514,23 @@ var WSHandler = class {
1107
2514
  return;
1108
2515
  }
1109
2516
  const id = msg.id;
2517
+ const name = typeof msg.name === "string" ? msg.name : null;
2518
+ if (name !== null && !NAMED_MESSAGE_TYPES.has(msg.type)) {
2519
+ this.sendError(conn, id, "INVALID_MESSAGE", `A "${msg.type}" message names no registered operation`);
2520
+ return;
2521
+ }
2522
+ if (!this.acceptSql && SQL_MESSAGE_TYPES.has(msg.type) && name === null) {
2523
+ this.sendError(conn, id, "SQL_NOT_ACCEPTED", SQL_NOT_ACCEPTED_MESSAGE);
2524
+ return;
2525
+ }
1110
2526
  switch (msg.type) {
1111
2527
  case "query":
1112
- handleQueryMessage(this.operationContext(conn, state), msg, id);
2528
+ if (name !== null) handleNamedQueryMessage(this.namedContext(conn, state), msg, id, name);
2529
+ else handleQueryMessage(this.operationContext(conn, state), msg, id);
1113
2530
  break;
1114
2531
  case "execute":
1115
- handleExecuteMessage(this.operationContext(conn, state), msg, id);
2532
+ if (name !== null) handleNamedExecuteMessage(this.namedContext(conn, state), msg, id, name);
2533
+ else handleExecuteMessage(this.operationContext(conn, state), msg, id);
1116
2534
  break;
1117
2535
  case "transaction":
1118
2536
  handleTransactionMessage(this.operationContext(conn, state), msg, id);
@@ -1124,15 +2542,26 @@ var WSHandler = class {
1124
2542
  handleLoadMessage(this.operationContext(conn, state), msg, id);
1125
2543
  break;
1126
2544
  case "subscribe":
1127
- this.handleSubscribe(conn, state, msg, id);
2545
+ if (name !== null) handleLiveSubscribeMessage(this.liveDeps(), conn, state, msg, id, name);
2546
+ else handleSubscribeMessage(this.subscribeDeps(), conn, state, msg, id);
1128
2547
  break;
1129
2548
  case "unsubscribe":
1130
2549
  this.handleUnsubscribe(conn, state, id);
1131
2550
  break;
2551
+ case "ack":
2552
+ handleAckMessage(this.subscribeDeps(), conn, state, msg, id);
2553
+ break;
1132
2554
  default:
1133
2555
  this.sendError(conn, id, "UNKNOWN_TYPE", `Unknown message type: '${msg.type}'`);
1134
2556
  }
1135
2557
  }
2558
+ handleSocketDrain(conn) {
2559
+ const state = this.connections.get(conn);
2560
+ if (!state) return;
2561
+ for (const stream of state.deviceStreams.values()) {
2562
+ stream.onSocketDrain();
2563
+ }
2564
+ }
1136
2565
  handleClose(conn) {
1137
2566
  const state = this.connections.get(conn);
1138
2567
  if (!state) return;
@@ -1167,109 +2596,43 @@ var WSHandler = class {
1167
2596
  sendCaughtError: (id, err) => this.sendSirannonError(conn, id, err)
1168
2597
  };
1169
2598
  }
1170
- async handleSubscribe(conn, state, msg, id) {
1171
- if (typeof msg.table !== "string") {
1172
- this.sendError(conn, id, "INVALID_MESSAGE", 'Subscribe message requires a "table" string field');
1173
- return;
1174
- }
1175
- if (state.subscriptions.has(id)) {
1176
- this.sendError(conn, id, "DUPLICATE_SUBSCRIPTION", `Subscription '${id}' already exists on this connection`);
1177
- return;
1178
- }
1179
- if (state.database.readOnly) {
1180
- this.sendError(conn, id, "READ_ONLY", "Subscriptions are not available on read-only databases");
1181
- return;
1182
- }
1183
- if (state.database.path === ":memory:") {
1184
- this.sendError(conn, id, "CDC_UNSUPPORTED", "CDC subscriptions require file-based databases");
1185
- return;
1186
- }
1187
- if (msg.filter !== void 0 && msg.filter !== null && (typeof msg.filter !== "object" || Array.isArray(msg.filter))) {
1188
- this.sendError(conn, id, "INVALID_MESSAGE", '"filter" must be a plain object');
1189
- return;
1190
- }
1191
- const decodedFilter = decodeBoundParams(msg.filter, "filter");
1192
- if (!decodedFilter.ok) {
1193
- this.sendError(conn, id, "INVALID_MESSAGE", decodedFilter.message);
1194
- return;
1195
- }
1196
- const filter = decodedFilter.value;
1197
- let sinceSeq;
1198
- if (msg.sinceSeq !== void 0) {
1199
- if (typeof msg.sinceSeq !== "string" || !/^\d+$/.test(msg.sinceSeq)) {
1200
- this.sendError(conn, id, "INVALID_MESSAGE", '"sinceSeq" must be a non-negative integer string');
1201
- return;
1202
- }
1203
- sinceSeq = BigInt(msg.sinceSeq);
1204
- }
1205
- let clientEpoch;
1206
- if (msg.epoch !== void 0) {
1207
- if (typeof msg.epoch !== "string") {
1208
- this.sendError(conn, id, "INVALID_MESSAGE", '"epoch" must be a string');
1209
- return;
1210
- }
1211
- clientEpoch = msg.epoch;
1212
- }
1213
- if (sinceSeq === void 0) {
1214
- await this.subscribeLive(conn, state, id, msg.table, filter);
1215
- return;
1216
- }
1217
- await this.subscribeResuming(conn, state, id, msg.table, filter, sinceSeq, clientEpoch);
2599
+ namedContext(conn, state) {
2600
+ return {
2601
+ ...this.operationContext(conn, state),
2602
+ databaseId: state.databaseId,
2603
+ identity: state.identity,
2604
+ operations: this.operations
2605
+ };
1218
2606
  }
1219
- async subscribeLive(conn, state, id, table, filter) {
1220
- try {
1221
- const ctx = await this.cdc.ensure(state.databaseId, state.database);
1222
- await ctx.tracker.watch(ctx.cdcConn, table);
1223
- const boundary = ctx.tracker.cursor;
1224
- const sub = ctx.manager.subscribe(table, filter, (event) => {
1225
- this.sendChange(conn, id, event);
1226
- });
1227
- state.subscriptions.set(id, sub);
1228
- this.send(conn, { type: "subscribed", id, seq: boundary.toString(), epoch: ctx.epoch });
1229
- } catch (err) {
1230
- this.cdc.maybeCleanup(state.databaseId);
1231
- this.sendSirannonError(conn, id, err);
1232
- }
2607
+ liveDeps() {
2608
+ return {
2609
+ operations: this.operations,
2610
+ sendSubscribedRows: (conn, id, rows) => this.send(conn, { type: "subscribed", id, rows }),
2611
+ sendLive: (conn, message) => this.send(conn, message),
2612
+ sendError: (conn, id, code, message) => this.sendError(conn, id, code, message),
2613
+ sendSirannonError: (conn, id, err) => this.sendSirannonError(conn, id, err)
2614
+ };
1233
2615
  }
1234
- async subscribeResuming(conn, state, id, table, filter, sinceSeq, clientEpoch) {
1235
- let ctx;
1236
- let primed;
1237
- let boundary;
1238
- let resync;
1239
- try {
1240
- ctx = await this.cdc.ensure(state.databaseId, state.database);
1241
- await ctx.tracker.watch(ctx.cdcConn, table);
1242
- boundary = ctx.tracker.cursor;
1243
- primed = new PrimedSubscription(
1244
- sinceSeq,
1245
- (event) => this.sendChange(conn, id, event),
1246
- () => this.handleOverload(conn)
1247
- );
1248
- const sub = ctx.manager.subscribe(table, filter, (event) => primed.onLiveEvent(event));
1249
- state.subscriptions.set(id, sub);
1250
- const minSeq = await ctx.tracker.getMinSeq(ctx.cdcConn);
1251
- const foreignEpoch = clientEpoch !== void 0 && clientEpoch !== ctx.epoch;
1252
- resync = foreignEpoch || needsResync(sinceSeq, minSeq, boundary);
1253
- this.send(conn, {
2616
+ subscribeDeps() {
2617
+ return {
2618
+ cdc: this.cdc,
2619
+ maxUnacknowledgedChanges: this.maxUnacknowledgedChanges,
2620
+ sendSubscribed: (conn, id, seq, epoch, resync, maxUnacknowledgedChanges) => this.send(conn, {
1254
2621
  type: "subscribed",
1255
2622
  id,
1256
- seq: boundary.toString(),
1257
- epoch: ctx.epoch,
1258
- ...resync ? { resync: true } : {}
1259
- });
1260
- } catch (err) {
1261
- this.cdc.maybeCleanup(state.databaseId);
1262
- this.sendSirannonError(conn, id, err);
1263
- return;
1264
- }
1265
- if (!resync) {
1266
- try {
1267
- await primed.replay(ctx.tracker, ctx.cdcConn, table, filter, boundary);
1268
- } catch {
1269
- this.send(conn, { type: "subscribed", id, seq: boundary.toString(), epoch: ctx.epoch, resync: true });
1270
- }
1271
- }
1272
- primed.goLive();
2623
+ seq,
2624
+ epoch,
2625
+ ...resync ? { resync: true } : {},
2626
+ ...maxUnacknowledgedChanges !== void 0 ? { maxUnacknowledgedChanges } : {}
2627
+ }),
2628
+ sendResult: (conn, id, data) => this.send(conn, { type: "result", id, data }),
2629
+ sendError: (conn, id, code, message) => this.sendError(conn, id, code, message),
2630
+ sendSirannonError: (conn, id, err) => this.sendSirannonError(conn, id, err),
2631
+ sendChange: (conn, id, event) => this.sendChange(conn, id, event),
2632
+ sendText: (conn, data) => this.sendText(conn, data),
2633
+ closeFaulted: (conn) => conn.close(1011, "Device stream failed"),
2634
+ handleOverload: (conn) => this.handleOverload(conn)
2635
+ };
1273
2636
  }
1274
2637
  handleUnsubscribe(conn, state, id) {
1275
2638
  const sub = state.subscriptions.get(id);
@@ -1311,18 +2674,14 @@ var WSHandler = class {
1311
2674
  this.sendError(conn, id, code, message);
1312
2675
  }
1313
2676
  sendChange(conn, subscriptionId, event) {
1314
- return this.send(conn, {
1315
- type: "change",
1316
- id: subscriptionId,
1317
- event: {
1318
- type: event.type,
1319
- table: event.table,
1320
- row: encodeTaggedValues(event.row),
1321
- oldRow: event.oldRow === void 0 ? void 0 : encodeTaggedValues(event.oldRow),
1322
- seq: event.seq.toString(),
1323
- timestamp: event.timestamp
1324
- }
1325
- });
2677
+ return this.send(conn, { type: "change", id: subscriptionId, event: wireChangeEvent(event) });
2678
+ }
2679
+ sendText(conn, data) {
2680
+ const outcome = conn.send(data);
2681
+ if (outcome === "dropped") {
2682
+ this.handleOverload(conn);
2683
+ }
2684
+ return outcome;
1326
2685
  }
1327
2686
  };
1328
2687
  function createWSHandler(sirannon, options) {
@@ -1330,17 +2689,14 @@ function createWSHandler(sirannon, options) {
1330
2689
  }
1331
2690
 
1332
2691
  // src/server/ws-route.ts
2692
+ var UNSUPPORTED_SUBPROTOCOL_MESSAGE = `The upgrade offered no subprotocol this server supports. Offer '${SIRANNON_WS_SUBPROTOCOL}' alongside any credential-bearing value, or offer none at all.`;
1333
2693
  function toSendOutcome(result) {
1334
2694
  if (result === 2) return "dropped";
1335
2695
  if (result === 0) return "buffered";
1336
2696
  return "sent";
1337
2697
  }
1338
- function selectWebSocketProtocol(header) {
1339
- const [firstProtocol] = header.split(",");
1340
- return firstProtocol?.trim() ?? "";
1341
- }
1342
2698
  function registerWebSocketRoute(options) {
1343
- const { app, wsHandler, onRequestHook } = options;
2699
+ const { app, wsHandler, authenticateHook } = options;
1344
2700
  app.ws("/db/:id", {
1345
2701
  maxPayloadLength: options.maxBodyBytes,
1346
2702
  maxBackpressure: options.maxBackpressureBytes,
@@ -1352,7 +2708,7 @@ function registerWebSocketRoute(options) {
1352
2708
  const method = req.getMethod();
1353
2709
  const secWebSocketKey = req.getHeader("sec-websocket-key");
1354
2710
  const secWebSocketProtocol = req.getHeader("sec-websocket-protocol");
1355
- const selectedWebSocketProtocol = selectWebSocketProtocol(secWebSocketProtocol);
2711
+ const negotiated = selectSubprotocol(secWebSocketProtocol);
1356
2712
  const secWebSocketExtensions = req.getHeader("sec-websocket-extensions");
1357
2713
  const headers = {};
1358
2714
  req.forEach((key, value) => {
@@ -1360,7 +2716,14 @@ function registerWebSocketRoute(options) {
1360
2716
  });
1361
2717
  const remoteAddress = decodeRemoteAddress(res);
1362
2718
  const abort = initAbortHandler(res);
1363
- if (!onRequestHook) {
2719
+ if (!negotiated.ok) {
2720
+ if (abort.claim()) {
2721
+ sendError(res, 400, "UNSUPPORTED_SUBPROTOCOL", UNSUPPORTED_SUBPROTOCOL_MESSAGE);
2722
+ }
2723
+ return;
2724
+ }
2725
+ const selectedWebSocketProtocol = negotiated.protocol;
2726
+ if (!authenticateHook) {
1364
2727
  if (abort.claim()) {
1365
2728
  res.upgrade(
1366
2729
  { databaseId: dbId },
@@ -1379,20 +2742,24 @@ function registerWebSocketRoute(options) {
1379
2742
  databaseId: dbId,
1380
2743
  remoteAddress
1381
2744
  };
1382
- runOnRequest(res, abort, ctx, onRequestHook).then((allowed) => {
1383
- if (!allowed || !abort.claim()) return;
1384
- res.upgrade(
1385
- { databaseId: dbId },
1386
- secWebSocketKey,
1387
- selectedWebSocketProtocol,
1388
- secWebSocketExtensions,
1389
- context
1390
- );
2745
+ authenticateUpgrade(res, abort, ctx, authenticateHook).then((authenticated) => {
2746
+ if (!authenticated.ok && !authenticated.refusal) return;
2747
+ if (!abort.claim()) return;
2748
+ const userData = authenticated.ok ? { databaseId: dbId, identity: authenticated.identity } : { databaseId: dbId, refusal: authenticated.refusal };
2749
+ res.upgrade(userData, secWebSocketKey, selectedWebSocketProtocol, secWebSocketExtensions, context);
1391
2750
  }).catch(() => {
1392
2751
  });
1393
2752
  },
1394
2753
  open: (ws) => {
1395
2754
  const userData = ws.getUserData();
2755
+ const refusal = userData.refusal;
2756
+ if (refusal) {
2757
+ try {
2758
+ ws.end(refusal.code, refusal.reason);
2759
+ } catch {
2760
+ }
2761
+ return;
2762
+ }
1396
2763
  const conn = {
1397
2764
  send(data) {
1398
2765
  try {
@@ -1409,7 +2776,7 @@ function registerWebSocketRoute(options) {
1409
2776
  }
1410
2777
  };
1411
2778
  userData.conn = conn;
1412
- wsHandler.handleOpen(conn, userData.databaseId).catch(() => {
2779
+ wsHandler.handleOpen(conn, userData.databaseId, userData.identity).catch(() => {
1413
2780
  });
1414
2781
  },
1415
2782
  message: (ws, message) => {
@@ -1424,6 +2791,12 @@ function registerWebSocketRoute(options) {
1424
2791
  wsHandler.handleOverload(userData.conn);
1425
2792
  }
1426
2793
  },
2794
+ drain: (ws) => {
2795
+ const userData = ws.getUserData();
2796
+ if (userData.conn) {
2797
+ wsHandler.handleSocketDrain(userData.conn);
2798
+ }
2799
+ },
1427
2800
  close: (ws) => {
1428
2801
  const userData = ws.getUserData();
1429
2802
  if (!userData.conn) return;
@@ -1434,6 +2807,10 @@ function registerWebSocketRoute(options) {
1434
2807
  }
1435
2808
 
1436
2809
  // src/server/server.ts
2810
+ var SQL_ROUTES = ["/db/:id/query", "/db/:id/execute", "/db/:id/transaction", "/db/:id/batch", "/db/:id/load"];
2811
+ function refuseSql(res) {
2812
+ sendError(res, 403, "SQL_NOT_ACCEPTED", SQL_NOT_ACCEPTED_MESSAGE);
2813
+ }
1437
2814
  var DEFAULT_MAX_BODY_BYTES = 1048576;
1438
2815
  var DEFAULT_WS_BACKPRESSURE_BYTES = 16 * 1048576;
1439
2816
  var UWS_MAX_LIMIT_BYTES = 4294967295;
@@ -1481,10 +2858,14 @@ var SirannonServer = class {
1481
2858
  host;
1482
2859
  port;
1483
2860
  cors;
1484
- onRequestHook;
2861
+ authenticateHook;
2862
+ acceptSql;
2863
+ operations;
2864
+ registryDigest;
1485
2865
  resolveExecutionTarget;
1486
2866
  getReplicationStatus;
1487
2867
  getClusterStatus;
2868
+ authorizeClusterStatus;
1488
2869
  sirannon;
1489
2870
  wsHandler;
1490
2871
  maxBodyBytes;
@@ -1494,16 +2875,24 @@ var SirannonServer = class {
1494
2875
  this.host = options?.host ?? "127.0.0.1";
1495
2876
  this.port = options?.port ?? 9876;
1496
2877
  this.cors = resolveCors(options?.cors);
1497
- this.onRequestHook = options?.onRequest;
2878
+ this.authenticateHook = options?.authenticate;
2879
+ this.acceptSql = options?.acceptSql === true;
2880
+ this.operations = options?.operations;
2881
+ this.registryDigest = operationRegistryDigest(options?.operations);
1498
2882
  this.resolveExecutionTarget = options?.resolveExecutionTarget;
1499
2883
  this.getReplicationStatus = options?.getReplicationStatus;
1500
2884
  this.getClusterStatus = options?.getClusterStatus;
2885
+ this.authorizeClusterStatus = options?.authorizeClusterStatus;
1501
2886
  this.maxBodyBytes = resolveMaxBodyBytes(options?.maxBodyBytes);
1502
2887
  this.maxWsBackpressureBytes = resolveWsBackpressure(options?.maxWebSocketBackpressureBytes, this.maxBodyBytes);
1503
2888
  this.wsHandler = new WSHandler(sirannon, {
1504
2889
  resolveExecutionTarget: this.resolveExecutionTarget,
1505
2890
  maxPayloadLength: this.maxBodyBytes,
1506
- cdcRetentionMs: options?.cdcRetentionMs
2891
+ cdcRetentionMs: options?.cdcRetentionMs,
2892
+ deviceCursorRetentionMs: options?.deviceCursorRetentionMs,
2893
+ maxUnacknowledgedChanges: options?.maxUnacknowledgedChanges,
2894
+ acceptSql: this.acceptSql,
2895
+ operations: options?.operations
1507
2896
  });
1508
2897
  this.app = uWS.App();
1509
2898
  this.registerRoutes();
@@ -1546,17 +2935,42 @@ var SirannonServer = class {
1546
2935
  });
1547
2936
  });
1548
2937
  }
2938
+ this.app.get(
2939
+ "/capabilities",
2940
+ this.withCors(handleCapabilities({ registryDigest: this.registryDigest, acceptSql: this.acceptSql }))
2941
+ );
1549
2942
  this.app.get("/health", this.withCors(handleLiveness()));
1550
2943
  this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon, this.getReplicationStatus)));
1551
- this.app.get("/db/:id/cluster", this.wrapDbGetRoute(handleClusterStatus(this.getClusterStatus)));
1552
- this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
1553
- this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
2944
+ this.app.get(
2945
+ "/db/:id/cluster",
2946
+ this.wrapDbGetRoute(handleClusterStatus(this.getClusterStatus, this.authorizeClusterStatus))
2947
+ );
2948
+ if (this.acceptSql) {
2949
+ this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
2950
+ this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
2951
+ this.app.post(
2952
+ "/db/:id/transaction",
2953
+ this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget))
2954
+ );
2955
+ this.app.post("/db/:id/batch", this.wrapDbRoute(handleBatch(this.sirannon, this.resolveExecutionTarget)));
2956
+ this.app.post("/db/:id/load", this.wrapDbRoute(handleLoad(this.sirannon, this.resolveExecutionTarget)));
2957
+ } else {
2958
+ for (const route of SQL_ROUTES) {
2959
+ this.app.post(route, this.withCors(refuseSql));
2960
+ }
2961
+ }
2962
+ this.app.post(
2963
+ "/db/:id/query/:name",
2964
+ this.wrapOperationRoute(handleOperationQuery(this.sirannon, this.operations, this.resolveExecutionTarget))
2965
+ );
1554
2966
  this.app.post(
1555
- "/db/:id/transaction",
1556
- this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget))
2967
+ "/db/:id/execute/:name",
2968
+ this.wrapOperationRoute(handleOperationExecute(this.sirannon, this.operations, this.resolveExecutionTarget))
1557
2969
  );
1558
- this.app.post("/db/:id/batch", this.wrapDbRoute(handleBatch(this.sirannon, this.resolveExecutionTarget)));
1559
- this.app.post("/db/:id/load", this.wrapDbRoute(handleLoad(this.sirannon, this.resolveExecutionTarget)));
2970
+ this.app.post("/db/:id/changes", this.wrapDbRoute(handleChanges(this.sirannon, this.resolveExecutionTarget)));
2971
+ this.app.post("/db/:id/migrations", this.wrapDbRoute(handleMigrationList(this.sirannon)));
2972
+ this.app.post("/db/:id/snapshot", this.wrapDbRoute(handleSnapshotManifest(this.sirannon)));
2973
+ this.app.post("/db/:id/snapshot/page", this.wrapDbRoute(handleSnapshotPage(this.sirannon)));
1560
2974
  this.registerWebSocketRoute();
1561
2975
  this.app.any("/*", (res) => {
1562
2976
  sendError(res, 404, "NOT_FOUND", "Route not found");
@@ -1566,7 +2980,7 @@ var SirannonServer = class {
1566
2980
  registerWebSocketRoute({
1567
2981
  app: this.app,
1568
2982
  wsHandler: this.wsHandler,
1569
- onRequestHook: this.onRequestHook,
2983
+ authenticateHook: this.authenticateHook,
1570
2984
  maxBodyBytes: this.maxBodyBytes,
1571
2985
  maxBackpressureBytes: this.maxWsBackpressureBytes
1572
2986
  });
@@ -1580,7 +2994,7 @@ var SirannonServer = class {
1580
2994
  };
1581
2995
  }
1582
2996
  wrapDbRoute(handler) {
1583
- const onRequestHook = this.onRequestHook;
2997
+ const authenticateHook = this.authenticateHook;
1584
2998
  const corsHeaders = this.cors;
1585
2999
  const maxBody = this.maxBodyBytes;
1586
3000
  return (res, req) => {
@@ -1592,7 +3006,7 @@ var SirannonServer = class {
1592
3006
  }
1593
3007
  const abort = initAbortHandler(res);
1594
3008
  const bodyPromise = readBody(res, maxBody, abort);
1595
- if (!onRequestHook) {
3009
+ if (!authenticateHook) {
1596
3010
  bodyPromise.then(async (rawBody) => {
1597
3011
  if (!abort.claim()) return;
1598
3012
  try {
@@ -1618,9 +3032,9 @@ var SirannonServer = class {
1618
3032
  databaseId: dbId,
1619
3033
  remoteAddress
1620
3034
  };
1621
- const hookPromise = runOnRequest(res, abort, ctx, onRequestHook);
1622
- Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, allowed]) => {
1623
- if (!allowed || !abort.claim()) return;
3035
+ const hookPromise = runAuthenticate(res, abort, ctx, authenticateHook);
3036
+ Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, authenticated]) => {
3037
+ if (!authenticated.ok || !abort.claim()) return;
1624
3038
  try {
1625
3039
  await handler(res, dbId, rawBody, abort);
1626
3040
  } catch {
@@ -1632,8 +3046,18 @@ var SirannonServer = class {
1632
3046
  });
1633
3047
  };
1634
3048
  }
3049
+ wrapOperationRoute(handler) {
3050
+ return wrapOperationRoute(
3051
+ {
3052
+ cors: this.cors,
3053
+ maxBodyBytes: this.maxBodyBytes,
3054
+ authenticateHook: this.authenticateHook
3055
+ },
3056
+ handler
3057
+ );
3058
+ }
1635
3059
  wrapDbGetRoute(handler) {
1636
- const onRequestHook = this.onRequestHook;
3060
+ const authenticateHook = this.authenticateHook;
1637
3061
  const corsHeaders = this.cors;
1638
3062
  return (res, req) => {
1639
3063
  const dbId = req.getParameter(0) ?? "";
@@ -1642,10 +3066,6 @@ var SirannonServer = class {
1642
3066
  if (corsHeaders) {
1643
3067
  writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
1644
3068
  }
1645
- if (!onRequestHook) {
1646
- handler(res, dbId);
1647
- return;
1648
- }
1649
3069
  const headers = {};
1650
3070
  req.forEach((key, value) => {
1651
3071
  headers[key] = value;
@@ -1658,10 +3078,18 @@ var SirannonServer = class {
1658
3078
  remoteAddress: decodeRemoteAddress(res)
1659
3079
  };
1660
3080
  const abort = initAbortHandler(res);
1661
- runOnRequest(res, abort, ctx, onRequestHook).then((allowed) => {
1662
- if (!allowed || !abort.claim()) return;
1663
- handler(res, dbId);
1664
- }).catch(() => {
3081
+ const run = async () => {
3082
+ if (authenticateHook && !(await runAuthenticate(res, abort, ctx, authenticateHook)).ok) return;
3083
+ if (!abort.claim()) return;
3084
+ try {
3085
+ await handler(res, dbId, ctx, abort);
3086
+ } catch {
3087
+ if (!abort.aborted) {
3088
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
3089
+ }
3090
+ }
3091
+ };
3092
+ run().catch(() => {
1665
3093
  });
1666
3094
  };
1667
3095
  }