@delali/sirannon-db 0.3.5 → 0.3.6-next.78

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/dist/backup/index.d.ts +5 -5
  2. package/dist/backup/index.mjs +5 -3
  3. package/dist/backup-scheduler/index.d.ts +2 -2
  4. package/dist/backup-scheduler/index.mjs +2 -2
  5. package/dist/chunk-22TYGH7J.mjs +55 -0
  6. package/dist/{chunk-SB3MIFLG.mjs → chunk-2ZQOZFX7.mjs} +29 -6
  7. package/dist/{chunk-UIDQGPJY.mjs → chunk-4O7WYZHN.mjs} +38 -3
  8. package/dist/{chunk-ZFFUVYYE.mjs → chunk-5DKWHT6K.mjs} +109 -1
  9. package/dist/chunk-5O4J6LQQ.mjs +35 -0
  10. package/dist/{chunk-CZL4D3BM.mjs → chunk-6CABU54O.mjs} +2 -2
  11. package/dist/{chunk-JAQ2I4OT.mjs → chunk-A5ULJVK7.mjs} +11 -11
  12. package/dist/{chunk-RLBKZZ24.mjs → chunk-AC7TKWNH.mjs} +1 -0
  13. package/dist/chunk-BKO4SCEC.mjs +99 -0
  14. package/dist/{chunk-KOTBP4AW.mjs → chunk-C7E4H6UN.mjs} +24 -4
  15. package/dist/{chunk-G4AG3ESO.mjs → chunk-FBA7P4ZA.mjs} +114 -149
  16. package/dist/{chunk-DKML5425.mjs → chunk-GBVB6WSC.mjs} +2 -2
  17. package/dist/chunk-I4JY5QPA.mjs +51 -0
  18. package/dist/{chunk-L6EX6K27.mjs → chunk-MO4APBYG.mjs} +54 -13
  19. package/dist/{chunk-RPWBYASV.mjs → chunk-MTOQ6QIS.mjs} +1 -1
  20. package/dist/chunk-MX77PGEN.mjs +60 -0
  21. package/dist/{chunk-BTUD6XQO.mjs → chunk-N3MHZV3B.mjs} +11 -29
  22. package/dist/{chunk-UHUDPQFI.mjs → chunk-OJXV4L3N.mjs} +1 -3
  23. package/dist/chunk-QF7WF7RJ.mjs +34 -0
  24. package/dist/chunk-RLBV4YTB.mjs +22 -0
  25. package/dist/{chunk-ISDT4RBD.mjs → chunk-ULUZUBON.mjs} +1 -1
  26. package/dist/{chunk-AP4367HK.mjs → chunk-VGRAAQX3.mjs} +4 -4
  27. package/dist/chunk-W74HFQFD.mjs +153 -0
  28. package/dist/chunk-Y4SP7CRF.mjs +9 -0
  29. package/dist/{chunk-JFVEGN7J.mjs → chunk-ZAUHAJ4G.mjs} +2 -1
  30. package/dist/{chunk-DDIHBUEN.mjs → chunk-ZWW53O5P.mjs} +19 -7
  31. package/dist/client/index.d.ts +8 -8
  32. package/dist/client/topology.d.ts +4 -4
  33. package/dist/{client-base-Doaqwrx3.d.ts → client-base-DJZbSY55.d.ts} +2 -2
  34. package/dist/{client-streams-MKPE4MXW.mjs → client-streams-F7IG3PTU.mjs} +13 -16
  35. package/dist/core/index.d.ts +48 -12
  36. package/dist/core/index.mjs +1812 -107
  37. package/dist/core/writer-worker.mjs +28 -4
  38. package/dist/{database-fz-8CJBP.d.ts → database-BZ96ZzAx.d.ts} +411 -184
  39. package/dist/{database-backup-BwTNiBm2.d.ts → database-backup-BhM8yJ1A.d.ts} +32 -2
  40. package/dist/driver/better-sqlite3.d.ts +11 -1
  41. package/dist/driver/better-sqlite3.mjs +212 -23
  42. package/dist/driver/bun.d.ts +1 -1
  43. package/dist/driver/bun.mjs +15 -22
  44. package/dist/driver/expo.d.ts +1 -1
  45. package/dist/driver/expo.mjs +185 -16
  46. package/dist/driver/node.d.ts +1 -1
  47. package/dist/driver/node.mjs +15 -62
  48. package/dist/driver/wa-sqlite.d.ts +1 -1
  49. package/dist/driver/wa-sqlite.mjs +14 -153
  50. package/dist/{change-tracker-Dpo99EI-.d.ts → errors-DLMAGQFb.d.ts} +200 -2
  51. package/dist/file-migrations/index.d.ts +1 -1
  52. package/dist/peer-refusal-RR37UOLV.mjs +1 -0
  53. package/dist/{protocol-DZSYSv0S.d.ts → protocol-DE10taas.d.ts} +1 -1
  54. package/dist/replication/coordinator/etcd.mjs +1 -1
  55. package/dist/replication/index.d.ts +7 -7
  56. package/dist/replication/index.mjs +66 -9
  57. package/dist/{restore-options-CcMukOLO.d.ts → restore-options-B6HV_5XT.d.ts} +6 -1
  58. package/dist/server/index.d.ts +10 -10
  59. package/dist/server/index.mjs +506 -350
  60. package/dist/{server-options-B9CsAZp5.d.ts → server-options-Bhdlvehe.d.ts} +14 -1
  61. package/dist/{server-streams-SBYCHWWM.mjs → server-streams-Q2AZAW3N.mjs} +6 -30
  62. package/dist/{sirannon-D0CjDULa.d.ts → sirannon-Ba1o2HUQ.d.ts} +5 -5
  63. package/dist/transport/grpc.d.ts +15 -4
  64. package/dist/transport/grpc.mjs +34 -37
  65. package/dist/transport/memory.d.ts +4 -3
  66. package/dist/transport/memory.mjs +8 -26
  67. package/dist/{types-DTmi7pXp.d.ts → types-BWyv_N18.d.ts} +189 -1
  68. package/dist/{types-BlZ9GG1a.d.ts → types-CDnlkVRK.d.ts} +8 -2
  69. package/package.json +17 -9
  70. package/dist/chunk-DBN57KVR.mjs +0 -171
  71. package/dist/chunk-W67A4H2E.mjs +0 -23
  72. package/dist/chunk-YW7IKQ7T.mjs +0 -62
