@delali/sirannon-db 0.3.4-next.62 → 0.3.4

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 (108) hide show
  1. package/README.md +79 -56
  2. package/dist/backup/index.d.ts +44 -45
  3. package/dist/backup/index.mjs +7 -7
  4. package/dist/backup-scheduler/index.d.ts +42 -41
  5. package/dist/backup-scheduler/index.mjs +4 -4
  6. package/dist/baseline-Co3iIsN_.d.ts +19 -0
  7. package/dist/change-tracker-DqCdeEyx.d.ts +128 -0
  8. package/dist/{chunk-VOSJBZ6Q.mjs → chunk-2ES3DT4A.mjs} +71 -33
  9. package/dist/{chunk-6CMCO7S4.mjs → chunk-2O3PP4CF.mjs} +1 -1
  10. package/dist/{chunk-E7Q7HE46.mjs → chunk-3BZLZ47R.mjs} +26 -26
  11. package/dist/chunk-4KASJKKK.mjs +2501 -0
  12. package/dist/{chunk-MYNI2WFS.mjs → chunk-52XW3Z76.mjs} +40 -8
  13. package/dist/chunk-55YRLSM2.mjs +57 -0
  14. package/dist/{chunk-KDAHYFW7.mjs → chunk-6AOJCNRU.mjs} +5 -5
  15. package/dist/{chunk-KXTYR2OQ.mjs → chunk-A3PHCO6W.mjs} +39 -111
  16. package/dist/chunk-ADNWEHGZ.mjs +299 -0
  17. package/dist/{chunk-2QLXDHAP.mjs → chunk-BLMRNSUG.mjs} +11 -2
  18. package/dist/chunk-BVMZJOVM.mjs +5 -0
  19. package/dist/{chunk-IWGIYDMZ.mjs → chunk-BZWDPF4G.mjs} +1 -1
  20. package/dist/{chunk-YVF2ZJTE.mjs → chunk-CZL4D3BM.mjs} +1 -1
  21. package/dist/{chunk-7SC2TZAS.mjs → chunk-DX4WGJL7.mjs} +2 -2
  22. package/dist/{chunk-45MKELYA.mjs → chunk-ESTZZPT5.mjs} +132 -13
  23. package/dist/{chunk-QP5L5DFJ.mjs → chunk-FJPR2CJX.mjs} +3 -3
  24. package/dist/chunk-HR7K3GZW.mjs +36 -0
  25. package/dist/{chunk-LXCGPZPA.mjs → chunk-IEKIYO4V.mjs} +1 -1
  26. package/dist/{chunk-TUD5CJ76.mjs → chunk-KB4OLJAD.mjs} +26 -19
  27. package/dist/{chunk-PBRXXISQ.mjs → chunk-KINDFBQP.mjs} +3 -2
  28. package/dist/{chunk-E6IMX7RS.mjs → chunk-KJLNYOOL.mjs} +2 -2
  29. package/dist/{chunk-BTTFW4Z4.mjs → chunk-KU32OT4H.mjs} +2 -10
  30. package/dist/{chunk-JQWGHSEH.mjs → chunk-NVSQ2T5Q.mjs} +71 -70
  31. package/dist/{chunk-W4EUTJNT.mjs → chunk-NZGSDALX.mjs} +94 -96
  32. package/dist/{chunk-U445A3C4.mjs → chunk-QGVRRKB2.mjs} +1 -1
  33. package/dist/{chunk-GYCEJNU4.mjs → chunk-R7GBXNTO.mjs} +1 -1
  34. package/dist/{chunk-7C36BCSN.mjs → chunk-VDN6FHU4.mjs} +1 -1
  35. package/dist/{chunk-6IYS4WLH.mjs → chunk-VO3ZWA2G.mjs} +142 -115
  36. package/dist/chunk-WOMADPM4.mjs +12 -0
  37. package/dist/{chunk-OUSWVNWT.mjs → chunk-YCMB6GR4.mjs} +1 -1
  38. package/dist/{chunk-YNTDOFTN.mjs → chunk-YW7IKQ7T.mjs} +1 -1
  39. package/dist/{chunk-M2BEAH5I.mjs → chunk-ZN4HI5FK.mjs} +126 -72
  40. package/dist/{chunk-SBL6GN43.mjs → chunk-ZYWI45EN.mjs} +1 -1
  41. package/dist/client/index.d.ts +135 -137
  42. package/dist/client/index.mjs +177 -82
  43. package/dist/client/topology.d.ts +31 -31
  44. package/dist/client/topology.mjs +42 -23
  45. package/dist/client-base-BxTocU1x.d.ts +398 -0
  46. package/dist/client-streams-YKANXMWO.mjs +112 -0
  47. package/dist/codegen/cli.mjs +4 -4
  48. package/dist/codegen/index.d.ts +36 -29
  49. package/dist/codegen/index.mjs +4 -4
  50. package/dist/core/index.d.ts +130 -115
  51. package/dist/core/index.mjs +387 -297
  52. package/dist/core/writer-worker.mjs +3 -3
  53. package/dist/database-CGDsXW1K.d.ts +959 -0
  54. package/dist/database-backup-DcLhxoa1.d.ts +143 -0
  55. package/dist/driver/better-sqlite3.d.ts +11 -10
  56. package/dist/driver/better-sqlite3.mjs +11 -11
  57. package/dist/driver/bun.d.ts +9 -8
  58. package/dist/driver/expo.d.ts +6 -7
  59. package/dist/driver/node.d.ts +11 -10
  60. package/dist/driver/node.mjs +13 -11
  61. package/dist/driver/wa-sqlite.d.ts +6 -6
  62. package/dist/errors-CJUkQ-KL.d.ts +199 -0
  63. package/dist/file-migrations/index.d.ts +20 -18
  64. package/dist/file-migrations/index.mjs +3 -3
  65. package/dist/forward-rpc-VRWFZXHN.mjs +44 -0
  66. package/dist/operation-registry-CGhNT-Sn.d.ts +96 -0
  67. package/dist/primary-wins-CHNOYuN7.d.ts +81 -0
  68. package/dist/protocol-DFpNNsIT.d.ts +153 -0
  69. package/dist/query-types-5B02ixH0.d.ts +129 -0
  70. package/dist/react/index.d.ts +22 -22
  71. package/dist/replication/coordinator/etcd.d.ts +37 -31
  72. package/dist/replication/coordinator/etcd.mjs +296 -131
  73. package/dist/replication/index.d.ts +201 -161
  74. package/dist/replication/index.mjs +630 -529
  75. package/dist/restore-options-CH8mE-Tb.d.ts +84 -0
  76. package/dist/server/index.d.ts +201 -191
  77. package/dist/server/index.mjs +736 -619
  78. package/dist/server-options-aYtCkefX.d.ts +317 -0
  79. package/dist/server-streams-24XL4T43.mjs +254 -0
  80. package/dist/sirannon-J3EgPVD7.d.ts +179 -0
  81. package/dist/transport/grpc.d.ts +37 -24
  82. package/dist/transport/grpc.mjs +83 -3199
  83. package/dist/transport/memory.d.ts +25 -26
  84. package/dist/transport/memory.mjs +18 -9
  85. package/dist/types-BrAiDGOb.d.ts +505 -0
  86. package/dist/types-CEJG0eFS.d.ts +88 -0
  87. package/dist/types-Dc8oWlxj.d.ts +1580 -0
  88. package/dist/types-DqODKeOq.d.ts +123 -0
  89. package/dist/types-guEKSzMr.d.ts +367 -0
  90. package/package.json +7 -7
  91. package/dist/baseline-D93hcIEE.d.ts +0 -17
  92. package/dist/change-tracker-CjRFuZdB.d.ts +0 -144
  93. package/dist/client-base-Cmd9EVme.d.ts +0 -397
  94. package/dist/database-BLQvqjsw.d.ts +0 -917
  95. package/dist/database-backup-B7oszQ2n.d.ts +0 -141
  96. package/dist/errors-Dei4GdBb.d.ts +0 -196
  97. package/dist/operation-registry-oiPuSYI6.d.ts +0 -94
  98. package/dist/primary-wins-B0np8JS3.d.ts +0 -85
  99. package/dist/protocol-HI-nY8DD.d.ts +0 -152
  100. package/dist/query-types-p1liOP-b.d.ts +0 -127
  101. package/dist/restore-options-CksYsH1l.d.ts +0 -83
  102. package/dist/server-options-Bj5iJnjx.d.ts +0 -292
  103. package/dist/sirannon-BPxC5tC2.d.ts +0 -178
  104. package/dist/types-B0EhgDbr.d.ts +0 -340
  105. package/dist/types-CbjfRwrK.d.ts +0 -499
  106. package/dist/types-CjhxcjhA.d.ts +0 -123
  107. package/dist/types-DPSmjU54.d.ts +0 -1518
  108. package/dist/types-DTO4CYhW.d.ts +0 -88