@@ -1,22 +1,24 @@
1
- import { restoreBackup, MAX_RESTORE_BATCH_SIZE } from '../chunk-L6EX6K27.mjs';
2
- import { tablesInFkOrder, dumpSchema, dumpTablePages } from '../chunk-DKML5425.mjs';
3
- import { resolveDeviceRetentionPolicy, ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, applyDeviceCursorBoundary, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription } from '../chunk-SB3MIFLG.mjs';
4
- import '../chunk-YW7IKQ7T.mjs';
5
- import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, updateDeviceCursor, selectMaxAssignedChangeSeq, MAX_CHANGE_SEQ, HLC, selectMaxChangeSeq, canonicaliseForChecksum, selectDeviceCursorExists, IDENTIFIER_RE } from '../chunk-RPWBYASV.mjs';
1
+ import { restoreBackup, MAX_RESTORE_BATCH_SIZE } from '../chunk-MO4APBYG.mjs';
2
+ import { tablesInFkOrder, dumpSchema, dumpTablePages } from '../chunk-GBVB6WSC.mjs';
3
+ import { resolveDeviceRetentionPolicy, ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription, applyDeviceCursorBoundary } from '../chunk-2ZQOZFX7.mjs';
4
+ import { wipeEncryptionKey, resolveEncryptionKey } from '../chunk-W74HFQFD.mjs';
5
+ import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, updateDeviceCursor, selectMaxAssignedChangeSeq, MAX_CHANGE_SEQ, HLC, selectMaxChangeSeq, canonicaliseForChecksum, selectDeviceCursorExists, IDENTIFIER_RE } from '../chunk-MTOQ6QIS.mjs';
6
+ import { object, nullable, pipe, string, minLength, maxLength, safeParse } from '../chunk-5DKWHT6K.mjs';
6
7
  import { BatchValidationError } from '../chunk-BLMRNSUG.mjs';
7
- import { MIGRATIONS_TABLE, decodeTaggedValues, encodeTaggedValues, encodeWireRowsInPlace, DEVICE_CURSORS_TABLE, CHANGES_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-JFVEGN7J.mjs';
8
+ import { MIGRATIONS_TABLE, decodeTaggedValues, encodeTaggedValues, encodeWireRowsInPlace, DEVICE_CURSORS_TABLE, CHANGES_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-ZAUHAJ4G.mjs';
8
9
  import { createOperationSource, operationRegistryDigest, findRead, isRefusal, resolveArguments, findWrite } from '../chunk-KB4OLJAD.mjs';
9
10
  import '../chunk-SEL4A4WT.mjs';
10
11
  import '../chunk-GVCNMPOS.mjs';
11
12
  import { optionalDependencyError } from '../chunk-WOMADPM4.mjs';
12
- import { discardStagedChain } from '../chunk-UHUDPQFI.mjs';
13
+ import { discardStagedChain } from '../chunk-OJXV4L3N.mjs';
13
14
  import '../chunk-QGVRRKB2.mjs';
14
15
  import '../chunk-AHCMO4YL.mjs';
15
16
  import '../chunk-BUWJH3D3.mjs';
16
- import '../chunk-YCMB6GR4.mjs';
17
- import '../chunk-CZL4D3BM.mjs';
17
+ import '../chunk-6CABU54O.mjs';
18
18
  import '../chunk-BGJ5JVYL.mjs';
19
19
  import '../chunk-HO2XTXVX.mjs';
20
+ import '../chunk-YCMB6GR4.mjs';
21
+ import '../chunk-5O4J6LQQ.mjs';
20
22
  import { SQLITE_USER_VERSION_MAX } from '../chunk-BZWDPF4G.mjs';
21
23
  import { SirannonError, WriteOverloadError } from '../chunk-KINDFBQP.mjs';
22
24
  import { createHash } from 'crypto';
@@ -152,10 +154,23 @@ var SERVER_FAULT_MESSAGES = /* @__PURE__ */ new Map([
152
154
  ],
153
155
  ["SERVER_DEPENDENCY_MISSING", OPTIONAL_PACKAGE_MISSING],
154
156
  ["TRANSPORT_DEPENDENCY_MISSING", OPTIONAL_PACKAGE_MISSING],
155
- ["COORDINATOR_DEPENDENCY_MISSING", OPTIONAL_PACKAGE_MISSING]
157
+ ["COORDINATOR_DEPENDENCY_MISSING", OPTIONAL_PACKAGE_MISSING],
158
+ ["ENCRYPTION_UNSUPPORTED", "The server's SQLite driver cannot encrypt a database"],
159
+ ["INVALID_ENCRYPTION_KEY", "The encryption key for this database is not a 32-byte key"],
160
+ ["ENCRYPTION_KEY_UNAVAILABLE", "The server could not obtain the encryption key for this database"],
161
+ ["ENCRYPTION_KEY_MISSING", "This database is encrypted, and the server opened it without a key"],
162
+ ["ENCRYPTION_KEY_REJECTED", "The key that the server holds does not open this database"],
163
+ ["DATABASE_NOT_ENCRYPTED", "This database is not encrypted, and the server opened it with a key"],
164
+ ["ENCRYPTED_PAGE_CORRUPT", "A page of this encrypted database failed its integrity check"],
165
+ ["ENCRYPTION_REQUIRED", "An encrypted node sends no data to a node that holds a plaintext database"],
166
+ ["ENCRYPTION_CHANGE_IN_PROGRESS", "A key change is running on this database"],
167
+ ["ENCRYPTION_CHANGE_ABORTED", "The key change stopped and left the database under its current key"]
156
168
  ]);
157
169
  function clientErrorMessage(err) {
158
- return SERVER_FAULT_MESSAGES.get(err.code) ?? err.message;
170
+ return clientMessageFor(err);
171
+ }
172
+ function clientMessageFor(error) {
173
+ return SERVER_FAULT_MESSAGES.get(error.code) ?? error.message;
159
174
  }
160
175
  function sharesErrorDetails(err) {
161
176
  return !SERVER_FAULT_MESSAGES.has(err.code);
@@ -477,8 +492,12 @@ function httpStatusForError(err) {
477
492
  case "HOOK_DENIED":
478
493
  return 403;
479
494
  case "BACKUP_CHAIN_BROKEN":
495
+ case "REPLICATION_ACTIVE":
496
+ case "ENCRYPTION_CHANGE_IN_PROGRESS":
497
+ case "ENCRYPTION_REQUIRED":
480
498
  return 409;
481
499
  case "BACKUP_UNSUPPORTED":
500
+ case "ENCRYPTION_UNSUPPORTED":
482
501
  return 501;
483
502
  case "BACKUP_DESTINATION_ERROR":
484
503
  return 502;
@@ -492,6 +511,7 @@ function httpStatusForError(err) {
492
511
  case "NODE_DRAINING":
493
512
  case "UNSAFE_RECOVERY_REQUIRED":
494
513
  case "WRITE_OVERLOADED":
514
+ case "ENCRYPTION_KEY_UNAVAILABLE":
495
515
  return 503;
496
516
  default:
497
517
  return 500;
@@ -1158,232 +1178,6 @@ function readinessStatusForReplication(replication, current) {
1158
1178
  return current;
1159
1179
  }
1160
1180
 
1161
- // src/server/backup-protocol.ts
1162
- var BACKUP_RESTORE_NOT_ACCEPTED_MESSAGE = "This server does not restore databases over the wire; turn on acceptBackupRestore to open the route";
1163
- function wholeNumberError(field, value, atLeast, atMost) {
1164
- if (value === void 0) return null;
1165
- if (typeof value !== "number" || !Number.isInteger(value) || value < atLeast) {
1166
- return `Field "${field}" must be a whole number of at least ${atLeast}`;
1167
- }
1168
- if (atMost !== void 0 && value > atMost) {
1169
- return `Field "${field}" must be a whole number of at most ${atMost}`;
1170
- }
1171
- return null;
1172
- }
1173
- function verifyNameValidationError(value) {
1174
- if (typeof value !== "string" || value.length === 0) {
1175
- return 'Field "name" is required and must be the name a chain record states';
1176
- }
1177
- return null;
1178
- }
1179
- function restorableFromValidationError(value) {
1180
- return wholeNumberError("restorableFrom", value, 0);
1181
- }
1182
- function restoreRequestValidationError(body) {
1183
- return wholeNumberError("moment", body.moment, 0) ?? wholeNumberError("batchSize", body.batchSize, 1, MAX_RESTORE_BATCH_SIZE);
1184
- }
1185
-
1186
- // src/server/http-backups.ts
1187
- function parseOptionalBody(res, rawBody) {
1188
- if (rawBody.length === 0) return {};
1189
- return parseBody(res, rawBody);
1190
- }
1191
- async function resolveBackupDatabase(res, abort, sirannon, dbId) {
1192
- let database;
1193
- try {
1194
- database = await sirannon.resolve(dbId);
1195
- } catch (err) {
1196
- sendCaughtError(res, abort, err);
1197
- return null;
1198
- }
1199
- if (abort.aborted) return null;
1200
- if (!database) {
1201
- sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
1202
- return null;
1203
- }
1204
- return database;
1205
- }
1206
- function refusedOnTheFirstTick(turn) {
1207
- const settled = turn.then(
1208
- () => void 0,
1209
- (err) => err ?? new SirannonError("The backup turn failed", "BACKUP_ERROR")
1210
- );
1211
- const tick = new Promise((resolve) => {
1212
- setImmediate(() => resolve(void 0));
1213
- });
1214
- return Promise.race([settled, tick]);
1215
- }
1216
- function readStatus(res, abort, database) {
1217
- try {
1218
- return database.backupStatus();
1219
- } catch (err) {
1220
- sendCaughtError(res, abort, err);
1221
- return null;
1222
- }
1223
- }
1224
- function handleBackupTrigger(sirannon) {
1225
- return async (res, dbId, rawBody, abort) => {
1226
- if (!parseOptionalBody(res, rawBody)) return;
1227
- const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1228
- if (!database) return;
1229
- if (!readStatus(res, abort, database)) return;
1230
- const refusal = await refusedOnTheFirstTick(database.captureBackupChanges());
1231
- if (refusal !== void 0) {
1232
- sendCaughtError(res, abort, refusal);
1233
- return;
1234
- }
1235
- if (abort.aborted) return;
1236
- sendJson(res, { started: true }, "202 Accepted");
1237
- };
1238
- }
1239
- function handleBackupStatus(sirannon) {
1240
- return async (res, dbId, _ctx, abort) => {
1241
- const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1242
- if (!database) return;
1243
- const status = readStatus(res, abort, database);
1244
- if (!status || abort.aborted) return;
1245
- sendJson(res, status);
1246
- };
1247
- }
1248
- function handleBackupChain(sirannon) {
1249
- return async (res, dbId, _ctx, abort) => {
1250
- const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1251
- if (!database) return;
1252
- try {
1253
- const chains = await database.backupChain();
1254
- if (abort.aborted) return;
1255
- const body = { chains };
1256
- sendJson(res, body);
1257
- } catch (err) {
1258
- sendCaughtError(res, abort, err);
1259
- }
1260
- };
1261
- }
1262
- function handleBackupVerify(sirannon) {
1263
- return async (res, dbId, rawBody, abort) => {
1264
- const body = parseOptionalBody(res, rawBody);
1265
- if (!body) return;
1266
- const name = body.name;
1267
- const nameError = verifyNameValidationError(name);
1268
- if (nameError !== null || typeof name !== "string") {
1269
- sendError(res, 400, "INVALID_REQUEST", nameError ?? 'Field "name" must be a string');
1270
- return;
1271
- }
1272
- const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1273
- if (!database) return;
1274
- try {
1275
- const result = await database.verifyBackup(name);
1276
- if (abort.aborted) return;
1277
- sendJson(res, result);
1278
- } catch (err) {
1279
- sendCaughtError(res, abort, err);
1280
- }
1281
- };
1282
- }
1283
- function handleBackupSafeToDelete(sirannon) {
1284
- return async (res, dbId, rawBody, abort) => {
1285
- const body = parseOptionalBody(res, rawBody);
1286
- if (!body) return;
1287
- const restorableFromError = restorableFromValidationError(body.restorableFrom);
1288
- if (restorableFromError !== null) {
1289
- sendError(res, 400, "INVALID_REQUEST", restorableFromError);
1290
- return;
1291
- }
1292
- const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1293
- if (!database) return;
1294
- try {
1295
- const records = await database.backupPiecesSafeToDelete(
1296
- body.restorableFrom === void 0 ? void 0 : { restorableFrom: body.restorableFrom }
1297
- );
1298
- if (abort.aborted) return;
1299
- const answer = { records };
1300
- sendJson(res, answer);
1301
- } catch (err) {
1302
- sendCaughtError(res, abort, err);
1303
- }
1304
- };
1305
- }
1306
-
1307
- // src/server/http-backup-restore.ts
1308
- function restoreAlreadyRunning(databaseId) {
1309
- return `A restore of database '${databaseId}' is already under way, and it keeps that file to itself until it finishes`;
1310
- }
1311
- async function rebuildDatabase(request) {
1312
- const { sirannon, runs, databaseId, location } = request;
1313
- try {
1314
- const outcome = await sirannon.withDatabaseOffline(databaseId, async (destPath) => {
1315
- await discardStagedChain(location.stagingDir);
1316
- return restoreBackup({
1317
- destination: location.destination,
1318
- chainName: location.chainName,
1319
- driver: sirannon.driver,
1320
- destPath,
1321
- replaceExisting: true,
1322
- moment: request.moment,
1323
- ...location.destinationTimeoutMs === void 0 ? {} : { destinationTimeoutMs: location.destinationTimeoutMs },
1324
- ...request.batchSize === void 0 ? {} : { batchSize: request.batchSize },
1325
- onProgress: (progress) => runs.progressed(databaseId, progress)
1326
- });
1327
- });
1328
- if (!outcome.ok) {
1329
- runs.failed(databaseId, outcome.failure);
1330
- return;
1331
- }
1332
- runs.finished(databaseId, outcome.value, outcome.reopenFailure);
1333
- } catch (err) {
1334
- runs.failed(databaseId, err);
1335
- }
1336
- }
1337
- function handleBackupRestore(sirannon, accepted, runs) {
1338
- return async (res, dbId, rawBody, abort) => {
1339
- if (!accepted) {
1340
- sendError(res, 403, "BACKUP_RESTORE_NOT_ACCEPTED", BACKUP_RESTORE_NOT_ACCEPTED_MESSAGE);
1341
- return;
1342
- }
1343
- const body = parseOptionalBody(res, rawBody);
1344
- if (!body) return;
1345
- const validationError = restoreRequestValidationError(body);
1346
- if (validationError !== null) {
1347
- sendError(res, 400, "INVALID_REQUEST", validationError);
1348
- return;
1349
- }
1350
- if (runs.read(dbId).state === "running") {
1351
- sendError(res, 409, "BACKUP_RESTORE_IN_PROGRESS", restoreAlreadyRunning(dbId));
1352
- return;
1353
- }
1354
- const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1355
- if (!database) return;
1356
- let location;
1357
- try {
1358
- location = database.backupLocation();
1359
- } catch (err) {
1360
- sendCaughtError(res, abort, err);
1361
- return;
1362
- }
1363
- const moment = body.moment === void 0 ? Date.now() : body.moment;
1364
- if (!runs.claim(dbId, moment)) {
1365
- sendError(res, 409, "BACKUP_RESTORE_IN_PROGRESS", restoreAlreadyRunning(dbId));
1366
- return;
1367
- }
1368
- void rebuildDatabase({
1369
- sirannon,
1370
- runs,
1371
- databaseId: dbId,
1372
- location,
1373
- moment,
1374
- ...body.batchSize === void 0 ? {} : { batchSize: body.batchSize }
1375
- });
1376
- if (abort.aborted) return;
1377
- sendJson(res, { started: true }, "202 Accepted");
1378
- };
1379
- }
1380
- function handleBackupRestoreStatus(runs) {
1381
- return async (res, dbId, _ctx, abort) => {
1382
- if (abort.aborted) return;
1383
- sendJson(res, runs.read(dbId));
1384
- };
1385
- }
1386
-
1387
1181
  // src/server/sync-protocol.ts
1388
1182
  var MAX_SYNC_BATCH_CHANGES = 1e3;
1389
1183
  var NODE_ID_RE = /^[0-9a-f]{32}$/;
@@ -1547,111 +1341,351 @@ function decodeSyncBatch(body) {
1547
1341
  function toChangesResponse(result) {
1548
1342
  return { applied: result.applied, skipped: result.skipped, conflicts: result.conflicts };
1549
1343
  }
1550
-
1551
- // src/server/http-changes.ts
1552
- var MAX_DEVICE_CLOCK_AHEAD_MS = 3e5;
1553
- function changeClockRefusal(batch, nowMs) {
1554
- for (const [index, change] of batch.changes.entries()) {
1555
- let wallMs;
1344
+
1345
+ // src/server/http-changes.ts
1346
+ var MAX_DEVICE_CLOCK_AHEAD_MS = 3e5;
1347
+ function changeClockRefusal(batch, nowMs) {
1348
+ for (const [index, change] of batch.changes.entries()) {
1349
+ let wallMs;
1350
+ try {
1351
+ wallMs = HLC.decode(change.hlc).wallMs;
1352
+ } catch {
1353
+ wallMs = Number.NaN;
1354
+ }
1355
+ if (!Number.isFinite(wallMs)) {
1356
+ return { code: "INVALID_REQUEST", message: `changes[${index}].hlc is not a valid timestamp` };
1357
+ }
1358
+ if (wallMs > nowMs + MAX_DEVICE_CLOCK_AHEAD_MS) {
1359
+ return {
1360
+ code: "DEVICE_CLOCK_AHEAD",
1361
+ message: `changes[${index}] is stamped more than ${MAX_DEVICE_CLOCK_AHEAD_MS} ms ahead of the server clock; correct the device clock`
1362
+ };
1363
+ }
1364
+ }
1365
+ return null;
1366
+ }
1367
+ function clockRangeOf(changes) {
1368
+ let min = changes[0].hlc;
1369
+ let max = changes[0].hlc;
1370
+ for (const change of changes) {
1371
+ if (change.hlc < min) min = change.hlc;
1372
+ if (change.hlc > max) max = change.hlc;
1373
+ }
1374
+ return { min, max };
1375
+ }
1376
+ function decodeVerifiableBatch(body) {
1377
+ let decoded;
1378
+ try {
1379
+ decoded = decodeSyncBatch(body);
1380
+ } catch {
1381
+ return null;
1382
+ }
1383
+ return { ...decoded, hlcRange: clockRangeOf(decoded.changes) };
1384
+ }
1385
+ async function refuseUngrantedPush(sirannon, databaseId, batch, identity) {
1386
+ if (!sirannon.hookRegistry.has("beforePush")) return;
1387
+ const tables = new Set(batch.changes.map((change) => change.table));
1388
+ for (const table of tables) {
1389
+ const ctx = { databaseId, table, deviceId: batch.sourceNodeId, identity };
1390
+ await sirannon.hookRegistry.invoke("beforePush", ctx);
1391
+ }
1392
+ }
1393
+ async function refuseSchemaMismatch(res, abort, target, deviceVersion) {
1394
+ const appliedMigrations = target.appliedMigrations;
1395
+ if (typeof appliedMigrations !== "function") return false;
1396
+ const serverVersion = highestMigrationVersion(await appliedMigrations.call(target));
1397
+ const refusal = schemaVersionGateRefusal(deviceVersion, serverVersion);
1398
+ if (refusal === null) return false;
1399
+ if (!abort.aborted) {
1400
+ sendError(res, 409, refusal.code, refusal.message, { serverVersion });
1401
+ }
1402
+ return true;
1403
+ }
1404
+ function handleChanges(sirannon, resolveTarget) {
1405
+ return async (res, dbId, rawBody, abort, identity) => {
1406
+ const body = parseBody(res, rawBody);
1407
+ if (!body) return;
1408
+ const batchError = syncBatchValidationError(body.batch);
1409
+ if (batchError !== null) {
1410
+ sendError(res, 400, "INVALID_REQUEST", batchError);
1411
+ return;
1412
+ }
1413
+ const schemaVersionError = schemaVersionValidationError(body.schemaVersion);
1414
+ if (schemaVersionError !== null) {
1415
+ sendError(res, 400, "INVALID_REQUEST", schemaVersionError);
1416
+ return;
1417
+ }
1418
+ const batch = decodeVerifiableBatch(body.batch);
1419
+ if (batch === null) {
1420
+ sendError(res, 400, "INVALID_REQUEST", '"batch.changes" carries a malformed tagged value');
1421
+ return;
1422
+ }
1423
+ const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
1424
+ if (!target) return;
1425
+ try {
1426
+ await refuseUngrantedPush(sirannon, dbId, batch, identity);
1427
+ const clockRefusal = changeClockRefusal(batch, Date.now());
1428
+ if (clockRefusal !== null) {
1429
+ sendError(res, 400, clockRefusal.code, clockRefusal.message);
1430
+ return;
1431
+ }
1432
+ if (await refuseSchemaMismatch(res, abort, target, body.schemaVersion ?? 0)) return;
1433
+ const applyChanges = target.applyChanges;
1434
+ if (typeof applyChanges !== "function") {
1435
+ sendError(
1436
+ res,
1437
+ 501,
1438
+ "SYNC_UNSUPPORTED",
1439
+ "The execution target for this database does not support applying sync changes"
1440
+ );
1441
+ return;
1442
+ }
1443
+ const result = await applyChanges.call(target, batch);
1444
+ if (abort.aborted) return;
1445
+ sendJson(res, toChangesResponse(result));
1446
+ } catch (err) {
1447
+ sendCaughtError(res, abort, err);
1448
+ }
1449
+ };
1450
+ }
1451
+
1452
+ // src/server/backup-protocol.ts
1453
+ var BACKUP_RESTORE_NOT_ACCEPTED_MESSAGE = "This server does not restore databases over the wire; turn on acceptBackupRestore to open the route";
1454
+ function wholeNumberError(field, value, atLeast, atMost) {
1455
+ if (value === void 0) return null;
1456
+ if (typeof value !== "number" || !Number.isInteger(value) || value < atLeast) {
1457
+ return `Field "${field}" must be a whole number of at least ${atLeast}`;
1458
+ }
1459
+ if (atMost !== void 0 && value > atMost) {
1460
+ return `Field "${field}" must be a whole number of at most ${atMost}`;
1461
+ }
1462
+ return null;
1463
+ }
1464
+ function verifyNameValidationError(value) {
1465
+ if (typeof value !== "string" || value.length === 0) {
1466
+ return 'Field "name" is required and must be the name a chain record states';
1467
+ }
1468
+ return null;
1469
+ }
1470
+ function restorableFromValidationError(value) {
1471
+ return wholeNumberError("restorableFrom", value, 0);
1472
+ }
1473
+ function restoreRequestValidationError(body) {
1474
+ return wholeNumberError("moment", body.moment, 0) ?? wholeNumberError("batchSize", body.batchSize, 1, MAX_RESTORE_BATCH_SIZE);
1475
+ }
1476
+
1477
+ // src/server/http-backups.ts
1478
+ function parseOptionalBody(res, rawBody) {
1479
+ if (rawBody.length === 0) return {};
1480
+ return parseBody(res, rawBody);
1481
+ }
1482
+ async function resolveBackupDatabase(res, abort, sirannon, dbId) {
1483
+ let database;
1484
+ try {
1485
+ database = await sirannon.resolve(dbId);
1486
+ } catch (err) {
1487
+ sendCaughtError(res, abort, err);
1488
+ return null;
1489
+ }
1490
+ if (abort.aborted) return null;
1491
+ if (!database) {
1492
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
1493
+ return null;
1494
+ }
1495
+ return database;
1496
+ }
1497
+ function refusedOnTheFirstTick(turn) {
1498
+ const settled = turn.then(
1499
+ () => void 0,
1500
+ (err) => err ?? new SirannonError("The backup turn failed", "BACKUP_ERROR")
1501
+ );
1502
+ const tick = new Promise((resolve) => {
1503
+ setImmediate(() => resolve(void 0));
1504
+ });
1505
+ return Promise.race([settled, tick]);
1506
+ }
1507
+ function readStatus(res, abort, database) {
1508
+ try {
1509
+ return database.backupStatus();
1510
+ } catch (err) {
1511
+ sendCaughtError(res, abort, err);
1512
+ return null;
1513
+ }
1514
+ }
1515
+ function handleBackupTrigger(sirannon) {
1516
+ return async (res, dbId, rawBody, abort) => {
1517
+ if (!parseOptionalBody(res, rawBody)) return;
1518
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1519
+ if (!database) return;
1520
+ if (!readStatus(res, abort, database)) return;
1521
+ const refusal = await refusedOnTheFirstTick(database.captureBackupChanges());
1522
+ if (refusal !== void 0) {
1523
+ sendCaughtError(res, abort, refusal);
1524
+ return;
1525
+ }
1526
+ if (abort.aborted) return;
1527
+ sendJson(res, { started: true }, "202 Accepted");
1528
+ };
1529
+ }
1530
+ function handleBackupStatus(sirannon) {
1531
+ return async (res, dbId, _ctx, abort) => {
1532
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1533
+ if (!database) return;
1534
+ const status = readStatus(res, abort, database);
1535
+ if (!status || abort.aborted) return;
1536
+ sendJson(res, status);
1537
+ };
1538
+ }
1539
+ function handleBackupChain(sirannon) {
1540
+ return async (res, dbId, _ctx, abort) => {
1541
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1542
+ if (!database) return;
1543
+ try {
1544
+ const chains = await database.backupChain();
1545
+ if (abort.aborted) return;
1546
+ const body = { chains };
1547
+ sendJson(res, body);
1548
+ } catch (err) {
1549
+ sendCaughtError(res, abort, err);
1550
+ }
1551
+ };
1552
+ }
1553
+ function handleBackupVerify(sirannon) {
1554
+ return async (res, dbId, rawBody, abort) => {
1555
+ const body = parseOptionalBody(res, rawBody);
1556
+ if (!body) return;
1557
+ const name = body.name;
1558
+ const nameError = verifyNameValidationError(name);
1559
+ if (nameError !== null || typeof name !== "string") {
1560
+ sendError(res, 400, "INVALID_REQUEST", nameError ?? 'Field "name" must be a string');
1561
+ return;
1562
+ }
1563
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1564
+ if (!database) return;
1556
1565
  try {
1557
- wallMs = HLC.decode(change.hlc).wallMs;
1558
- } catch {
1559
- wallMs = Number.NaN;
1566
+ const result = await database.verifyBackup(name);
1567
+ if (abort.aborted) return;
1568
+ sendJson(res, result);
1569
+ } catch (err) {
1570
+ sendCaughtError(res, abort, err);
1560
1571
  }
1561
- if (!Number.isFinite(wallMs)) {
1562
- return { code: "INVALID_REQUEST", message: `changes[${index}].hlc is not a valid timestamp` };
1572
+ };
1573
+ }
1574
+ function handleBackupSafeToDelete(sirannon) {
1575
+ return async (res, dbId, rawBody, abort) => {
1576
+ const body = parseOptionalBody(res, rawBody);
1577
+ if (!body) return;
1578
+ const restorableFromError = restorableFromValidationError(body.restorableFrom);
1579
+ if (restorableFromError !== null) {
1580
+ sendError(res, 400, "INVALID_REQUEST", restorableFromError);
1581
+ return;
1563
1582
  }
1564
- if (wallMs > nowMs + MAX_DEVICE_CLOCK_AHEAD_MS) {
1565
- return {
1566
- code: "DEVICE_CLOCK_AHEAD",
1567
- message: `changes[${index}] is stamped more than ${MAX_DEVICE_CLOCK_AHEAD_MS} ms ahead of the server clock; correct the device clock`
1568
- };
1583
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
1584
+ if (!database) return;
1585
+ try {
1586
+ const records = await database.backupPiecesSafeToDelete(
1587
+ body.restorableFrom === void 0 ? void 0 : { restorableFrom: body.restorableFrom }
1588
+ );
1589
+ if (abort.aborted) return;
1590
+ const answer = { records };
1591
+ sendJson(res, answer);
1592
+ } catch (err) {
1593
+ sendCaughtError(res, abort, err);
1569
1594
  }
1570
- }
1571
- return null;
1595
+ };
1572
1596
  }
1573
- function clockRangeOf(changes) {
1574
- let min = changes[0].hlc;
1575
- let max = changes[0].hlc;
1576
- for (const change of changes) {
1577
- if (change.hlc < min) min = change.hlc;
1578
- if (change.hlc > max) max = change.hlc;
1597
+
1598
+ // src/server/http-encryption.ts
1599
+ var MAX_KEY_REF_LENGTH = 256;
1600
+ var ENCRYPTION_CHANGE_REQUEST = object({
1601
+ keyRef: nullable(pipe(string(), minLength(1), maxLength(MAX_KEY_REF_LENGTH)))
1602
+ });
1603
+ var ENCRYPTION_CHANGE_NOT_ACCEPTED_MESSAGE = "This server does not change database keys over the wire; turn on acceptEncryptionChange to open the route";
1604
+ function resolveEncryptionChangeSettings(options) {
1605
+ const accepted = options?.acceptEncryptionChange === true;
1606
+ if (accepted && (options?.authenticate === void 0 || typeof options.encryptionKeys !== "function")) {
1607
+ throw new SirannonError(
1608
+ "ServerOptions.acceptEncryptionChange opens a route that changes the key of a running database, so it needs an authenticate hook to name the caller and an encryptionKeys function to resolve each keyRef",
1609
+ "INVALID_ENCRYPTION_CHANGE"
1610
+ );
1579
1611
  }
1580
- return { min, max };
1612
+ return { accepted, keys: options?.encryptionKeys };
1581
1613
  }
1582
- function decodeVerifiableBatch(body) {
1583
- let decoded;
1584
- try {
1585
- decoded = decodeSyncBatch(body);
1586
- } catch {
1587
- return null;
1588
- }
1589
- return { ...decoded, hlcRange: clockRangeOf(decoded.changes) };
1614
+ async function checkedKeySource(keys, databaseId, keyRef) {
1615
+ const source = () => keys(databaseId, keyRef);
1616
+ wipeEncryptionKey(await resolveEncryptionKey(source));
1617
+ return source;
1590
1618
  }
1591
- async function refuseUngrantedPush(sirannon, databaseId, batch, identity) {
1592
- if (!sirannon.hookRegistry.has("beforePush")) return;
1593
- const tables = new Set(batch.changes.map((change) => change.table));
1594
- for (const table of tables) {
1595
- const ctx = { databaseId, table, deviceId: batch.sourceNodeId, identity };
1596
- await sirannon.hookRegistry.invoke("beforePush", ctx);
1597
- }
1619
+ function errorForClient(error) {
1620
+ return { code: error.code, message: clientMessageFor(error) };
1598
1621
  }
1599
- async function refuseSchemaMismatch(res, abort, target, deviceVersion) {
1600
- const appliedMigrations = target.appliedMigrations;
1601
- if (typeof appliedMigrations !== "function") return false;
1602
- const serverVersion = highestMigrationVersion(await appliedMigrations.call(target));
1603
- const refusal = schemaVersionGateRefusal(deviceVersion, serverVersion);
1604
- if (refusal === null) return false;
1605
- if (!abort.aborted) {
1606
- sendError(res, 409, refusal.code, refusal.message, { serverVersion });
1607
- }
1608
- return true;
1622
+ function statusForClient(status) {
1623
+ const { error, reopenError } = status.change;
1624
+ return {
1625
+ ...status,
1626
+ change: {
1627
+ ...status.change,
1628
+ ...error === void 0 ? {} : { error: errorForClient(error) },
1629
+ ...reopenError === void 0 ? {} : { reopenError: errorForClient(reopenError) }
1630
+ }
1631
+ };
1609
1632
  }
1610
- function handleChanges(sirannon, resolveTarget) {
1611
- return async (res, dbId, rawBody, abort, identity) => {
1612
- const body = parseBody(res, rawBody);
1613
- if (!body) return;
1614
- const batchError = syncBatchValidationError(body.batch);
1615
- if (batchError !== null) {
1616
- sendError(res, 400, "INVALID_REQUEST", batchError);
1633
+ function handleEncryptionChange(sirannon, settings) {
1634
+ return async (res, dbId, rawBody, abort) => {
1635
+ const keys = settings.keys;
1636
+ if (!settings.accepted || keys === void 0) {
1637
+ sendError(res, 403, "ENCRYPTION_CHANGE_NOT_ACCEPTED", ENCRYPTION_CHANGE_NOT_ACCEPTED_MESSAGE);
1617
1638
  return;
1618
1639
  }
1619
- const schemaVersionError = schemaVersionValidationError(body.schemaVersion);
1620
- if (schemaVersionError !== null) {
1621
- sendError(res, 400, "INVALID_REQUEST", schemaVersionError);
1640
+ const body = parseOptionalBody(res, rawBody);
1641
+ if (body === null) return;
1642
+ const request = safeParse(ENCRYPTION_CHANGE_REQUEST, body);
1643
+ if (!request.success) {
1644
+ sendError(
1645
+ res,
1646
+ 400,
1647
+ "INVALID_REQUEST",
1648
+ `keyRef takes a string of 1 to ${MAX_KEY_REF_LENGTH} characters that names the target key, or null to remove encryption`
1649
+ );
1622
1650
  return;
1623
1651
  }
1624
- const batch = decodeVerifiableBatch(body.batch);
1625
- if (batch === null) {
1626
- sendError(res, 400, "INVALID_REQUEST", '"batch.changes" carries a malformed tagged value');
1652
+ const database = sirannon.get(dbId);
1653
+ if (!database) {
1654
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
1627
1655
  return;
1628
1656
  }
1629
- const target = await resolveExecutionTarget(res, abort, sirannon, dbId, resolveTarget);
1630
- if (!target) return;
1657
+ let key = null;
1631
1658
  try {
1632
- await refuseUngrantedPush(sirannon, dbId, batch, identity);
1633
- const clockRefusal = changeClockRefusal(batch, Date.now());
1634
- if (clockRefusal !== null) {
1635
- sendError(res, 400, clockRefusal.code, clockRefusal.message);
1636
- return;
1637
- }
1638
- if (await refuseSchemaMismatch(res, abort, target, body.schemaVersion ?? 0)) return;
1639
- const applyChanges = target.applyChanges;
1640
- if (typeof applyChanges !== "function") {
1641
- sendError(
1642
- res,
1643
- 501,
1644
- "SYNC_UNSUPPORTED",
1645
- "The execution target for this database does not support applying sync changes"
1646
- );
1647
- return;
1648
- }
1649
- const result = await applyChanges.call(target, batch);
1650
- if (abort.aborted) return;
1651
- sendJson(res, toChangesResponse(result));
1659
+ if (request.output.keyRef !== null) key = await checkedKeySource(keys, dbId, request.output.keyRef);
1660
+ } catch (err) {
1661
+ sendCaughtError(res, abort, err);
1662
+ return;
1663
+ }
1664
+ const refusal = await refusedOnTheFirstTick(database.changeEncryption({ key }));
1665
+ if (refusal !== void 0) {
1666
+ sendCaughtError(res, abort, refusal);
1667
+ return;
1668
+ }
1669
+ if (abort.aborted) return;
1670
+ sendJson(res, { started: true }, "202 Accepted");
1671
+ };
1672
+ }
1673
+ function handleEncryptionStatus(sirannon) {
1674
+ return async (res, dbId, _ctx, abort) => {
1675
+ const database = sirannon.get(dbId);
1676
+ if (!database) {
1677
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
1678
+ return;
1679
+ }
1680
+ let status;
1681
+ try {
1682
+ status = await database.encryptionStatus();
1652
1683
  } catch (err) {
1653
1684
  sendCaughtError(res, abort, err);
1685
+ return;
1654
1686
  }
1687
+ if (abort.aborted) return;
1688
+ sendJson(res, statusForClient(status));
1655
1689
  };
1656
1690
  }
1657
1691
 
@@ -1880,7 +1914,7 @@ function handleSnapshotManifest(sirannon) {
1880
1914
  await database.runCdcMaintenance(async (writer) => {
1881
1915
  epoch = await ensureCdcEpoch(writer);
1882
1916
  });
1883
- const conn = await sirannon.driver.open(database.path, { walMode: true });
1917
+ const conn = await database.openFileConnection({ walMode: true });
1884
1918
  try {
1885
1919
  const tableNames = await tablesInFkOrder(conn);
1886
1920
  await refuseUngrantedTables(sirannon, dbId, tableNames, identity);
@@ -1935,7 +1969,7 @@ function handleSnapshotPage(sirannon) {
1935
1969
  if (!database) return;
1936
1970
  try {
1937
1971
  await refuseUngrantedTables(sirannon, dbId, [body.table], identity);
1938
- const conn = await sirannon.driver.open(database.path, { walMode: true });
1972
+ const conn = await database.openFileConnection({ walMode: true });
1939
1973
  try {
1940
1974
  if (!await selectTableExists(conn, body.table)) {
1941
1975
  sendError(res, 404, "TABLE_NOT_FOUND", `Table '${body.table}' not found`);
@@ -2116,6 +2150,104 @@ function wrapOperationRoute(deps, handler) {
2116
2150
  };
2117
2151
  }
2118
2152
 
2153
+ // src/server/http-backup-restore.ts
2154
+ function restoreAlreadyRunning(databaseId) {
2155
+ return `A restore of database '${databaseId}' is already under way, and it keeps that file to itself until it finishes`;
2156
+ }
2157
+ async function rebuildDatabase(request) {
2158
+ const { sirannon, runs, databaseId, location } = request;
2159
+ try {
2160
+ const outcome = await sirannon.withDatabaseOffline(databaseId, async (destPath, encryption) => {
2161
+ await discardStagedChain(location.stagingDir);
2162
+ return restoreBackup({
2163
+ destination: location.destination,
2164
+ chainName: location.chainName,
2165
+ driver: sirannon.driver,
2166
+ destPath,
2167
+ ...encryption === void 0 ? {} : { encryption },
2168
+ replaceExisting: true,
2169
+ moment: request.moment,
2170
+ ...location.destinationTimeoutMs === void 0 ? {} : { destinationTimeoutMs: location.destinationTimeoutMs },
2171
+ ...request.batchSize === void 0 ? {} : { batchSize: request.batchSize },
2172
+ onProgress: (progress) => runs.progressed(databaseId, progress)
2173
+ });
2174
+ });
2175
+ if (!outcome.ok) {
2176
+ runs.failed(databaseId, outcome.failure);
2177
+ return;
2178
+ }
2179
+ runs.finished(databaseId, outcome.value, outcome.reopenFailure);
2180
+ } catch (err) {
2181
+ runs.failed(databaseId, err);
2182
+ }
2183
+ }
2184
+ function handleBackupRestore(sirannon, accepted, runs) {
2185
+ return async (res, dbId, rawBody, abort) => {
2186
+ if (!accepted) {
2187
+ sendError(res, 403, "BACKUP_RESTORE_NOT_ACCEPTED", BACKUP_RESTORE_NOT_ACCEPTED_MESSAGE);
2188
+ return;
2189
+ }
2190
+ const body = parseOptionalBody(res, rawBody);
2191
+ if (!body) return;
2192
+ const validationError = restoreRequestValidationError(body);
2193
+ if (validationError !== null) {
2194
+ sendError(res, 400, "INVALID_REQUEST", validationError);
2195
+ return;
2196
+ }
2197
+ if (runs.read(dbId).state === "running") {
2198
+ sendError(res, 409, "BACKUP_RESTORE_IN_PROGRESS", restoreAlreadyRunning(dbId));
2199
+ return;
2200
+ }
2201
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
2202
+ if (!database) return;
2203
+ let location;
2204
+ try {
2205
+ database.assertNoReplicationEngine("restore");
2206
+ location = database.backupLocation();
2207
+ } catch (err) {
2208
+ sendCaughtError(res, abort, err);
2209
+ return;
2210
+ }
2211
+ const moment = body.moment === void 0 ? Date.now() : body.moment;
2212
+ if (!runs.claim(dbId, moment)) {
2213
+ sendError(res, 409, "BACKUP_RESTORE_IN_PROGRESS", restoreAlreadyRunning(dbId));
2214
+ return;
2215
+ }
2216
+ void rebuildDatabase({
2217
+ sirannon,
2218
+ runs,
2219
+ databaseId: dbId,
2220
+ location,
2221
+ moment,
2222
+ ...body.batchSize === void 0 ? {} : { batchSize: body.batchSize }
2223
+ });
2224
+ if (abort.aborted) return;
2225
+ sendJson(res, { started: true }, "202 Accepted");
2226
+ };
2227
+ }
2228
+ function handleBackupRestoreStatus(runs) {
2229
+ return async (res, dbId, _ctx, abort) => {
2230
+ if (abort.aborted) return;
2231
+ sendJson(res, runs.read(dbId));
2232
+ };
2233
+ }
2234
+
2235
+ // src/server/operator-routes.ts
2236
+ function registerOperatorRoutes(routes, deps) {
2237
+ const { sirannon, routeDeps } = deps;
2238
+ const post = (path, handler) => routes.post(path, wrapDbRoute(routeDeps, handler));
2239
+ const get = (path, handler) => routes.get(path, wrapDbGetRoute(routeDeps, handler));
2240
+ post("/db/:id/backup", handleBackupTrigger(sirannon));
2241
+ get("/db/:id/backup", handleBackupStatus(sirannon));
2242
+ get("/db/:id/backup/chain", handleBackupChain(sirannon));
2243
+ post("/db/:id/backup/verify", handleBackupVerify(sirannon));
2244
+ post("/db/:id/backup/safe-to-delete", handleBackupSafeToDelete(sirannon));
2245
+ post("/db/:id/backup/restore", handleBackupRestore(sirannon, deps.acceptBackupRestore, deps.restoreRuns));
2246
+ get("/db/:id/backup/restore", handleBackupRestoreStatus(deps.restoreRuns));
2247
+ post("/db/:id/encryption", handleEncryptionChange(sirannon, deps.encryptionChange));
2248
+ get("/db/:id/encryption", handleEncryptionStatus(sirannon));
2249
+ }
2250
+
2119
2251
  // src/server/uws-loader.ts
2120
2252
  var UWEBSOCKETS_DEPENDENCY = {
2121
2253
  packageName: "uWebSockets.js",
@@ -2229,6 +2361,7 @@ var CdcContextRegistry = class {
2229
2361
  const ctx = await promise;
2230
2362
  if (this.closed) {
2231
2363
  ctx.stopPolling();
2364
+ ctx.releaseHold();
2232
2365
  await ctx.cdcConn.close().catch(() => {
2233
2366
  });
2234
2367
  throw new SirannonError("WebSocket handler is shut down", "HANDLER_CLOSED");
@@ -2246,6 +2379,7 @@ var CdcContextRegistry = class {
2246
2379
  const ctx = this.contexts.get(databaseId);
2247
2380
  if (!ctx || ctx.manager.size > 0) return;
2248
2381
  ctx.stopPolling();
2382
+ ctx.releaseHold();
2249
2383
  ctx.cdcConn.close().catch(() => {
2250
2384
  });
2251
2385
  this.contexts.delete(databaseId);
@@ -2255,6 +2389,7 @@ var CdcContextRegistry = class {
2255
2389
  this.closed = true;
2256
2390
  for (const ctx of this.contexts.values()) {
2257
2391
  ctx.stopPolling();
2392
+ ctx.releaseHold();
2258
2393
  try {
2259
2394
  await ctx.cdcConn.close();
2260
2395
  } catch {
@@ -2263,7 +2398,7 @@ var CdcContextRegistry = class {
2263
2398
  this.contexts.clear();
2264
2399
  }
2265
2400
  async createContext(database) {
2266
- const cdcConn = await this.sirannon.driver.open(database.path, { walMode: true });
2401
+ const cdcConn = await database.openFileConnection({ walMode: true });
2267
2402
  const retention = database.changeRetention.cdcRetention ?? this.retentionMs;
2268
2403
  const deviceRetention = resolveDeviceRetentionPolicy(database.changeRetention, this.serverDeviceRetention);
2269
2404
  const tracker = new ChangeTracker(retention === void 0 ? void 0 : { retention });
@@ -2284,19 +2419,21 @@ var CdcContextRegistry = class {
2284
2419
  });
2285
2420
  throw new SirannonError(`Database '${database.id}' is closed`, "DATABASE_CLOSED");
2286
2421
  }
2287
- let polling = false;
2422
+ const ctx = { cdcConn, tracker, manager, stopPolling: () => {
2423
+ }, releaseHold: () => {
2424
+ }, epoch };
2425
+ let interval = null;
2426
+ let inFlight = null;
2288
2427
  let consecutiveErrors = 0;
2289
2428
  let tickCount = 0;
2290
2429
  const MAX_CONSECUTIVE_ERRORS = 10;
2291
2430
  const stopPolling = () => {
2292
- clearInterval(interval);
2431
+ if (interval !== null) clearInterval(interval);
2432
+ interval = null;
2293
2433
  };
2294
- const tick = async () => {
2295
- if (manager.size === 0) return;
2296
- if (polling) return;
2297
- polling = true;
2434
+ const pollOnce = async () => {
2298
2435
  try {
2299
- const events = await tracker.poll(cdcConn);
2436
+ const events = await tracker.poll(ctx.cdcConn);
2300
2437
  if (events.length > 0) {
2301
2438
  manager.dispatch(events);
2302
2439
  }
@@ -2316,13 +2453,36 @@ var CdcContextRegistry = class {
2316
2453
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2317
2454
  stopPolling();
2318
2455
  }
2319
- } finally {
2320
- polling = false;
2321
2456
  }
2322
2457
  };
2323
- const interval = setInterval(tick, DEFAULT_POLL_INTERVAL_MS);
2324
- interval.unref?.();
2325
- return { cdcConn, tracker, manager, stopPolling, epoch };
2458
+ const tick = () => {
2459
+ if (manager.size === 0 || inFlight !== null) return;
2460
+ inFlight = pollOnce().finally(() => {
2461
+ inFlight = null;
2462
+ });
2463
+ };
2464
+ const startPolling = () => {
2465
+ stopPolling();
2466
+ consecutiveErrors = 0;
2467
+ const started = setInterval(tick, DEFAULT_POLL_INTERVAL_MS);
2468
+ started.unref?.();
2469
+ interval = started;
2470
+ };
2471
+ ctx.stopPolling = stopPolling;
2472
+ startPolling();
2473
+ ctx.releaseHold = database.holdFileConnection({
2474
+ release: async () => {
2475
+ stopPolling();
2476
+ await inFlight;
2477
+ await ctx.cdcConn.close().catch(() => {
2478
+ });
2479
+ },
2480
+ reopen: async () => {
2481
+ ctx.cdcConn = await database.openFileConnection({ walMode: true });
2482
+ startPolling();
2483
+ }
2484
+ });
2485
+ return ctx;
2326
2486
  }
2327
2487
  };
2328
2488
 
@@ -4093,6 +4253,7 @@ var SirannonServer = class {
4093
4253
  authenticateHook;
4094
4254
  acceptSql;
4095
4255
  acceptBackupRestore;
4256
+ encryptionChange;
4096
4257
  acceptDeviceSync;
4097
4258
  restoreRuns = new BackupRestoreRuns();
4098
4259
  drain = new ConnectionDrain();
@@ -4115,6 +4276,7 @@ var SirannonServer = class {
4115
4276
  this.acceptSql = options?.acceptSql === true;
4116
4277
  this.acceptBackupRestore = options?.acceptBackupRestore === true;
4117
4278
  assertBackupRestoreAuthenticated(this.acceptBackupRestore, this.authenticateHook !== void 0);
4279
+ this.encryptionChange = resolveEncryptionChangeSettings(options);
4118
4280
  this.acceptDeviceSync = options?.acceptDeviceSync === true;
4119
4281
  assertDeviceSyncAuthenticated(this.acceptDeviceSync, this.authenticateHook !== void 0);
4120
4282
  this.operations = { registry: options?.operations, shared: options?.sharedOperations };
@@ -4268,7 +4430,13 @@ var SirannonServer = class {
4268
4430
  "/db/:id/execute/:name",
4269
4431
  this.wrapOperationRoute(handleOperationExecute(this.sirannon, this.operations, this.resolveExecutionTarget))
4270
4432
  );
4271
- this.registerBackupRoutes(routes);
4433
+ registerOperatorRoutes(routes, {
4434
+ sirannon: this.sirannon,
4435
+ routeDeps: this.routeDeps(),
4436
+ acceptBackupRestore: this.acceptBackupRestore,
4437
+ restoreRuns: this.restoreRuns,
4438
+ encryptionChange: this.encryptionChange
4439
+ });
4272
4440
  if (this.acceptDeviceSync) {
4273
4441
  routes.post("/db/:id/changes", this.wrapDbRoute(handleChanges(this.sirannon, this.resolveExecutionTarget)));
4274
4442
  routes.post("/db/:id/migrations", this.wrapDbRoute(handleMigrationList(this.sirannon)));
@@ -4291,18 +4459,6 @@ var SirannonServer = class {
4291
4459
  sendError(res, 404, "NOT_FOUND", "Route not found");
4292
4460
  });
4293
4461
  }
4294
- registerBackupRoutes(routes) {
4295
- routes.post("/db/:id/backup", this.wrapDbRoute(handleBackupTrigger(this.sirannon)));
4296
- routes.get("/db/:id/backup", this.wrapDbGetRoute(handleBackupStatus(this.sirannon)));
4297
- routes.get("/db/:id/backup/chain", this.wrapDbGetRoute(handleBackupChain(this.sirannon)));
4298
- routes.post("/db/:id/backup/verify", this.wrapDbRoute(handleBackupVerify(this.sirannon)));
4299
- routes.post("/db/:id/backup/safe-to-delete", this.wrapDbRoute(handleBackupSafeToDelete(this.sirannon)));
4300
- routes.post(
4301
- "/db/:id/backup/restore",
4302
- this.wrapDbRoute(handleBackupRestore(this.sirannon, this.acceptBackupRestore, this.restoreRuns))
4303
- );
4304
- routes.get("/db/:id/backup/restore", this.wrapDbGetRoute(handleBackupRestoreStatus(this.restoreRuns)));
4305
- }
4306
4462
  withCors(handler) {
4307
4463
  if (!this.cors) return handler;
4308
4464
  const cors = this.cors;