@@ -1,22 +1,22 @@
1
- import { restoreBackup, MAX_RESTORE_BATCH_SIZE } from '../chunk-E7Q7HE46.mjs';
2
- import { tablesInFkOrder, dumpSchema, dumpTablePages } from '../chunk-QP5L5DFJ.mjs';
3
- import { ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription } from '../chunk-M2BEAH5I.mjs';
4
- import '../chunk-YNTDOFTN.mjs';
5
- import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, selectMaxChangeSeq, canonicaliseForChecksum, selectDeviceCursors, selectMinForeignChangeSeqSql, IDENTIFIER_RE, deleteDeviceCursorsUpdatedBefore } from '../chunk-45MKELYA.mjs';
6
- import '../chunk-2QLXDHAP.mjs';
7
- import { encodeTaggedValues, MIGRATIONS_TABLE, decodeTaggedValues, encodeWireRowsInPlace, CHANGES_TABLE, DEVICE_CURSORS_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-6CMCO7S4.mjs';
8
- import { createOperationSource, operationRegistryDigest, findRead, isRefusal, resolveArguments, findWrite } from '../chunk-TUD5CJ76.mjs';
1
+ import { restoreBackup, MAX_RESTORE_BATCH_SIZE } from '../chunk-3BZLZ47R.mjs';
2
+ import { tablesInFkOrder, dumpSchema, dumpTablePages } from '../chunk-FJPR2CJX.mjs';
3
+ import { optionalDependencyError } from '../chunk-WOMADPM4.mjs';
4
+ import { resolveDeviceRetentionPolicy, ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, applyDeviceCursorBoundary, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription } from '../chunk-ZN4HI5FK.mjs';
5
+ import '../chunk-YW7IKQ7T.mjs';
6
+ import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, HLC, selectMaxChangeSeq, canonicaliseForChecksum, IDENTIFIER_RE } from '../chunk-ESTZZPT5.mjs';
7
+ import '../chunk-BLMRNSUG.mjs';
8
+ import { encodeTaggedValues, MIGRATIONS_TABLE, decodeTaggedValues, encodeWireRowsInPlace, CHANGES_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-2O3PP4CF.mjs';
9
+ import { createOperationSource, operationRegistryDigest, findRead, isRefusal, resolveArguments, findWrite } from '../chunk-KB4OLJAD.mjs';
9
10
  import '../chunk-GVCNMPOS.mjs';
10
- import { discardStagedChain } from '../chunk-KDAHYFW7.mjs';
11
- import '../chunk-U445A3C4.mjs';
12
- import '../chunk-E6IMX7RS.mjs';
13
- import '../chunk-GYCEJNU4.mjs';
14
- import '../chunk-OUSWVNWT.mjs';
15
- import '../chunk-YVF2ZJTE.mjs';
11
+ import { discardStagedChain } from '../chunk-6AOJCNRU.mjs';
12
+ import '../chunk-QGVRRKB2.mjs';
13
+ import '../chunk-KJLNYOOL.mjs';
14
+ import '../chunk-R7GBXNTO.mjs';
15
+ import '../chunk-YCMB6GR4.mjs';
16
+ import '../chunk-CZL4D3BM.mjs';
16
17
  import '../chunk-QBBLSQHQ.mjs';
17
- import { SQLITE_USER_VERSION_MAX } from '../chunk-IWGIYDMZ.mjs';
18
- import { SirannonError, WriteOverloadError } from '../chunk-PBRXXISQ.mjs';
19
- import uWS from 'uWebSockets.js';
18
+ import { SQLITE_USER_VERSION_MAX } from '../chunk-BZWDPF4G.mjs';
19
+ import { SirannonError, WriteOverloadError } from '../chunk-KINDFBQP.mjs';
20
20
  import { createHash } from 'crypto';
21
21
 
22
22
  // src/server/protocol.ts
@@ -148,11 +148,11 @@ function describeFailure(err) {
148
148
  var BackupRestoreRuns = class {
149
149
  runs = /* @__PURE__ */ new Map();
150
150
  /**
151
- * Marks a restore of one database as under way, unless one is already running.
151
+ * Marks a restore of one database as started, unless another restore of it is in progress.
152
152
  *
153
- * @param databaseId - Identifier of the database being rebuilt.
154
- * @param moment - Epoch milliseconds the caller asked to be taken back to.
155
- * @returns True where this call claimed the database, false where a restore already has it.
153
+ * @param databaseId - The identifier of the database that the server rebuilds.
154
+ * @param moment - The moment, in epoch milliseconds, that the caller asked to restore to.
155
+ * @returns True when this call claims the database, and false when another restore of it is in progress.
156
156
  */
157
157
  claim(databaseId, moment) {
158
158
  if (this.runs.get(databaseId)?.state === "running") return false;
@@ -160,9 +160,9 @@ var BackupRestoreRuns = class {
160
160
  return true;
161
161
  }
162
162
  /**
163
- * Records how far the restore of one database has got.
163
+ * Records the progress of one database's restore.
164
164
  *
165
- * @param databaseId - Identifier of the database being rebuilt.
165
+ * @param databaseId - The identifier of the database that the server is rebuilding.
166
166
  * @param progress - Pieces fetched, bytes fetched, and change pieces replayed.
167
167
  */
168
168
  progressed(databaseId, progress) {
@@ -172,13 +172,14 @@ var BackupRestoreRuns = class {
172
172
  /**
173
173
  * Records a restore that rebuilt the database.
174
174
  *
175
- * A reopen that failed is reported beside the report, and the state stays
176
- * `done`, because Sirannon replaced the data either way and an operator shown
177
- * only the failure would believe their database untouched.
175
+ * When the database fails to open again, this method records that error beside
176
+ * the report and keeps the state `done`, because Sirannon replaced the data
177
+ * either way, and an operator who saw only the failure could believe that the
178
+ * data was unchanged.
178
179
  *
179
- * @param databaseId - Identifier of the database that was rebuilt.
180
- * @param report - What the restore produced.
181
- * @param reopenFailure - What stopped the database opening again, where anything did.
180
+ * @param databaseId - The identifier of the rebuilt database.
181
+ * @param report - The restore's report.
182
+ * @param reopenFailure - The error that stopped the database from opening again, if there was one.
182
183
  */
183
184
  finished(databaseId, report, reopenFailure) {
184
185
  this.runs.set(databaseId, {
@@ -191,15 +192,15 @@ var BackupRestoreRuns = class {
191
192
  });
192
193
  }
193
194
  /**
194
- * Records a restore that stopped, under the code it stopped with.
195
+ * Records a failed restore under the code of the error that stopped it.
195
196
  *
196
- * Only a `SirannonError` passes its message through. Anything else reports
197
- * the same sentence the HTTP layer sends for an unexpected failure, since a
198
- * runtime error names the paths and the internals of the machine serving the
199
- * request.
197
+ * The record keeps the message of a `SirannonError` only. For any other error,
198
+ * it stores the message that the HTTP layer sends for an unexpected failure,
199
+ * because a runtime error can include file paths and other internal details of
200
+ * the server's machine.
200
201
  *
201
- * @param databaseId - Identifier of the database it was rebuilding.
202
- * @param err - What stopped it.
202
+ * @param databaseId - The identifier of the database that the server was rebuilding.
203
+ * @param err - The error that stopped the restore.
203
204
  */
204
205
  failed(databaseId, err) {
205
206
  const previous = this.runs.get(databaseId);
@@ -212,10 +213,10 @@ var BackupRestoreRuns = class {
212
213
  });
213
214
  }
214
215
  /**
215
- * Reads where one database's restore stands.
216
+ * Returns the status of one database's restore.
216
217
  *
217
- * @param databaseId - Identifier of the database to report on.
218
- * @returns The restore, or an idle answer where none has run.
218
+ * @param databaseId - The identifier of the database.
219
+ * @returns The restore's status, or an `idle` status when no restore has started.
219
220
  */
220
221
  read(databaseId) {
221
222
  return this.runs.get(databaseId) ?? { state: "idle" };
@@ -224,6 +225,8 @@ var BackupRestoreRuns = class {
224
225
 
225
226
  // src/server/http-common.ts
226
227
  var SQL_NOT_ACCEPTED_MESSAGE = "This server does not accept SQL statements over the wire; call a registered operation by name";
228
+ var TABLE_SUBSCRIPTION_NOT_ACCEPTED_MESSAGE = "This server keeps table subscriptions off; subscribe to a registered read by name, or give the server acceptSql: true or an onBeforeSubscribe hook";
229
+ var DEVICE_SYNC_NOT_ACCEPTED_MESSAGE = "This server keeps device sync off; the server has to set acceptDeviceSync before a device can sync";
227
230
  function initAbortHandler(res) {
228
231
  const listeners = [];
229
232
  let aborted = false;
@@ -428,7 +431,7 @@ var SERVER_CAPABILITIES = [...REQUIRED_DEVICE_SYNC_CAPABILITIES, STAGED_STREAM_C
428
431
  var NAMED_QUERY_CAPABILITY = "query.named";
429
432
  var SQL_QUERY_CAPABILITY = "query.sql";
430
433
  function buildCapabilitiesResponse(options) {
431
- const capabilities = [...SERVER_CAPABILITIES];
434
+ const capabilities = options?.acceptDeviceSync === true ? [...SERVER_CAPABILITIES] : [];
432
435
  if (options?.registryDigest !== void 0) capabilities.push(NAMED_QUERY_CAPABILITY);
433
436
  if (options?.acceptSql === true) capabilities.push(SQL_QUERY_CAPABILITY);
434
437
  const response = { capabilities };
@@ -472,6 +475,408 @@ function writeCorsOrigin(res, cors, requestOrigin) {
472
475
  }
473
476
  }
474
477
 
478
+ // src/server/wire-rows.ts
479
+ async function queryWireRows(target, sql, params, options) {
480
+ if (target.queryForWire) {
481
+ return target.queryForWire(sql, params, options);
482
+ }
483
+ return encodeTaggedValues(await target.query(sql, params, options));
484
+ }
485
+
486
+ // src/server/http-handler.ts
487
+ function decodeBatchParams(res, raw) {
488
+ const decoded = [];
489
+ for (const entry of raw) {
490
+ const result = decodeBoundParams(entry, "paramsBatch");
491
+ if (!result.ok) {
492
+ sendError(res, 400, "INVALID_REQUEST", result.message);
493
+ return null;
494
+ }
495
+ decoded.push(result.value);
496
+ }
497
+ return decoded;
498
+ }
499
+ function handleQuery(sirannon, resolveTarget) {
500
+ return async (res, dbId, rawBody, abort) => {
501
+ const body = parseBody(res, rawBody);
502
+ if (!body) return;
503
+ if (!body.sql || typeof body.sql !== "string") {
504
+ sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
505
+ return;
506
+ }
507
+ const readConcern = parseReadConcern(res, body.readConcern);
508
+ if (!readConcern.ok) return;
509
+ const params = decodeBoundParams(body.params, "params");
510
+ if (!params.ok) {
511
+ sendError(res, 400, "INVALID_REQUEST", params.message);
512
+ return;
513
+ }
514
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
515
+ if (!target) return;
516
+ try {
517
+ const rows = await queryWireRows(
518
+ target,
519
+ body.sql,
520
+ params.value,
521
+ readConcern.value ? { readConcern: readConcern.value } : void 0
522
+ );
523
+ if (abort.aborted) return;
524
+ sendJson(res, { rows });
525
+ } catch (err) {
526
+ sendCaughtError(res, abort, err);
527
+ }
528
+ };
529
+ }
530
+ function handleExecute(sirannon, resolveTarget) {
531
+ return async (res, dbId, rawBody, abort) => {
532
+ const body = parseBody(res, rawBody);
533
+ if (!body) return;
534
+ if (!body.sql || typeof body.sql !== "string") {
535
+ sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
536
+ return;
537
+ }
538
+ const writeConcern = parseWriteConcern(res, body.writeConcern);
539
+ if (!writeConcern.ok) return;
540
+ const params = decodeBoundParams(body.params, "params");
541
+ if (!params.ok) {
542
+ sendError(res, 400, "INVALID_REQUEST", params.message);
543
+ return;
544
+ }
545
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
546
+ if (!target) return;
547
+ try {
548
+ const result = await target.execute(
549
+ body.sql,
550
+ params.value,
551
+ writeConcern.value ? { writeConcern: writeConcern.value } : void 0
552
+ );
553
+ if (abort.aborted) return;
554
+ sendJson(res, toExecuteResponse(result));
555
+ } catch (err) {
556
+ sendCaughtError(res, abort, err);
557
+ }
558
+ };
559
+ }
560
+ function handleTransaction(sirannon, resolveTarget) {
561
+ return async (res, dbId, rawBody, abort) => {
562
+ const body = parseBody(res, rawBody);
563
+ if (!body) return;
564
+ const statementsError = transactionStatementsValidationError(body.statements);
565
+ if (statementsError !== null) {
566
+ sendError(res, 400, "INVALID_REQUEST", statementsError);
567
+ return;
568
+ }
569
+ const writeConcern = parseWriteConcern(res, body.writeConcern);
570
+ if (!writeConcern.ok) return;
571
+ const statements = [];
572
+ for (const stmt of body.statements) {
573
+ const params = decodeBoundParams(stmt.params, "params");
574
+ if (!params.ok) {
575
+ sendError(res, 400, "INVALID_REQUEST", params.message);
576
+ return;
577
+ }
578
+ statements.push({ sql: stmt.sql, params: params.value });
579
+ }
580
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
581
+ if (!target) return;
582
+ const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
583
+ try {
584
+ const results = target.executeTransaction ? await target.executeTransaction(statements, txOptions) : await target.transaction(async (tx) => {
585
+ const txResults = [];
586
+ for (const stmt of statements) {
587
+ txResults.push(await tx.execute(stmt.sql, stmt.params));
588
+ }
589
+ return txResults;
590
+ }, txOptions);
591
+ if (abort.aborted) return;
592
+ sendJson(res, {
593
+ results: results.map(toExecuteResponse)
594
+ });
595
+ } catch (err) {
596
+ sendCaughtError(res, abort, err);
597
+ }
598
+ };
599
+ }
600
+ function handleBatch(sirannon, resolveTarget) {
601
+ return async (res, dbId, rawBody, abort) => {
602
+ const body = parseBody(res, rawBody);
603
+ if (!body) return;
604
+ if (!body.sql || typeof body.sql !== "string") {
605
+ sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
606
+ return;
607
+ }
608
+ const paramsBatchError = paramsBatchValidationError(body.paramsBatch);
609
+ if (paramsBatchError !== null) {
610
+ sendError(res, 400, "INVALID_REQUEST", paramsBatchError);
611
+ return;
612
+ }
613
+ const writeConcern = parseWriteConcern(res, body.writeConcern);
614
+ if (!writeConcern.ok) return;
615
+ const paramsBatch = decodeBatchParams(res, body.paramsBatch);
616
+ if (paramsBatch === null) return;
617
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
618
+ if (!target) return;
619
+ try {
620
+ const results = await target.transaction(
621
+ async (tx) => tx.executeBatch(body.sql, paramsBatch),
622
+ writeConcern.value ? { writeConcern: writeConcern.value } : void 0
623
+ );
624
+ if (abort.aborted) return;
625
+ sendJson(res, {
626
+ results: results.map(toExecuteResponse)
627
+ });
628
+ } catch (err) {
629
+ sendCaughtError(res, abort, err);
630
+ }
631
+ };
632
+ }
633
+ function handleLoad(sirannon, resolveTarget) {
634
+ return async (res, dbId, rawBody, abort) => {
635
+ const body = parseBody(res, rawBody);
636
+ if (!body) return;
637
+ if (!body.sql || typeof body.sql !== "string") {
638
+ sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
639
+ return;
640
+ }
641
+ const paramsBatchError = paramsBatchValidationError(body.paramsBatch);
642
+ if (paramsBatchError !== null) {
643
+ sendError(res, 400, "INVALID_REQUEST", paramsBatchError);
644
+ return;
645
+ }
646
+ const durabilityError = loadDurabilityValidationError(body.durability);
647
+ if (durabilityError !== null) {
648
+ sendError(res, 400, "INVALID_REQUEST", durabilityError);
649
+ return;
650
+ }
651
+ const checkpointError = loadCheckpointValidationError(body.checkpoint);
652
+ if (checkpointError !== null) {
653
+ sendError(res, 400, "INVALID_REQUEST", checkpointError);
654
+ return;
655
+ }
656
+ const paramsBatch = decodeBatchParams(res, body.paramsBatch);
657
+ if (paramsBatch === null) return;
658
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
659
+ if (!target) return;
660
+ const bulkLoad = target.bulkLoad;
661
+ if (typeof bulkLoad !== "function") {
662
+ sendError(res, 501, "BULK_LOAD_UNSUPPORTED", "The execution target for this database does not support bulk load");
663
+ return;
664
+ }
665
+ try {
666
+ const summary = await bulkLoad.call(target, body.sql, paramsBatch, toBulkLoadOptions(body));
667
+ if (abort.aborted) return;
668
+ sendJson(res, summary);
669
+ } catch (err) {
670
+ sendCaughtError(res, abort, err);
671
+ }
672
+ };
673
+ }
674
+ function sendClusterUnavailable(res, dbId) {
675
+ sendError(res, 404, "NOT_FOUND", `Cluster status is not available for database '${dbId}'`);
676
+ }
677
+ function handleClusterStatus(getClusterStatus, authorizeClusterStatus) {
678
+ return async (res, dbId, ctx, abort) => {
679
+ if (!getClusterStatus || !authorizeClusterStatus) {
680
+ sendClusterUnavailable(res, dbId);
681
+ return;
682
+ }
683
+ let allowed;
684
+ try {
685
+ allowed = await authorizeClusterStatus(ctx);
686
+ } catch {
687
+ if (abort.aborted) return;
688
+ sendError(res, 500, "HOOK_ERROR", "authorizeClusterStatus threw an error");
689
+ return;
690
+ }
691
+ if (abort.aborted) return;
692
+ if (!allowed) {
693
+ sendClusterUnavailable(res, dbId);
694
+ return;
695
+ }
696
+ const status = getClusterStatus(dbId);
697
+ if (!status) {
698
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
699
+ return;
700
+ }
701
+ sendJson(res, toClusterStatusResponse(status));
702
+ };
703
+ }
704
+ function toClusterStatusResponse(status) {
705
+ return {
706
+ ...status,
707
+ currentPrimary: status.currentPrimary ? { ...status.currentPrimary } : status.currentPrimary,
708
+ readEndpoints: status.readEndpoints?.map((endpoint) => ({
709
+ ...endpoint,
710
+ readConcerns: [...endpoint.readConcerns]
711
+ })),
712
+ primaryTerm: status.primaryTerm?.toString()
713
+ };
714
+ }
715
+
716
+ // src/core/ws-handshake.ts
717
+ var SIRANNON_WS_SUBPROTOCOL = "sirannon.v1";
718
+ var WS_CLOSE_UNAUTHENTICATED = 4401;
719
+ var WS_CLOSE_FORBIDDEN = 4403;
720
+ function selectSubprotocol(header) {
721
+ const offered = header.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
722
+ if (offered.length === 0) return { ok: true, protocol: "" };
723
+ if (offered.includes(SIRANNON_WS_SUBPROTOCOL)) return { ok: true, protocol: SIRANNON_WS_SUBPROTOCOL };
724
+ return { ok: false };
725
+ }
726
+
727
+ // src/server/request-hook.ts
728
+ function decodeRemoteAddress(res) {
729
+ return Buffer.from(res.getRemoteAddressAsText()).toString();
730
+ }
731
+ var CLOSE_REASON_MAX_BYTES = 123;
732
+ 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.";
733
+ function looksLikeRefusal(value) {
734
+ if (typeof value !== "object" || value === null) return false;
735
+ const candidate = value;
736
+ return typeof candidate.status === "number" && typeof candidate.code === "string" && typeof candidate.message === "string";
737
+ }
738
+ async function invokeAuthenticate(ctx, hook) {
739
+ try {
740
+ const identity = await hook(ctx);
741
+ if (looksLikeRefusal(identity)) return { status: "returned-refusal" };
742
+ return { status: "identity", identity };
743
+ } catch (error) {
744
+ return { status: "threw", error };
745
+ }
746
+ }
747
+ function sendRefusal(res, abort, outcome) {
748
+ if (!abort.claim()) return;
749
+ if (outcome.status === "returned-refusal") {
750
+ sendError(res, 500, "HOOK_ERROR", RETURNED_REFUSAL_MESSAGE);
751
+ return;
752
+ }
753
+ if (outcome.status === "threw" && outcome.error instanceof SirannonError) {
754
+ sendCaughtError(res, abort, outcome.error);
755
+ return;
756
+ }
757
+ sendError(res, 500, "HOOK_ERROR", "authenticate hook threw an error");
758
+ }
759
+ function truncateCloseReason(reason) {
760
+ if (Buffer.byteLength(reason, "utf-8") <= CLOSE_REASON_MAX_BYTES) return reason;
761
+ let truncated = reason.slice(0, CLOSE_REASON_MAX_BYTES);
762
+ while (truncated.length > 0 && Buffer.byteLength(truncated, "utf-8") > CLOSE_REASON_MAX_BYTES) {
763
+ truncated = truncated.slice(0, -1);
764
+ }
765
+ return truncated;
766
+ }
767
+ function upgradeRefusalFor(error) {
768
+ if (!(error instanceof SirannonError)) return void 0;
769
+ const status = httpStatusForError(error);
770
+ if (status !== 401 && status !== 403) return void 0;
771
+ return {
772
+ code: status === 403 ? WS_CLOSE_FORBIDDEN : WS_CLOSE_UNAUTHENTICATED,
773
+ reason: truncateCloseReason(`${error.code}: ${error.message}`)
774
+ };
775
+ }
776
+ async function runAuthenticate(res, abort, ctx, hook) {
777
+ const outcome = await invokeAuthenticate(ctx, hook);
778
+ if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
779
+ sendRefusal(res, abort, outcome);
780
+ return { ok: false };
781
+ }
782
+ async function authenticateUpgrade(res, abort, ctx, hook) {
783
+ const outcome = await invokeAuthenticate(ctx, hook);
784
+ if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
785
+ if (outcome.status === "threw") {
786
+ const refusal = upgradeRefusalFor(outcome.error);
787
+ if (refusal) return { ok: false, refusal };
788
+ }
789
+ sendRefusal(res, abort, outcome);
790
+ return { ok: false, refusal: void 0 };
791
+ }
792
+
793
+ // src/server/db-route.ts
794
+ function readHeaders(req) {
795
+ const headers = {};
796
+ req.forEach((key, value) => {
797
+ headers[key] = value;
798
+ });
799
+ return headers;
800
+ }
801
+ function wrapDbRoute(deps, handler) {
802
+ const { authenticateHook, cors, maxBodyBytes } = deps;
803
+ return (res, req) => {
804
+ const dbId = req.getParameter(0) ?? "";
805
+ const method = req.getMethod();
806
+ const path = req.getUrl();
807
+ if (cors) {
808
+ writeCorsOrigin(res, cors, req.getHeader("origin"));
809
+ }
810
+ const abort = initAbortHandler(res);
811
+ const bodyPromise = readBody(res, maxBodyBytes, abort);
812
+ if (!authenticateHook) {
813
+ bodyPromise.then(async (rawBody) => {
814
+ if (!abort.claim()) return;
815
+ try {
816
+ await handler(res, dbId, rawBody, abort, void 0);
817
+ } catch {
818
+ if (!abort.aborted) {
819
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
820
+ }
821
+ }
822
+ }).catch(() => {
823
+ });
824
+ return;
825
+ }
826
+ const ctx = {
827
+ headers: readHeaders(req),
828
+ method,
829
+ path,
830
+ databaseId: dbId,
831
+ remoteAddress: decodeRemoteAddress(res)
832
+ };
833
+ const hookPromise = runAuthenticate(res, abort, ctx, authenticateHook);
834
+ Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, authenticated]) => {
835
+ if (!authenticated.ok || !abort.claim()) return;
836
+ try {
837
+ await handler(res, dbId, rawBody, abort, authenticated.identity);
838
+ } catch {
839
+ if (!abort.aborted) {
840
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
841
+ }
842
+ }
843
+ }).catch(() => {
844
+ });
845
+ };
846
+ }
847
+ function wrapDbGetRoute(deps, handler) {
848
+ const { authenticateHook, cors } = deps;
849
+ return (res, req) => {
850
+ const dbId = req.getParameter(0) ?? "";
851
+ const method = req.getMethod();
852
+ const path = req.getUrl();
853
+ if (cors) {
854
+ writeCorsOrigin(res, cors, req.getHeader("origin"));
855
+ }
856
+ const ctx = {
857
+ headers: readHeaders(req),
858
+ method,
859
+ path,
860
+ databaseId: dbId,
861
+ remoteAddress: decodeRemoteAddress(res)
862
+ };
863
+ const abort = initAbortHandler(res);
864
+ const run = async () => {
865
+ if (authenticateHook && !(await runAuthenticate(res, abort, ctx, authenticateHook)).ok) return;
866
+ if (!abort.claim()) return;
867
+ try {
868
+ await handler(res, dbId, ctx, abort);
869
+ } catch {
870
+ if (!abort.aborted) {
871
+ sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
872
+ }
873
+ }
874
+ };
875
+ run().catch(() => {
876
+ });
877
+ };
878
+ }
879
+
475
880
  // src/server/health.ts
476
881
  function handleLiveness() {
477
882
  const payload = JSON.stringify({ status: "ok" });
@@ -881,204 +1286,49 @@ function toChangesResponse(result) {
881
1286
  return { applied: result.applied, skipped: result.skipped, conflicts: result.conflicts };
882
1287
  }
883
1288
 
884
- // src/server/wire-rows.ts
885
- async function queryWireRows(target, sql, params, options) {
886
- if (target.queryForWire) {
887
- return target.queryForWire(sql, params, options);
888
- }
889
- return encodeTaggedValues(await target.query(sql, params, options));
890
- }
891
-
892
- // src/server/http-handler.ts
893
- function decodeBatchParams(res, raw) {
894
- const decoded = [];
895
- for (const entry of raw) {
896
- const result = decodeBoundParams(entry, "paramsBatch");
897
- if (!result.ok) {
898
- sendError(res, 400, "INVALID_REQUEST", result.message);
899
- return null;
900
- }
901
- decoded.push(result.value);
902
- }
903
- return decoded;
904
- }
905
- function handleQuery(sirannon, resolveTarget) {
906
- return async (res, dbId, rawBody, abort) => {
907
- const body = parseBody(res, rawBody);
908
- if (!body) return;
909
- if (!body.sql || typeof body.sql !== "string") {
910
- sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
911
- return;
912
- }
913
- const readConcern = parseReadConcern(res, body.readConcern);
914
- if (!readConcern.ok) return;
915
- const params = decodeBoundParams(body.params, "params");
916
- if (!params.ok) {
917
- sendError(res, 400, "INVALID_REQUEST", params.message);
918
- return;
919
- }
920
- const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
921
- if (!target) return;
922
- try {
923
- const rows = await queryWireRows(
924
- target,
925
- body.sql,
926
- params.value,
927
- readConcern.value ? { readConcern: readConcern.value } : void 0
928
- );
929
- if (abort.aborted) return;
930
- sendJson(res, { rows });
931
- } catch (err) {
932
- sendCaughtError(res, abort, err);
933
- }
934
- };
935
- }
936
- function handleExecute(sirannon, resolveTarget) {
937
- return async (res, dbId, rawBody, abort) => {
938
- const body = parseBody(res, rawBody);
939
- if (!body) return;
940
- if (!body.sql || typeof body.sql !== "string") {
941
- sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
942
- return;
943
- }
944
- const writeConcern = parseWriteConcern(res, body.writeConcern);
945
- if (!writeConcern.ok) return;
946
- const params = decodeBoundParams(body.params, "params");
947
- if (!params.ok) {
948
- sendError(res, 400, "INVALID_REQUEST", params.message);
949
- return;
950
- }
951
- const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
952
- if (!target) return;
1289
+ // src/server/http-changes.ts
1290
+ var MAX_DEVICE_CLOCK_AHEAD_MS = 3e5;
1291
+ function changeClockRefusal(batch, nowMs) {
1292
+ for (const [index, change] of batch.changes.entries()) {
1293
+ let wallMs;
953
1294
  try {
954
- const result = await target.execute(
955
- body.sql,
956
- params.value,
957
- writeConcern.value ? { writeConcern: writeConcern.value } : void 0
958
- );
959
- if (abort.aborted) return;
960
- sendJson(res, toExecuteResponse(result));
961
- } catch (err) {
962
- sendCaughtError(res, abort, err);
963
- }
964
- };
965
- }
966
- function handleTransaction(sirannon, resolveTarget) {
967
- return async (res, dbId, rawBody, abort) => {
968
- const body = parseBody(res, rawBody);
969
- if (!body) return;
970
- const statementsError = transactionStatementsValidationError(body.statements);
971
- if (statementsError !== null) {
972
- sendError(res, 400, "INVALID_REQUEST", statementsError);
973
- return;
1295
+ wallMs = HLC.decode(change.hlc).wallMs;
1296
+ } catch {
1297
+ wallMs = Number.NaN;
974
1298
  }
975
- const writeConcern = parseWriteConcern(res, body.writeConcern);
976
- if (!writeConcern.ok) return;
977
- const statements = [];
978
- for (const stmt of body.statements) {
979
- const params = decodeBoundParams(stmt.params, "params");
980
- if (!params.ok) {
981
- sendError(res, 400, "INVALID_REQUEST", params.message);
982
- return;
983
- }
984
- statements.push({ sql: stmt.sql, params: params.value });
1299
+ if (!Number.isFinite(wallMs)) {
1300
+ return { code: "INVALID_REQUEST", message: `changes[${index}].hlc is not a valid timestamp` };
985
1301
  }
986
- const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
987
- if (!target) return;
988
- const txOptions = writeConcern.value ? { writeConcern: writeConcern.value } : void 0;
989
- try {
990
- const results = target.executeTransaction ? await target.executeTransaction(statements, txOptions) : await target.transaction(async (tx) => {
991
- const txResults = [];
992
- for (const stmt of statements) {
993
- txResults.push(await tx.execute(stmt.sql, stmt.params));
994
- }
995
- return txResults;
996
- }, txOptions);
997
- if (abort.aborted) return;
998
- sendJson(res, {
999
- results: results.map(toExecuteResponse)
1000
- });
1001
- } catch (err) {
1002
- sendCaughtError(res, abort, err);
1302
+ if (wallMs > nowMs + MAX_DEVICE_CLOCK_AHEAD_MS) {
1303
+ return {
1304
+ code: "DEVICE_CLOCK_AHEAD",
1305
+ message: `changes[${index}] is stamped more than ${MAX_DEVICE_CLOCK_AHEAD_MS} ms ahead of the server clock; correct the device clock`
1306
+ };
1003
1307
  }
1004
- };
1308
+ }
1309
+ return null;
1005
1310
  }
1006
- function handleBatch(sirannon, resolveTarget) {
1007
- return async (res, dbId, rawBody, abort) => {
1008
- const body = parseBody(res, rawBody);
1009
- if (!body) return;
1010
- if (!body.sql || typeof body.sql !== "string") {
1011
- sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
1012
- return;
1013
- }
1014
- const paramsBatchError = paramsBatchValidationError(body.paramsBatch);
1015
- if (paramsBatchError !== null) {
1016
- sendError(res, 400, "INVALID_REQUEST", paramsBatchError);
1017
- return;
1018
- }
1019
- const writeConcern = parseWriteConcern(res, body.writeConcern);
1020
- if (!writeConcern.ok) return;
1021
- const paramsBatch = decodeBatchParams(res, body.paramsBatch);
1022
- if (paramsBatch === null) return;
1023
- const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
1024
- if (!target) return;
1025
- try {
1026
- const results = await target.transaction(
1027
- async (tx) => tx.executeBatch(body.sql, paramsBatch),
1028
- writeConcern.value ? { writeConcern: writeConcern.value } : void 0
1029
- );
1030
- if (abort.aborted) return;
1031
- sendJson(res, {
1032
- results: results.map(toExecuteResponse)
1033
- });
1034
- } catch (err) {
1035
- sendCaughtError(res, abort, err);
1036
- }
1037
- };
1311
+ async function refuseUngrantedPush(sirannon, databaseId, batch, identity) {
1312
+ if (!sirannon.hookRegistry.has("beforePush")) return;
1313
+ const tables = new Set(batch.changes.map((change) => change.table));
1314
+ for (const table of tables) {
1315
+ const ctx = { databaseId, table, deviceId: batch.sourceNodeId, identity };
1316
+ await sirannon.hookRegistry.invoke("beforePush", ctx);
1317
+ }
1038
1318
  }
1039
- function handleLoad(sirannon, resolveTarget) {
1040
- return async (res, dbId, rawBody, abort) => {
1041
- const body = parseBody(res, rawBody);
1042
- if (!body) return;
1043
- if (!body.sql || typeof body.sql !== "string") {
1044
- sendError(res, 400, "INVALID_REQUEST", 'Field "sql" is required and must be a string');
1045
- return;
1046
- }
1047
- const paramsBatchError = paramsBatchValidationError(body.paramsBatch);
1048
- if (paramsBatchError !== null) {
1049
- sendError(res, 400, "INVALID_REQUEST", paramsBatchError);
1050
- return;
1051
- }
1052
- const durabilityError = loadDurabilityValidationError(body.durability);
1053
- if (durabilityError !== null) {
1054
- sendError(res, 400, "INVALID_REQUEST", durabilityError);
1055
- return;
1056
- }
1057
- const checkpointError = loadCheckpointValidationError(body.checkpoint);
1058
- if (checkpointError !== null) {
1059
- sendError(res, 400, "INVALID_REQUEST", checkpointError);
1060
- return;
1061
- }
1062
- const paramsBatch = decodeBatchParams(res, body.paramsBatch);
1063
- if (paramsBatch === null) return;
1064
- const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
1065
- if (!target) return;
1066
- const bulkLoad = target.bulkLoad;
1067
- if (typeof bulkLoad !== "function") {
1068
- sendError(res, 501, "BULK_LOAD_UNSUPPORTED", "The execution target for this database does not support bulk load");
1069
- return;
1070
- }
1071
- try {
1072
- const summary = await bulkLoad.call(target, body.sql, paramsBatch, toBulkLoadOptions(body));
1073
- if (abort.aborted) return;
1074
- sendJson(res, summary);
1075
- } catch (err) {
1076
- sendCaughtError(res, abort, err);
1077
- }
1078
- };
1319
+ async function refuseSchemaMismatch(res, abort, target, deviceVersion) {
1320
+ const appliedMigrations = target.appliedMigrations;
1321
+ if (typeof appliedMigrations !== "function") return false;
1322
+ const serverVersion = highestMigrationVersion(await appliedMigrations.call(target));
1323
+ const refusal = schemaVersionGateRefusal(deviceVersion, serverVersion);
1324
+ if (refusal === null) return false;
1325
+ if (!abort.aborted) {
1326
+ sendError(res, 409, refusal.code, refusal.message, { serverVersion });
1327
+ }
1328
+ return true;
1079
1329
  }
1080
1330
  function handleChanges(sirannon, resolveTarget) {
1081
- return async (res, dbId, rawBody, abort) => {
1331
+ return async (res, dbId, rawBody, abort, identity) => {
1082
1332
  const body = parseBody(res, rawBody);
1083
1333
  if (!body) return;
1084
1334
  const batchError = syncBatchValidationError(body.batch);
@@ -1093,33 +1343,26 @@ function handleChanges(sirannon, resolveTarget) {
1093
1343
  }
1094
1344
  const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
1095
1345
  if (!target) return;
1096
- const appliedMigrations = target.appliedMigrations;
1097
- if (typeof appliedMigrations === "function") {
1098
- try {
1099
- const serverVersion = highestMigrationVersion(await appliedMigrations.call(target));
1100
- const refusal = schemaVersionGateRefusal(body.schemaVersion ?? 0, serverVersion);
1101
- if (refusal !== null) {
1102
- if (abort.aborted) return;
1103
- sendError(res, 409, refusal.code, refusal.message, { serverVersion });
1104
- return;
1105
- }
1106
- } catch (err) {
1107
- sendCaughtError(res, abort, err);
1346
+ try {
1347
+ const batch = decodeSyncBatch(body.batch);
1348
+ await refuseUngrantedPush(sirannon, dbId, batch, identity);
1349
+ const clockRefusal = changeClockRefusal(batch, Date.now());
1350
+ if (clockRefusal !== null) {
1351
+ sendError(res, 400, clockRefusal.code, clockRefusal.message);
1108
1352
  return;
1109
1353
  }
1110
- }
1111
- const applyChanges = target.applyChanges;
1112
- if (typeof applyChanges !== "function") {
1113
- sendError(
1114
- res,
1115
- 501,
1116
- "SYNC_UNSUPPORTED",
1117
- "The execution target for this database does not support applying sync changes"
1118
- );
1119
- return;
1120
- }
1121
- try {
1122
- const result = await applyChanges.call(target, decodeSyncBatch(body.batch));
1354
+ if (await refuseSchemaMismatch(res, abort, target, body.schemaVersion ?? 0)) return;
1355
+ const applyChanges = target.applyChanges;
1356
+ if (typeof applyChanges !== "function") {
1357
+ sendError(
1358
+ res,
1359
+ 501,
1360
+ "SYNC_UNSUPPORTED",
1361
+ "The execution target for this database does not support applying sync changes"
1362
+ );
1363
+ return;
1364
+ }
1365
+ const result = await applyChanges.call(target, batch);
1123
1366
  if (abort.aborted) return;
1124
1367
  sendJson(res, toChangesResponse(result));
1125
1368
  } catch (err) {
@@ -1127,47 +1370,6 @@ function handleChanges(sirannon, resolveTarget) {
1127
1370
  }
1128
1371
  };
1129
1372
  }
1130
- function sendClusterUnavailable(res, dbId) {
1131
- sendError(res, 404, "NOT_FOUND", `Cluster status is not available for database '${dbId}'`);
1132
- }
1133
- function handleClusterStatus(getClusterStatus, authorizeClusterStatus) {
1134
- return async (res, dbId, ctx, abort) => {
1135
- if (!getClusterStatus || !authorizeClusterStatus) {
1136
- sendClusterUnavailable(res, dbId);
1137
- return;
1138
- }
1139
- let allowed;
1140
- try {
1141
- allowed = await authorizeClusterStatus(ctx);
1142
- } catch {
1143
- if (abort.aborted) return;
1144
- sendError(res, 500, "HOOK_ERROR", "authorizeClusterStatus threw an error");
1145
- return;
1146
- }
1147
- if (abort.aborted) return;
1148
- if (!allowed) {
1149
- sendClusterUnavailable(res, dbId);
1150
- return;
1151
- }
1152
- const status = getClusterStatus(dbId);
1153
- if (!status) {
1154
- sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
1155
- return;
1156
- }
1157
- sendJson(res, toClusterStatusResponse(status));
1158
- };
1159
- }
1160
- function toClusterStatusResponse(status) {
1161
- return {
1162
- ...status,
1163
- currentPrimary: status.currentPrimary ? { ...status.currentPrimary } : status.currentPrimary,
1164
- readEndpoints: status.readEndpoints?.map((endpoint) => ({
1165
- ...endpoint,
1166
- readConcerns: [...endpoint.readConcerns]
1167
- })),
1168
- primaryTerm: status.primaryTerm?.toString()
1169
- };
1170
- }
1171
1373
 
1172
1374
  // src/server/http-migrations.ts
1173
1375
  function handleMigrationList(sirannon) {
@@ -1232,9 +1434,9 @@ function decodeArguments(res, raw) {
1232
1434
  }
1233
1435
  return decoded.value ?? {};
1234
1436
  }
1235
- function handleOperationQuery(sirannon, registry, resolveTarget) {
1437
+ function handleOperationQuery(sirannon, sets, resolveTarget) {
1236
1438
  return async (res, dbId, name, identity, rawBody, abort) => {
1237
- const operation = findRead(registry, dbId, name);
1439
+ const operation = findRead(sets, dbId, name);
1238
1440
  if (isRefusal(operation)) {
1239
1441
  sendError(res, operation.status, operation.code, operation.message);
1240
1442
  return;
@@ -1273,9 +1475,9 @@ function handleOperationQuery(sirannon, registry, resolveTarget) {
1273
1475
  }
1274
1476
  };
1275
1477
  }
1276
- function handleOperationExecute(sirannon, registry, resolveTarget) {
1478
+ function handleOperationExecute(sirannon, sets, resolveTarget) {
1277
1479
  return async (res, dbId, name, identity, rawBody, abort) => {
1278
- const operation = findWrite(registry, dbId, name);
1480
+ const operation = findWrite(sets, dbId, name);
1279
1481
  if (isRefusal(operation)) {
1280
1482
  sendError(res, operation.status, operation.code, operation.message);
1281
1483
  return;
@@ -1514,6 +1716,14 @@ function assertBackupRestoreAuthenticated(acceptBackupRestore, hasAuthenticate)
1514
1716
  );
1515
1717
  }
1516
1718
  }
1719
+ function assertDeviceSyncAuthenticated(acceptDeviceSync, hasAuthenticate) {
1720
+ if (acceptDeviceSync && !hasAuthenticate) {
1721
+ throw new SirannonError(
1722
+ "ServerOptions.acceptDeviceSync opens a route that writes rows into every table of a database, so it needs an authenticate hook to name the caller",
1723
+ "INVALID_DEVICE_SYNC"
1724
+ );
1725
+ }
1726
+ }
1517
1727
  function resolveMaxBodyBytes(value) {
1518
1728
  if (value === void 0) return DEFAULT_MAX_BODY_BYTES;
1519
1729
  if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
@@ -1553,83 +1763,6 @@ function resolveWsBackpressure(value, maxBodyBytes) {
1553
1763
  return resolved;
1554
1764
  }
1555
1765
 
1556
- // src/core/ws-handshake.ts
1557
- var SIRANNON_WS_SUBPROTOCOL = "sirannon.v1";
1558
- var WS_CLOSE_UNAUTHENTICATED = 4401;
1559
- var WS_CLOSE_FORBIDDEN = 4403;
1560
- function selectSubprotocol(header) {
1561
- const offered = header.split(",").map((value) => value.trim()).filter((value) => value.length > 0);
1562
- if (offered.length === 0) return { ok: true, protocol: "" };
1563
- if (offered.includes(SIRANNON_WS_SUBPROTOCOL)) return { ok: true, protocol: SIRANNON_WS_SUBPROTOCOL };
1564
- return { ok: false };
1565
- }
1566
-
1567
- // src/server/request-hook.ts
1568
- function decodeRemoteAddress(res) {
1569
- return Buffer.from(res.getRemoteAddressAsText()).toString();
1570
- }
1571
- var CLOSE_REASON_MAX_BYTES = 123;
1572
- 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.";
1573
- function looksLikeRefusal(value) {
1574
- if (typeof value !== "object" || value === null) return false;
1575
- const candidate = value;
1576
- return typeof candidate.status === "number" && typeof candidate.code === "string" && typeof candidate.message === "string";
1577
- }
1578
- async function invokeAuthenticate(ctx, hook) {
1579
- try {
1580
- const identity = await hook(ctx);
1581
- if (looksLikeRefusal(identity)) return { status: "returned-refusal" };
1582
- return { status: "identity", identity };
1583
- } catch (error) {
1584
- return { status: "threw", error };
1585
- }
1586
- }
1587
- function sendRefusal(res, abort, outcome) {
1588
- if (!abort.claim()) return;
1589
- if (outcome.status === "returned-refusal") {
1590
- sendError(res, 500, "HOOK_ERROR", RETURNED_REFUSAL_MESSAGE);
1591
- return;
1592
- }
1593
- if (outcome.status === "threw" && outcome.error instanceof SirannonError) {
1594
- sendCaughtError(res, abort, outcome.error);
1595
- return;
1596
- }
1597
- sendError(res, 500, "HOOK_ERROR", "authenticate hook threw an error");
1598
- }
1599
- function truncateCloseReason(reason) {
1600
- if (Buffer.byteLength(reason, "utf-8") <= CLOSE_REASON_MAX_BYTES) return reason;
1601
- let truncated = reason.slice(0, CLOSE_REASON_MAX_BYTES);
1602
- while (truncated.length > 0 && Buffer.byteLength(truncated, "utf-8") > CLOSE_REASON_MAX_BYTES) {
1603
- truncated = truncated.slice(0, -1);
1604
- }
1605
- return truncated;
1606
- }
1607
- function upgradeRefusalFor(error) {
1608
- if (!(error instanceof SirannonError)) return void 0;
1609
- const status = httpStatusForError(error);
1610
- if (status !== 401 && status !== 403) return void 0;
1611
- return {
1612
- code: status === 403 ? WS_CLOSE_FORBIDDEN : WS_CLOSE_UNAUTHENTICATED,
1613
- reason: truncateCloseReason(`${error.code}: ${error.message}`)
1614
- };
1615
- }
1616
- async function runAuthenticate(res, abort, ctx, hook) {
1617
- const outcome = await invokeAuthenticate(ctx, hook);
1618
- if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
1619
- sendRefusal(res, abort, outcome);
1620
- return { ok: false };
1621
- }
1622
- async function authenticateUpgrade(res, abort, ctx, hook) {
1623
- const outcome = await invokeAuthenticate(ctx, hook);
1624
- if (outcome.status === "identity") return { ok: true, identity: outcome.identity };
1625
- if (outcome.status === "threw") {
1626
- const refusal = upgradeRefusalFor(outcome.error);
1627
- if (refusal) return { ok: false, refusal };
1628
- }
1629
- sendRefusal(res, abort, outcome);
1630
- return { ok: false, refusal: void 0 };
1631
- }
1632
-
1633
1766
  // src/server/operation-route.ts
1634
1767
  function decodePathSegment(value) {
1635
1768
  try {
@@ -1685,37 +1818,33 @@ function wrapOperationRoute(deps, handler) {
1685
1818
  };
1686
1819
  }
1687
1820
 
1821
+ // src/server/uws-loader.ts
1822
+ var UWEBSOCKETS_DEPENDENCY = {
1823
+ packageName: "uWebSockets.js",
1824
+ neededBy: "The Sirannon server",
1825
+ installCommand: 'pnpm add -E "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.69.0"',
1826
+ code: "SERVER_DEPENDENCY_MISSING"
1827
+ };
1828
+ async function loadUWebSockets() {
1829
+ try {
1830
+ return await import('uWebSockets.js');
1831
+ } catch (err) {
1832
+ throw optionalDependencyError(UWEBSOCKETS_DEPENDENCY, err);
1833
+ }
1834
+ }
1835
+
1688
1836
  // src/server/device-cursors.ts
1689
- var DEFAULT_DEVICE_CURSOR_RETENTION_MS = 30 * 24 * 36e5;
1690
1837
  async function upsertDeviceAck(conn, deviceId, seq) {
1691
1838
  await ensureDeviceCursorsTable(conn);
1692
1839
  await upsertDeviceCursor(conn, deviceId, seq, Date.now() / 1e3);
1693
1840
  }
1694
- function evictStaleDeviceCursors(conn, retentionMs) {
1695
- return deleteDeviceCursorsUpdatedBefore(conn, Date.now() / 1e3 - retentionMs / 1e3);
1696
- }
1697
- async function effectiveMinDeviceCursor(conn, retentionMs, changesTable = CHANGES_TABLE) {
1698
- if (!await selectTableExists(conn, DEVICE_CURSORS_TABLE)) return null;
1699
- await evictStaleDeviceCursors(conn, retentionMs);
1700
- const cursors = await selectDeviceCursors(conn);
1701
- if (cursors.length === 0) return null;
1702
- const nextForeignStmt = await conn.prepare(selectMinForeignChangeSeqSql(changesTable));
1703
- const maxSeq = await selectMaxChangeSeq(conn, changesTable);
1704
- let min = null;
1705
- for (const cursor of cursors) {
1706
- const acked = cursor.ackedSeq;
1707
- const foreignRow = await nextForeignStmt.get(acked.toString(), cursor.deviceId);
1708
- const nextForeign = foreignRow?.seq === null || foreignRow?.seq === void 0 ? null : BigInt(foreignRow.seq);
1709
- const effective = nextForeign === null ? maxSeq : nextForeign - 1n;
1710
- if (min === null || effective < min) {
1711
- min = effective;
1712
- }
1713
- }
1714
- return min;
1715
- }
1716
1841
 
1717
1842
  // src/server/ws-ack.ts
1718
1843
  async function handleAckMessage(deps, conn, state, msg, id) {
1844
+ if (!deps.acceptDeviceSync) {
1845
+ deps.sendError(conn, id, "DEVICE_SYNC_NOT_ACCEPTED", DEVICE_SYNC_NOT_ACCEPTED_MESSAGE);
1846
+ return;
1847
+ }
1719
1848
  if (!isValidDeviceId(msg.deviceId)) {
1720
1849
  deps.sendError(conn, id, "INVALID_MESSAGE", '"deviceId" must be a 32-hex device id');
1721
1850
  return;
@@ -1725,13 +1854,21 @@ async function handleAckMessage(deps, conn, state, msg, id) {
1725
1854
  return;
1726
1855
  }
1727
1856
  const deviceId = msg.deviceId;
1857
+ const streams = [...state.deviceStreams.values()].filter((stream) => stream.deviceId === deviceId);
1858
+ if (streams.length === 0) {
1859
+ deps.sendError(
1860
+ conn,
1861
+ id,
1862
+ "DEVICE_NOT_SUBSCRIBED",
1863
+ "An acknowledgement names a device this connection holds no subscription for"
1864
+ );
1865
+ return;
1866
+ }
1728
1867
  const seq = BigInt(msg.seq);
1729
1868
  try {
1730
1869
  await state.database.runCdcMaintenance((writer) => upsertDeviceAck(writer, deviceId, seq));
1731
- for (const stream of state.deviceStreams.values()) {
1732
- if (stream.deviceId === deviceId) {
1733
- stream.onAck(seq);
1734
- }
1870
+ for (const stream of streams) {
1871
+ stream.onAck(seq);
1735
1872
  }
1736
1873
  deps.sendResult(conn, id, { acked: true, seq: seq.toString() });
1737
1874
  } catch (err) {
@@ -1742,17 +1879,24 @@ async function handleAckMessage(deps, conn, state, msg, id) {
1742
1879
  // src/server/ws-cdc.ts
1743
1880
  var DEFAULT_POLL_INTERVAL_MS = 50;
1744
1881
  var CLEANUP_INTERVAL_TICKS = 100;
1882
+ function toChangeRetentionOptions(options) {
1883
+ return {
1884
+ ...options?.cdcRetentionMs === void 0 ? {} : { cdcRetention: options.cdcRetentionMs },
1885
+ ...options?.deviceCursorRetentionMs === void 0 ? {} : { deviceCursorRetention: options.deviceCursorRetentionMs },
1886
+ ...options?.maxChangesHeldForDevice === void 0 ? {} : { maxChangesHeldForDevice: options.maxChangesHeldForDevice }
1887
+ };
1888
+ }
1745
1889
  var CdcContextRegistry = class {
1746
1890
  sirannon;
1747
1891
  retentionMs;
1748
- deviceCursorRetentionMs;
1892
+ serverDeviceRetention;
1749
1893
  contexts = /* @__PURE__ */ new Map();
1750
1894
  pending = /* @__PURE__ */ new Map();
1751
1895
  closed = false;
1752
- constructor(sirannon, retentionMs, deviceCursorRetentionMs) {
1896
+ constructor(sirannon, serverRetention = {}) {
1753
1897
  this.sirannon = sirannon;
1754
- this.retentionMs = retentionMs;
1755
- this.deviceCursorRetentionMs = deviceCursorRetentionMs ?? DEFAULT_DEVICE_CURSOR_RETENTION_MS;
1898
+ this.retentionMs = serverRetention.cdcRetention;
1899
+ this.serverDeviceRetention = serverRetention;
1756
1900
  }
1757
1901
  async ensure(databaseId, database) {
1758
1902
  const existing = this.contexts.get(databaseId);
@@ -1800,8 +1944,10 @@ var CdcContextRegistry = class {
1800
1944
  }
1801
1945
  async createContext(database) {
1802
1946
  const cdcConn = await this.sirannon.driver.open(database.path, { walMode: true });
1803
- const tracker = new ChangeTracker(this.retentionMs === void 0 ? void 0 : { retention: this.retentionMs });
1804
- const manager = new SubscriptionManager();
1947
+ const retention = database.changeRetention.cdcRetention ?? this.retentionMs;
1948
+ const deviceRetention = resolveDeviceRetentionPolicy(database.changeRetention, this.serverDeviceRetention);
1949
+ const tracker = new ChangeTracker(retention === void 0 ? void 0 : { retention });
1950
+ const manager = new SubscriptionManager(this.sirannon.metrics?.observeDispatch(database.id));
1805
1951
  let epoch = "";
1806
1952
  try {
1807
1953
  await tracker.advanceToLatest(cdcConn);
@@ -1840,12 +1986,7 @@ var CdcContextRegistry = class {
1840
1986
  if (tickCount >= CLEANUP_INTERVAL_TICKS) {
1841
1987
  tickCount = 0;
1842
1988
  await database.runCdcMaintenance(async (writer) => {
1843
- const minCursor = await effectiveMinDeviceCursor(writer, this.deviceCursorRetentionMs);
1844
- if (minCursor === null) {
1845
- tracker.clearPruneBoundary();
1846
- } else {
1847
- tracker.setPruneBoundary(minCursor);
1848
- }
1989
+ await applyDeviceCursorBoundary(writer, tracker, deviceRetention);
1849
1990
  await tracker.cleanup(writer);
1850
1991
  }).catch(() => {
1851
1992
  });
@@ -2015,14 +2156,15 @@ var DeviceChangeStream = class {
2015
2156
  this.deps.packer?.clear();
2016
2157
  }
2017
2158
  /**
2018
- * Moves a socket that stopped writing on its own. uWebSockets holds the
2019
- * remainder of a partial write until the next write on that socket and
2020
- * reports no drain in the meantime, so a stream that has queued everything
2021
- * it has would leave the last changes sitting there. A small buffered count
2022
- * that has not moved since the previous poll is that state, and a control
2023
- * frame carries the remainder out. A socket holding enough to count as
2024
- * congested is draining under its own flow control and takes nothing extra,
2025
- * because another frame there would cross the backpressure limit.
2159
+ * Sends a ping on a socket whose buffered bytes have stopped flushing.
2160
+ * uWebSockets keeps the rest of a partial write queued until the next write
2161
+ * on that socket and fires no drain event in the meantime, so the last
2162
+ * changes of a stream with nothing more to send could stay in the buffer.
2163
+ * When the buffered count is above zero, unchanged since the previous poll
2164
+ * batch, and below the congestion threshold, this method sends a ping so that
2165
+ * uWebSockets flushes the rest. It skips a congested socket, because that
2166
+ * socket drains under its own flow control and one more frame could push it
2167
+ * past the backpressure limit.
2026
2168
  */
2027
2169
  nudgeStalledSocket() {
2028
2170
  const buffered = this.deps.socketBuffered();
@@ -2067,25 +2209,24 @@ var DeviceChangeStream = class {
2067
2209
  return true;
2068
2210
  }
2069
2211
  /**
2070
- * Waits for the socket only while it holds enough to be worth waiting for.
2071
- * uWebSockets reports `buffered` for a send it could not finish in one go,
2072
- * including one that queued a few hundred bytes of a frame it otherwise
2073
- * sent, and it notifies a drain only when the socket becomes writable
2074
- * again. A stream that parked on the outcome alone would wait on a small
2075
- * remainder that the next send would have flushed, and the wake-up would
2076
- * never come.
2212
+ * Makes the stream wait for a drain event only while the socket is congested.
2213
+ * uWebSockets reports `buffered` for any send that it cannot finish at once,
2214
+ * including one that leaves only a few hundred bytes of a frame queued, and
2215
+ * it fires a drain event only when the socket becomes writable again. If the
2216
+ * stream waited on every `buffered` outcome, it could wait on a small
2217
+ * remainder for a drain event that never fires.
2077
2218
  */
2078
2219
  parkOnSocket() {
2079
2220
  this.socketWait = this.deps.socketCongested();
2080
2221
  }
2081
2222
  /**
2082
- * Abandons the in-flight stream state and falls back to the change log.
2083
- * The grouper's held event was never queued and its seq is above both
2084
- * watermarks, so dropping it loses nothing. The catch-up read resumes
2085
- * after the last position fully settled: `highestQueuedSeq` is the last
2086
- * event that reached the socket, and `processedSeq` may run further ahead
2087
- * of it over a span whose every event was suppressed, so a long run of
2088
- * the device's own echoes is not re-read on every pause.
2223
+ * Discards the in-flight stream state and switches to reading the change log.
2224
+ * The event that the grouper holds back is still unqueued, and its seq is
2225
+ * above both watermarks, so discarding it loses no change. The catch-up read
2226
+ * starts after the later of two positions. `highestQueuedSeq` is the last
2227
+ * event that the stream queued on the socket, and `processedSeq` can be further
2228
+ * ahead when the transform suppresses every event after that one, so the
2229
+ * stream skips a long sequence of the device's own echoes on every pause.
2089
2230
  */
2090
2231
  enterCatchup() {
2091
2232
  const flushed = this.deps.packer?.flush() ?? "queued";
@@ -2106,10 +2247,10 @@ var DeviceChangeStream = class {
2106
2247
  }
2107
2248
  }
2108
2249
  /**
2109
- * Starts the catch-up read, or records that one is owed when a read is
2110
- * already running. A read that hands back the stream mid-pass, because the
2111
- * socket reported backpressure it no longer holds, would otherwise leave
2112
- * the stream in catch-up with nothing scheduled to resume it.
2250
+ * Starts the catch-up read, or sets a flag for another read when one is
2251
+ * already in progress. Without the flag, a read that stops partway because
2252
+ * of backpressure that clears before the read ends could leave the stream in
2253
+ * catch-up mode with no read scheduled to resume it.
2113
2254
  */
2114
2255
  requestDrain() {
2115
2256
  if (this.draining) {
@@ -2119,24 +2260,25 @@ var DeviceChangeStream = class {
2119
2260
  void this.drain();
2120
2261
  }
2121
2262
  /**
2122
- * A closed window stops the catch-up read only where the window may pace
2123
- * the stream: anywhere under `perEvent` pacing, and at a transaction
2124
- * boundary under `perTransaction` pacing. Mid-transaction the read keeps
2125
- * going, because a `perTransaction` device acknowledges only applied
2126
- * whole transactions, and holding the rest of an open transaction back
2127
- * would wait for an acknowledgement that can never arrive.
2263
+ * Returns true when the catch-up read must pause for a full delivery window,
2264
+ * which happens at any event under `perEvent` pacing and only at a
2265
+ * transaction boundary under `perTransaction` pacing. A `perTransaction`
2266
+ * device acknowledges only whole transactions that it has applied, so if
2267
+ * the stream held back the rest of an open transaction, it would wait for
2268
+ * an acknowledgement that the device never sends.
2128
2269
  */
2129
2270
  readGateClosed() {
2130
2271
  if (!this.windowClosed()) return false;
2131
2272
  return this.deps.pacing === "perEvent" || !this.midTransaction;
2132
2273
  }
2133
2274
  /**
2134
- * Sizes the next catch-up read from what the socket accepted this time.
2135
- * A read that ends at a full socket discards everything past the event
2136
- * that stopped it, so a device on a congested link would otherwise decode
2137
- * the same rows on every pause. Doubling the accepted count keeps a
2138
- * caught-up device reading whole batches while a paced one reads close to
2139
- * what it can send.
2275
+ * Sets the size of the next catch-up read to twice the number of events that
2276
+ * the stream offered from the last read, between 32 and 1,000. When a read stops
2277
+ * at a full socket, the stream discards every event after the one where it
2278
+ * stopped, so with a larger fixed batch the server would decode the same rows again on
2279
+ * every pause for a device on a congested link. Doubling the count gives a
2280
+ * caught-up device full batches and a paced device batches close to the
2281
+ * number of events that its socket accepts.
2140
2282
  */
2141
2283
  resizeCatchupBatch(accepted) {
2142
2284
  const doubled = accepted * 2;
@@ -2209,11 +2351,12 @@ var DeviceChangeStream = class {
2209
2351
  }
2210
2352
  }
2211
2353
  /**
2212
- * Rejoins the live feed. Runs synchronously right after a log read so that no
2213
- * poller tick can dispatch between the caught-up check and the mode flip.
2214
- * The grouper survives the transition: the event it holds is released by
2215
- * the boundary flush when the poller stopped at a transaction boundary,
2216
- * exactly as a resuming ordinary subscription does.
2354
+ * Switches the stream back to the live feed. This method is synchronous and
2355
+ * follows a log read directly, so that no poller tick can dispatch events
2356
+ * between the caught-up check and the switch to `live` mode. The stream keeps its grouper across the switch,
2357
+ * and when the poller's last batch ends at a transaction boundary, the
2358
+ * boundary flush releases the event that the grouper holds, as it does for
2359
+ * an ordinary subscription that resumes.
2217
2360
  */
2218
2361
  goLive() {
2219
2362
  this.mode = "live";
@@ -2697,6 +2840,14 @@ async function subscribeDevice(deps, conn, state, request) {
2697
2840
  // src/server/ws-subscribe.ts
2698
2841
  var MAX_SUBSCRIBED_TABLES = 500;
2699
2842
  async function handleSubscribeMessage(deps, conn, state, msg, id) {
2843
+ if (msg.deviceId !== void 0 && !deps.acceptDeviceSync) {
2844
+ deps.sendError(conn, id, "DEVICE_SYNC_NOT_ACCEPTED", DEVICE_SYNC_NOT_ACCEPTED_MESSAGE);
2845
+ return;
2846
+ }
2847
+ if (msg.deviceId === void 0 && !deps.acceptSql && !deps.hasSubscribeHook()) {
2848
+ deps.sendError(conn, id, "SQL_NOT_ACCEPTED", TABLE_SUBSCRIPTION_NOT_ACCEPTED_MESSAGE);
2849
+ return;
2850
+ }
2700
2851
  const tables = readTableSet(msg);
2701
2852
  if (typeof tables === "string") {
2702
2853
  deps.sendError(conn, id, "INVALID_MESSAGE", tables);
@@ -2763,7 +2914,13 @@ async function handleSubscribeMessage(deps, conn, state, msg, id) {
2763
2914
  if (deps.hasSubscribeHook()) {
2764
2915
  for (const table2 of tables) {
2765
2916
  try {
2766
- await deps.beforeSubscribe({ databaseId: state.databaseId, table: table2, filter, identity: state.identity });
2917
+ await deps.beforeSubscribe({
2918
+ databaseId: state.databaseId,
2919
+ table: table2,
2920
+ filter,
2921
+ identity: state.identity,
2922
+ deviceId
2923
+ });
2767
2924
  } catch (err) {
2768
2925
  deps.sendSirannonError(conn, id, err);
2769
2926
  return;
@@ -2944,17 +3101,19 @@ var WSHandler = class {
2944
3101
  maxUnacknowledgedChanges;
2945
3102
  socketResumeBytes;
2946
3103
  acceptSql;
3104
+ acceptDeviceSync;
2947
3105
  operations;
2948
3106
  closed = false;
2949
3107
  constructor(sirannon, options) {
2950
3108
  this.sirannon = sirannon;
2951
3109
  this.acceptSql = options?.acceptSql === true;
2952
- this.operations = createOperationSource(options?.operations);
3110
+ this.acceptDeviceSync = options?.acceptDeviceSync === true;
3111
+ this.operations = createOperationSource(options?.operations, options?.sharedOperations);
2953
3112
  this.maxPayloadLength = options?.maxPayloadLength ?? DEFAULT_MAX_PAYLOAD_LENGTH;
2954
3113
  this.maxUnacknowledgedChanges = options?.maxUnacknowledgedChanges ?? DEFAULT_MAX_UNACKNOWLEDGED_CHANGES;
2955
3114
  this.socketResumeBytes = Math.ceil((options?.maxBackpressureBytes ?? DEFAULT_MAX_BACKPRESSURE_BYTES) / 2);
2956
3115
  this.resolveExecutionTarget = options?.resolveExecutionTarget;
2957
- this.cdc = new CdcContextRegistry(sirannon, options?.cdcRetentionMs, options?.deviceCursorRetentionMs);
3116
+ this.cdc = new CdcContextRegistry(sirannon, toChangeRetentionOptions(options));
2958
3117
  }
2959
3118
  async handleOpen(conn, databaseId, identity) {
2960
3119
  if (this.closed) {
@@ -2997,9 +3156,9 @@ var WSHandler = class {
2997
3156
  });
2998
3157
  }
2999
3158
  /**
3000
- * Tears down a connection whose outbound buffer overflowed. Closing lets the
3001
- * client reject in-flight requests and reconnect; guarding on `overloaded`
3002
- * keeps a burst of dropped frames from repeatedly re-closing the socket.
3159
+ * Closes a connection after the server drops a frame on it at the backpressure limit,
3160
+ * so the client can reject its pending requests and reconnect. The `overloaded` flag
3161
+ * makes the handler close the socket once, however many frames the server drops in a burst.
3003
3162
  */
3004
3163
  handleOverload(conn) {
3005
3164
  const state = this.connections.get(conn);
@@ -3137,6 +3296,8 @@ var WSHandler = class {
3137
3296
  subscribeDeps() {
3138
3297
  return {
3139
3298
  cdc: this.cdc,
3299
+ acceptSql: this.acceptSql,
3300
+ acceptDeviceSync: this.acceptDeviceSync,
3140
3301
  maxUnacknowledgedChanges: this.maxUnacknowledgedChanges,
3141
3302
  socketResumeBytes: this.socketResumeBytes,
3142
3303
  hasSubscribeHook: () => this.sirannon.hookRegistry.has("beforeSubscribe"),
@@ -3359,11 +3520,21 @@ function registerWebSocketRoute(options) {
3359
3520
 
3360
3521
  // src/server/server.ts
3361
3522
  var SQL_ROUTES = ["/db/:id/query", "/db/:id/execute", "/db/:id/transaction", "/db/:id/batch", "/db/:id/load"];
3523
+ var DEVICE_SYNC_ROUTES = [
3524
+ "/db/:id/changes",
3525
+ "/db/:id/migrations",
3526
+ "/db/:id/snapshot",
3527
+ "/db/:id/snapshot/page"
3528
+ ];
3362
3529
  function refuseSql(res) {
3363
3530
  sendError(res, 403, "SQL_NOT_ACCEPTED", SQL_NOT_ACCEPTED_MESSAGE);
3364
3531
  }
3532
+ function refuseDeviceSync(res) {
3533
+ sendError(res, 403, "DEVICE_SYNC_NOT_ACCEPTED", DEVICE_SYNC_NOT_ACCEPTED_MESSAGE);
3534
+ }
3365
3535
  var SirannonServer = class {
3366
- app;
3536
+ runtime = null;
3537
+ runtimeLoad = null;
3367
3538
  listenSocket = null;
3368
3539
  host;
3369
3540
  port;
@@ -3371,6 +3542,7 @@ var SirannonServer = class {
3371
3542
  authenticateHook;
3372
3543
  acceptSql;
3373
3544
  acceptBackupRestore;
3545
+ acceptDeviceSync;
3374
3546
  restoreRuns = new BackupRestoreRuns();
3375
3547
  operations;
3376
3548
  registryDigest;
@@ -3391,8 +3563,10 @@ var SirannonServer = class {
3391
3563
  this.acceptSql = options?.acceptSql === true;
3392
3564
  this.acceptBackupRestore = options?.acceptBackupRestore === true;
3393
3565
  assertBackupRestoreAuthenticated(this.acceptBackupRestore, this.authenticateHook !== void 0);
3394
- this.operations = options?.operations;
3395
- this.registryDigest = operationRegistryDigest(options?.operations);
3566
+ this.acceptDeviceSync = options?.acceptDeviceSync === true;
3567
+ assertDeviceSyncAuthenticated(this.acceptDeviceSync, this.authenticateHook !== void 0);
3568
+ this.operations = { registry: options?.operations, shared: options?.sharedOperations };
3569
+ this.registryDigest = operationRegistryDigest(this.operations.registry, this.operations.shared);
3396
3570
  this.resolveExecutionTarget = options?.resolveExecutionTarget;
3397
3571
  this.getReplicationStatus = options?.getReplicationStatus;
3398
3572
  this.getClusterStatus = options?.getClusterStatus;
@@ -3405,21 +3579,24 @@ var SirannonServer = class {
3405
3579
  maxBackpressureBytes: this.maxWsBackpressureBytes,
3406
3580
  cdcRetentionMs: options?.cdcRetentionMs,
3407
3581
  deviceCursorRetentionMs: options?.deviceCursorRetentionMs,
3582
+ maxChangesHeldForDevice: options?.maxChangesHeldForDevice,
3408
3583
  maxUnacknowledgedChanges: options?.maxUnacknowledgedChanges,
3409
3584
  acceptSql: this.acceptSql,
3410
- operations: options?.operations
3585
+ acceptDeviceSync: this.acceptDeviceSync,
3586
+ operations: this.operations.registry,
3587
+ sharedOperations: this.operations.shared
3411
3588
  });
3412
- this.app = uWS.App();
3413
- this.registerRoutes();
3414
3589
  }
3415
3590
  /**
3416
- * Binds the configured host and port and starts serving.
3591
+ * Loads uWebSockets.js and starts serving on the configured host and port.
3417
3592
  *
3418
- * @throws When the port is already in use.
3593
+ * @throws A `SirannonError` with code `SERVER_DEPENDENCY_MISSING` when this process cannot load uWebSockets.js.
3594
+ * @throws An `Error` when uWebSockets.js cannot listen on the host and port, for example because the port is already in use.
3419
3595
  */
3420
- listen() {
3421
- return new Promise((resolve, reject) => {
3422
- this.app.listen(this.host, this.port, (socket) => {
3596
+ async listen() {
3597
+ const { app } = await this.loadRuntime();
3598
+ await new Promise((resolve, reject) => {
3599
+ app.listen(this.host, this.port, (socket) => {
3423
3600
  if (socket) {
3424
3601
  this.listenSocket = socket;
3425
3602
  resolve();
@@ -3430,29 +3607,47 @@ var SirannonServer = class {
3430
3607
  });
3431
3608
  }
3432
3609
  /**
3433
- * Stops serving and closes every open connection.
3610
+ * Stops listening for new connections and closes every open WebSocket connection.
3434
3611
  */
3435
3612
  async close() {
3436
3613
  try {
3437
3614
  await this.wsHandler.close();
3438
3615
  } finally {
3439
- if (this.listenSocket) {
3440
- uWS.us_listen_socket_close(this.listenSocket);
3616
+ if (this.listenSocket && this.runtime) {
3617
+ this.runtime.uws.us_listen_socket_close(this.listenSocket);
3441
3618
  this.listenSocket = null;
3442
3619
  }
3443
3620
  }
3444
3621
  }
3445
3622
  /**
3446
- * Port the server bound to, which is the resolved port when you asked for 0.
3623
+ * The port that the server listens on, which the operating system chooses when you set `port` to 0, or -1 while the server is not listening.
3447
3624
  */
3448
3625
  get listeningPort() {
3449
- if (!this.listenSocket) return -1;
3450
- return uWS.us_socket_local_port(this.listenSocket);
3626
+ if (!this.listenSocket || !this.runtime) return -1;
3627
+ return this.runtime.uws.us_socket_local_port(this.listenSocket);
3628
+ }
3629
+ loadRuntime() {
3630
+ if (!this.runtimeLoad) {
3631
+ this.runtimeLoad = loadUWebSockets().then(
3632
+ (uws) => {
3633
+ const app = uws.App();
3634
+ this.registerRoutes(app);
3635
+ const runtime = { uws, app };
3636
+ this.runtime = runtime;
3637
+ return runtime;
3638
+ },
3639
+ (err) => {
3640
+ this.runtimeLoad = null;
3641
+ throw err;
3642
+ }
3643
+ );
3644
+ }
3645
+ return this.runtimeLoad;
3451
3646
  }
3452
- registerRoutes() {
3647
+ registerRoutes(app) {
3453
3648
  if (this.cors) {
3454
3649
  const cors = this.cors;
3455
- this.app.options("/*", (res, req) => {
3650
+ app.options("/*", (res, req) => {
3456
3651
  const requestOrigin = req.getHeader("origin");
3457
3652
  res.cork(() => {
3458
3653
  res.writeStatus("204 No Content");
@@ -3461,68 +3656,74 @@ var SirannonServer = class {
3461
3656
  });
3462
3657
  });
3463
3658
  }
3464
- this.app.get(
3659
+ app.get(
3465
3660
  "/capabilities",
3466
- this.withCors(handleCapabilities({ registryDigest: this.registryDigest, acceptSql: this.acceptSql }))
3661
+ this.withCors(
3662
+ handleCapabilities({
3663
+ registryDigest: this.registryDigest,
3664
+ acceptSql: this.acceptSql,
3665
+ acceptDeviceSync: this.acceptDeviceSync
3666
+ })
3667
+ )
3467
3668
  );
3468
- this.app.get("/health", this.withCors(handleLiveness()));
3469
- this.app.get("/health/ready", this.withCors(handleReadiness(this.sirannon, this.getReplicationStatus)));
3470
- this.app.get(
3669
+ app.get("/health", this.withCors(handleLiveness()));
3670
+ app.get("/health/ready", this.withCors(handleReadiness(this.sirannon, this.getReplicationStatus)));
3671
+ app.get(
3471
3672
  "/db/:id/cluster",
3472
3673
  this.wrapDbGetRoute(handleClusterStatus(this.getClusterStatus, this.authorizeClusterStatus))
3473
3674
  );
3474
3675
  if (this.acceptSql) {
3475
- this.app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
3476
- this.app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
3477
- this.app.post(
3478
- "/db/:id/transaction",
3479
- this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget))
3480
- );
3481
- this.app.post("/db/:id/batch", this.wrapDbRoute(handleBatch(this.sirannon, this.resolveExecutionTarget)));
3482
- this.app.post("/db/:id/load", this.wrapDbRoute(handleLoad(this.sirannon, this.resolveExecutionTarget)));
3676
+ app.post("/db/:id/query", this.wrapDbRoute(handleQuery(this.sirannon, this.resolveExecutionTarget)));
3677
+ app.post("/db/:id/execute", this.wrapDbRoute(handleExecute(this.sirannon, this.resolveExecutionTarget)));
3678
+ app.post("/db/:id/transaction", this.wrapDbRoute(handleTransaction(this.sirannon, this.resolveExecutionTarget)));
3679
+ app.post("/db/:id/batch", this.wrapDbRoute(handleBatch(this.sirannon, this.resolveExecutionTarget)));
3680
+ app.post("/db/:id/load", this.wrapDbRoute(handleLoad(this.sirannon, this.resolveExecutionTarget)));
3483
3681
  } else {
3484
3682
  for (const route of SQL_ROUTES) {
3485
- this.app.post(route, this.withCors(refuseSql));
3683
+ app.post(route, this.withCors(refuseSql));
3486
3684
  }
3487
3685
  }
3488
- this.app.post(
3686
+ app.post(
3489
3687
  "/db/:id/query/:name",
3490
3688
  this.wrapOperationRoute(handleOperationQuery(this.sirannon, this.operations, this.resolveExecutionTarget))
3491
3689
  );
3492
- this.app.post(
3690
+ app.post(
3493
3691
  "/db/:id/execute/:name",
3494
3692
  this.wrapOperationRoute(handleOperationExecute(this.sirannon, this.operations, this.resolveExecutionTarget))
3495
3693
  );
3496
- this.registerBackupRoutes();
3497
- this.app.post("/db/:id/changes", this.wrapDbRoute(handleChanges(this.sirannon, this.resolveExecutionTarget)));
3498
- this.app.post("/db/:id/migrations", this.wrapDbRoute(handleMigrationList(this.sirannon)));
3499
- this.app.post("/db/:id/snapshot", this.wrapDbRoute(handleSnapshotManifest(this.sirannon)));
3500
- this.app.post("/db/:id/snapshot/page", this.wrapDbRoute(handleSnapshotPage(this.sirannon)));
3501
- this.registerWebSocketRoute();
3502
- this.app.any("/*", (res) => {
3503
- sendError(res, 404, "NOT_FOUND", "Route not found");
3504
- });
3505
- }
3506
- registerBackupRoutes() {
3507
- this.app.post("/db/:id/backup", this.wrapDbRoute(handleBackupTrigger(this.sirannon)));
3508
- this.app.get("/db/:id/backup", this.wrapDbGetRoute(handleBackupStatus(this.sirannon)));
3509
- this.app.get("/db/:id/backup/chain", this.wrapDbGetRoute(handleBackupChain(this.sirannon)));
3510
- this.app.post("/db/:id/backup/verify", this.wrapDbRoute(handleBackupVerify(this.sirannon)));
3511
- this.app.post("/db/:id/backup/safe-to-delete", this.wrapDbRoute(handleBackupSafeToDelete(this.sirannon)));
3512
- this.app.post(
3513
- "/db/:id/backup/restore",
3514
- this.wrapDbRoute(handleBackupRestore(this.sirannon, this.acceptBackupRestore, this.restoreRuns))
3515
- );
3516
- this.app.get("/db/:id/backup/restore", this.wrapDbGetRoute(handleBackupRestoreStatus(this.restoreRuns)));
3517
- }
3518
- registerWebSocketRoute() {
3694
+ this.registerBackupRoutes(app);
3695
+ if (this.acceptDeviceSync) {
3696
+ app.post("/db/:id/changes", this.wrapDbRoute(handleChanges(this.sirannon, this.resolveExecutionTarget)));
3697
+ app.post("/db/:id/migrations", this.wrapDbRoute(handleMigrationList(this.sirannon)));
3698
+ app.post("/db/:id/snapshot", this.wrapDbRoute(handleSnapshotManifest(this.sirannon)));
3699
+ app.post("/db/:id/snapshot/page", this.wrapDbRoute(handleSnapshotPage(this.sirannon)));
3700
+ } else {
3701
+ for (const route of DEVICE_SYNC_ROUTES) {
3702
+ app.post(route, this.withCors(refuseDeviceSync));
3703
+ }
3704
+ }
3519
3705
  registerWebSocketRoute({
3520
- app: this.app,
3706
+ app,
3521
3707
  wsHandler: this.wsHandler,
3522
3708
  authenticateHook: this.authenticateHook,
3523
3709
  maxBodyBytes: this.maxBodyBytes,
3524
3710
  maxBackpressureBytes: this.maxWsBackpressureBytes
3525
3711
  });
3712
+ app.any("/*", (res) => {
3713
+ sendError(res, 404, "NOT_FOUND", "Route not found");
3714
+ });
3715
+ }
3716
+ registerBackupRoutes(app) {
3717
+ app.post("/db/:id/backup", this.wrapDbRoute(handleBackupTrigger(this.sirannon)));
3718
+ app.get("/db/:id/backup", this.wrapDbGetRoute(handleBackupStatus(this.sirannon)));
3719
+ app.get("/db/:id/backup/chain", this.wrapDbGetRoute(handleBackupChain(this.sirannon)));
3720
+ app.post("/db/:id/backup/verify", this.wrapDbRoute(handleBackupVerify(this.sirannon)));
3721
+ app.post("/db/:id/backup/safe-to-delete", this.wrapDbRoute(handleBackupSafeToDelete(this.sirannon)));
3722
+ app.post(
3723
+ "/db/:id/backup/restore",
3724
+ this.wrapDbRoute(handleBackupRestore(this.sirannon, this.acceptBackupRestore, this.restoreRuns))
3725
+ );
3726
+ app.get("/db/:id/backup/restore", this.wrapDbGetRoute(handleBackupRestoreStatus(this.restoreRuns)));
3526
3727
  }
3527
3728
  withCors(handler) {
3528
3729
  if (!this.cors) return handler;
@@ -3532,105 +3733,21 @@ var SirannonServer = class {
3532
3733
  handler(res, req);
3533
3734
  };
3534
3735
  }
3535
- wrapDbRoute(handler) {
3536
- const authenticateHook = this.authenticateHook;
3537
- const corsHeaders = this.cors;
3538
- const maxBody = this.maxBodyBytes;
3539
- return (res, req) => {
3540
- const dbId = req.getParameter(0) ?? "";
3541
- const method = req.getMethod();
3542
- const path = req.getUrl();
3543
- if (corsHeaders) {
3544
- writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
3545
- }
3546
- const abort = initAbortHandler(res);
3547
- const bodyPromise = readBody(res, maxBody, abort);
3548
- if (!authenticateHook) {
3549
- bodyPromise.then(async (rawBody) => {
3550
- if (!abort.claim()) return;
3551
- try {
3552
- await handler(res, dbId, rawBody, abort, void 0);
3553
- } catch {
3554
- if (!abort.aborted) {
3555
- sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
3556
- }
3557
- }
3558
- }).catch(() => {
3559
- });
3560
- return;
3561
- }
3562
- const headers = {};
3563
- req.forEach((key, value) => {
3564
- headers[key] = value;
3565
- });
3566
- const remoteAddress = decodeRemoteAddress(res);
3567
- const ctx = {
3568
- headers,
3569
- method,
3570
- path,
3571
- databaseId: dbId,
3572
- remoteAddress
3573
- };
3574
- const hookPromise = runAuthenticate(res, abort, ctx, authenticateHook);
3575
- Promise.all([bodyPromise, hookPromise]).then(async ([rawBody, authenticated]) => {
3576
- if (!authenticated.ok || !abort.claim()) return;
3577
- try {
3578
- await handler(res, dbId, rawBody, abort, authenticated.identity);
3579
- } catch {
3580
- if (!abort.aborted) {
3581
- sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
3582
- }
3583
- }
3584
- }).catch(() => {
3585
- });
3736
+ routeDeps() {
3737
+ return {
3738
+ cors: this.cors,
3739
+ maxBodyBytes: this.maxBodyBytes,
3740
+ authenticateHook: this.authenticateHook
3586
3741
  };
3587
3742
  }
3588
- wrapOperationRoute(handler) {
3589
- return wrapOperationRoute(
3590
- {
3591
- cors: this.cors,
3592
- maxBodyBytes: this.maxBodyBytes,
3593
- authenticateHook: this.authenticateHook
3594
- },
3595
- handler
3596
- );
3743
+ wrapDbRoute(handler) {
3744
+ return wrapDbRoute(this.routeDeps(), handler);
3597
3745
  }
3598
3746
  wrapDbGetRoute(handler) {
3599
- const authenticateHook = this.authenticateHook;
3600
- const corsHeaders = this.cors;
3601
- return (res, req) => {
3602
- const dbId = req.getParameter(0) ?? "";
3603
- const method = req.getMethod();
3604
- const path = req.getUrl();
3605
- if (corsHeaders) {
3606
- writeCorsOrigin(res, corsHeaders, req.getHeader("origin"));
3607
- }
3608
- const headers = {};
3609
- req.forEach((key, value) => {
3610
- headers[key] = value;
3611
- });
3612
- const ctx = {
3613
- headers,
3614
- method,
3615
- path,
3616
- databaseId: dbId,
3617
- remoteAddress: decodeRemoteAddress(res)
3618
- };
3619
- const abort = initAbortHandler(res);
3620
- const run = async () => {
3621
- if (authenticateHook && !(await runAuthenticate(res, abort, ctx, authenticateHook)).ok) return;
3622
- if (!abort.claim()) return;
3623
- try {
3624
- await handler(res, dbId, ctx, abort);
3625
- } catch {
3626
- if (!abort.aborted) {
3627
- sendError(res, 500, "INTERNAL_ERROR", "An unexpected error occurred");
3628
- }
3629
- }
3630
- };
3631
- run().catch(() => {
3632
- });
3633
- };
3747
+ return wrapDbGetRoute(this.routeDeps(), handler);
3748
+ }
3749
+ wrapOperationRoute(handler) {
3750
+ return wrapOperationRoute(this.routeDeps(), handler);
3634
3751
  }
3635
3752
  };
3636
3753
  function createServer(sirannon, options) {