@delali/sirannon-db 0.2.3-next.37 → 0.2.3-next.39

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 (42) hide show
  1. package/dist/backup/index.d.ts +6 -84
  2. package/dist/backup/index.mjs +6 -584
  3. package/dist/backup-scheduler/index.d.ts +58 -8
  4. package/dist/backup-scheduler/index.mjs +3 -2
  5. package/dist/{change-tracker-DXHPFe9J.d.ts → change-tracker-uG9gWvsY.d.ts} +1 -1
  6. package/dist/chunk-2QCPVE67.mjs +143 -0
  7. package/dist/chunk-63VBFSCS.mjs +525 -0
  8. package/dist/chunk-7SC2TZAS.mjs +41 -0
  9. package/dist/{chunk-72I5UI5C.mjs → chunk-DVS4QSIZ.mjs} +51 -23
  10. package/dist/{chunk-BQTVNI5H.mjs → chunk-DXXCLTFF.mjs} +205 -45
  11. package/dist/{chunk-BQFQ65OL.mjs → chunk-LXCGPZPA.mjs} +9 -1
  12. package/dist/{chunk-ZKDBGX7H.mjs → chunk-OJQHM4MT.mjs} +365 -357
  13. package/dist/chunk-U445A3C4.mjs +88 -0
  14. package/dist/chunk-W77EEVOT.mjs +156 -0
  15. package/dist/client/index.d.ts +8 -8
  16. package/dist/client/topology.d.ts +4 -4
  17. package/dist/{client-base-CGBpjZUB.d.ts → client-base-C-B8u5Lm.d.ts} +2 -2
  18. package/dist/core/index.d.ts +9 -9
  19. package/dist/core/index.mjs +228 -48
  20. package/dist/{database-955qag_W.d.ts → database-BlhUdJAO.d.ts} +46 -39
  21. package/dist/{chain-queries-D5aZ-8v2.d.ts → database-backup-C090lMHc.d.ts} +52 -2
  22. package/dist/driver/better-sqlite3.d.ts +1 -1
  23. package/dist/driver/better-sqlite3.mjs +9 -6
  24. package/dist/driver/bun.d.ts +1 -1
  25. package/dist/driver/expo.d.ts +1 -1
  26. package/dist/driver/node.d.ts +1 -1
  27. package/dist/driver/node.mjs +9 -6
  28. package/dist/driver/wa-sqlite.d.ts +1 -1
  29. package/dist/file-migrations/index.d.ts +1 -1
  30. package/dist/{protocol-BfiujBsE.d.ts → protocol-QekTGoKn.d.ts} +2 -2
  31. package/dist/replication/index.d.ts +7 -7
  32. package/dist/restore-options-C9MW9gpm.d.ts +83 -0
  33. package/dist/server/index.d.ts +102 -9
  34. package/dist/server/index.mjs +355 -3
  35. package/dist/{server-options-D4VmfTHw.d.ts → server-options-DeK6ZG8u.d.ts} +8 -1
  36. package/dist/{sirannon-DaGUawHs.d.ts → sirannon-C9cTgFrw.d.ts} +48 -5
  37. package/dist/transport/grpc.d.ts +3 -3
  38. package/dist/transport/memory.d.ts +3 -3
  39. package/dist/{types-BEjS69rs.d.ts → types-C1yBGTdx.d.ts} +2 -2
  40. package/dist/{types-Xb0L3o3w.d.ts → types-DApb7dvw.d.ts} +266 -23
  41. package/package.json +8 -7
  42. package/dist/chunk-GBM4AU24.mjs +0 -42
@@ -1,11 +1,18 @@
1
+ import { restoreBackup, MAX_RESTORE_BATCH_SIZE } from '../chunk-63VBFSCS.mjs';
1
2
  import { dumpSchema, tablesInFkOrder, dumpTablePages } from '../chunk-XF2HH5E6.mjs';
2
3
  import { ChangeTracker, SubscriptionManager, ensureCdcEpoch, migrationChecksum, needsResync, filteredChange, TransactionGrouper, isBulkLoadDurability, PrimedSubscription } from '../chunk-ECEGYTVF.mjs';
4
+ import '../chunk-YNTDOFTN.mjs';
3
5
  import { SEQ_STRING_RE, highestMigrationVersion, selectCountTableRows, selectTableExists, selectAppliedMigrations, PkResolver, ensureDeviceCursorsTable, upsertDeviceCursor, selectMaxChangeSeq, canonicaliseForChecksum, selectDeviceCursors, selectMinForeignChangeSeqSql, IDENTIFIER_RE, deleteDeviceCursorsUpdatedBefore } from '../chunk-7FQRQH5Z.mjs';
4
6
  import '../chunk-2QLXDHAP.mjs';
5
7
  import { encodeTaggedValues, MIGRATIONS_TABLE, decodeTaggedValues, encodeWireRowsInPlace, CHANGES_TABLE, DEVICE_CURSORS_TABLE, INTERNAL_TABLE_PREFIX } from '../chunk-7R4ER4FB.mjs';
6
8
  import { createOperationSource, operationRegistryDigest, findRead, isRefusal, resolveArguments, findWrite } from '../chunk-TUD5CJ76.mjs';
7
9
  import '../chunk-GVCNMPOS.mjs';
10
+ import { discardStagedChain } from '../chunk-W77EEVOT.mjs';
11
+ import '../chunk-U445A3C4.mjs';
12
+ import '../chunk-2QCPVE67.mjs';
13
+ import '../chunk-DVS4QSIZ.mjs';
8
14
  import '../chunk-OUSWVNWT.mjs';
15
+ import '../chunk-YVF2ZJTE.mjs';
9
16
  import '../chunk-V5FUJEYY.mjs';
10
17
  import { SQLITE_USER_VERSION_MAX } from '../chunk-IWGIYDMZ.mjs';
11
18
  import { SirannonError, WriteOverloadError } from '../chunk-PBRXXISQ.mjs';
@@ -131,6 +138,90 @@ function paramsBatchValidationError(value) {
131
138
  return null;
132
139
  }
133
140
 
141
+ // src/server/backup-restore-runs.ts
142
+ function describeFailure(err) {
143
+ return {
144
+ code: err instanceof SirannonError ? err.code : "INTERNAL_ERROR",
145
+ message: err instanceof SirannonError ? err.message : "An unexpected error occurred"
146
+ };
147
+ }
148
+ var BackupRestoreRuns = class {
149
+ runs = /* @__PURE__ */ new Map();
150
+ /**
151
+ * Marks a restore of one database as under way, unless one is already running.
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.
156
+ */
157
+ claim(databaseId, moment) {
158
+ if (this.runs.get(databaseId)?.state === "running") return false;
159
+ this.runs.set(databaseId, { state: "running", moment, startedAt: Date.now() });
160
+ return true;
161
+ }
162
+ /**
163
+ * Records how far the restore of one database has got.
164
+ *
165
+ * @param databaseId - Identifier of the database being rebuilt.
166
+ * @param progress - Pieces fetched, bytes fetched, and change pieces replayed.
167
+ */
168
+ progressed(databaseId, progress) {
169
+ const run = this.runs.get(databaseId);
170
+ if (run?.state === "running") run.progress = progress;
171
+ }
172
+ /**
173
+ * Records a restore that rebuilt the database.
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.
178
+ *
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.
182
+ */
183
+ finished(databaseId, report, reopenFailure) {
184
+ this.runs.set(databaseId, {
185
+ state: "done",
186
+ moment: this.runs.get(databaseId)?.moment ?? report.restoresTo,
187
+ startedAt: report.startedAt,
188
+ finishedAt: report.finishedAt,
189
+ report,
190
+ ...reopenFailure === void 0 ? {} : { reopenError: describeFailure(reopenFailure) }
191
+ });
192
+ }
193
+ /**
194
+ * Records a restore that stopped, under the code it stopped with.
195
+ *
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.
200
+ *
201
+ * @param databaseId - Identifier of the database it was rebuilding.
202
+ * @param err - What stopped it.
203
+ */
204
+ failed(databaseId, err) {
205
+ const previous = this.runs.get(databaseId);
206
+ this.runs.set(databaseId, {
207
+ state: "failed",
208
+ ...previous?.moment === void 0 ? {} : { moment: previous.moment },
209
+ ...previous?.startedAt === void 0 ? {} : { startedAt: previous.startedAt },
210
+ finishedAt: Date.now(),
211
+ error: describeFailure(err)
212
+ });
213
+ }
214
+ /**
215
+ * Reads where one database's restore stands.
216
+ *
217
+ * @param databaseId - Identifier of the database to report on.
218
+ * @returns The restore, or an idle answer where none has run.
219
+ */
220
+ read(databaseId) {
221
+ return this.runs.get(databaseId) ?? { state: "idle" };
222
+ }
223
+ };
224
+
134
225
  // src/server/http-common.ts
135
226
  var SQL_NOT_ACCEPTED_MESSAGE = "This server does not accept SQL statements over the wire; call a registered operation by name";
136
227
  function initAbortHandler(res) {
@@ -200,17 +291,23 @@ function parseBody(res, raw) {
200
291
  sendError(res, 400, "EMPTY_BODY", "Request body is empty");
201
292
  return null;
202
293
  }
294
+ let parsed;
203
295
  try {
204
- return JSON.parse(raw.toString("utf-8"));
296
+ parsed = JSON.parse(raw.toString("utf-8"));
205
297
  } catch {
206
298
  sendError(res, 400, "INVALID_JSON", "Request body is not valid JSON");
207
299
  return null;
208
300
  }
301
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
302
+ sendError(res, 400, "INVALID_REQUEST", "Request body must be a JSON object");
303
+ return null;
304
+ }
305
+ return parsed;
209
306
  }
210
- function sendJson(res, data) {
307
+ function sendJson(res, data, status = "200 OK") {
211
308
  const payload = JSON.stringify(data);
212
309
  res.cork(() => {
213
- res.writeStatus("200 OK").writeHeader("Content-Type", "application/json").end(payload);
310
+ res.writeStatus(status).writeHeader("Content-Type", "application/json").end(payload);
214
311
  });
215
312
  }
216
313
  function sendError(res, status, code, message, details, headers) {
@@ -244,6 +341,12 @@ function httpStatusForError(err) {
244
341
  return 409;
245
342
  case "HOOK_DENIED":
246
343
  return 403;
344
+ case "BACKUP_CHAIN_BROKEN":
345
+ return 409;
346
+ case "BACKUP_UNSUPPORTED":
347
+ return 501;
348
+ case "BACKUP_DESTINATION_ERROR":
349
+ return 502;
247
350
  case "DATABASE_CLOSED":
248
351
  case "SHUTDOWN":
249
352
  case "READ_CONCERN_ERROR":
@@ -430,6 +533,230 @@ function readinessStatusForReplication(replication, current) {
430
533
  return current;
431
534
  }
432
535
 
536
+ // src/server/backup-protocol.ts
537
+ var BACKUP_RESTORE_NOT_ACCEPTED_MESSAGE = "This server does not restore databases over the wire; turn on acceptBackupRestore to open the route";
538
+ function wholeNumberError(field, value, atLeast, atMost) {
539
+ if (value === void 0) return null;
540
+ if (typeof value !== "number" || !Number.isInteger(value) || value < atLeast) {
541
+ return `Field "${field}" must be a whole number of at least ${atLeast}`;
542
+ }
543
+ if (atMost !== void 0 && value > atMost) {
544
+ return `Field "${field}" must be a whole number of at most ${atMost}`;
545
+ }
546
+ return null;
547
+ }
548
+ function verifyNameValidationError(value) {
549
+ if (typeof value !== "string" || value.length === 0) {
550
+ return 'Field "name" is required and must be the name a chain record states';
551
+ }
552
+ return null;
553
+ }
554
+ function restorableFromValidationError(value) {
555
+ return wholeNumberError("restorableFrom", value, 0);
556
+ }
557
+ function restoreRequestValidationError(body) {
558
+ return wholeNumberError("moment", body.moment, 0) ?? wholeNumberError("batchSize", body.batchSize, 1, MAX_RESTORE_BATCH_SIZE);
559
+ }
560
+
561
+ // src/server/http-backups.ts
562
+ function parseOptionalBody(res, rawBody) {
563
+ if (rawBody.length === 0) return {};
564
+ return parseBody(res, rawBody);
565
+ }
566
+ async function resolveBackupDatabase(res, abort, sirannon, dbId) {
567
+ let database;
568
+ try {
569
+ database = await sirannon.resolve(dbId);
570
+ } catch (err) {
571
+ sendCaughtError(res, abort, err);
572
+ return null;
573
+ }
574
+ if (abort.aborted) return null;
575
+ if (!database) {
576
+ sendError(res, 404, "DATABASE_NOT_FOUND", `Database '${dbId}' not found`);
577
+ return null;
578
+ }
579
+ return database;
580
+ }
581
+ function refusedOnTheFirstTick(turn) {
582
+ const settled = turn.then(
583
+ () => void 0,
584
+ (err) => err ?? new SirannonError("The backup turn failed", "BACKUP_ERROR")
585
+ );
586
+ const tick = new Promise((resolve) => {
587
+ setImmediate(() => resolve(void 0));
588
+ });
589
+ return Promise.race([settled, tick]);
590
+ }
591
+ function readStatus(res, abort, database) {
592
+ try {
593
+ return database.backupStatus();
594
+ } catch (err) {
595
+ sendCaughtError(res, abort, err);
596
+ return null;
597
+ }
598
+ }
599
+ function handleBackupTrigger(sirannon) {
600
+ return async (res, dbId, _rawBody, abort) => {
601
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
602
+ if (!database) return;
603
+ if (!readStatus(res, abort, database)) return;
604
+ const refusal = await refusedOnTheFirstTick(database.captureBackupChanges());
605
+ if (refusal !== void 0) {
606
+ sendCaughtError(res, abort, refusal);
607
+ return;
608
+ }
609
+ if (abort.aborted) return;
610
+ sendJson(res, { started: true }, "202 Accepted");
611
+ };
612
+ }
613
+ function handleBackupStatus(sirannon) {
614
+ return async (res, dbId, _ctx, abort) => {
615
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
616
+ if (!database) return;
617
+ const status = readStatus(res, abort, database);
618
+ if (!status || abort.aborted) return;
619
+ sendJson(res, status);
620
+ };
621
+ }
622
+ function handleBackupChain(sirannon) {
623
+ return async (res, dbId, _ctx, abort) => {
624
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
625
+ if (!database) return;
626
+ try {
627
+ const chains = await database.backupChain();
628
+ if (abort.aborted) return;
629
+ const body = { chains };
630
+ sendJson(res, body);
631
+ } catch (err) {
632
+ sendCaughtError(res, abort, err);
633
+ }
634
+ };
635
+ }
636
+ function handleBackupVerify(sirannon) {
637
+ return async (res, dbId, rawBody, abort) => {
638
+ const body = parseOptionalBody(res, rawBody);
639
+ if (!body) return;
640
+ const nameError = verifyNameValidationError(body.name);
641
+ if (nameError !== null) {
642
+ sendError(res, 400, "INVALID_REQUEST", nameError);
643
+ return;
644
+ }
645
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
646
+ if (!database) return;
647
+ try {
648
+ const result = await database.verifyBackup(body.name);
649
+ if (abort.aborted) return;
650
+ sendJson(res, result);
651
+ } catch (err) {
652
+ sendCaughtError(res, abort, err);
653
+ }
654
+ };
655
+ }
656
+ function handleBackupSafeToDelete(sirannon) {
657
+ return async (res, dbId, rawBody, abort) => {
658
+ const body = parseOptionalBody(res, rawBody);
659
+ if (!body) return;
660
+ const restorableFromError = restorableFromValidationError(body.restorableFrom);
661
+ if (restorableFromError !== null) {
662
+ sendError(res, 400, "INVALID_REQUEST", restorableFromError);
663
+ return;
664
+ }
665
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
666
+ if (!database) return;
667
+ try {
668
+ const records = await database.backupPiecesSafeToDelete(
669
+ body.restorableFrom === void 0 ? void 0 : { restorableFrom: body.restorableFrom }
670
+ );
671
+ if (abort.aborted) return;
672
+ const answer = { records };
673
+ sendJson(res, answer);
674
+ } catch (err) {
675
+ sendCaughtError(res, abort, err);
676
+ }
677
+ };
678
+ }
679
+
680
+ // src/server/http-backup-restore.ts
681
+ function restoreAlreadyRunning(databaseId) {
682
+ return `A restore of database '${databaseId}' is already under way, and it keeps that file to itself until it finishes`;
683
+ }
684
+ async function rebuildDatabase(request) {
685
+ const { sirannon, runs, databaseId, location } = request;
686
+ try {
687
+ const outcome = await sirannon.withDatabaseOffline(databaseId, async (destPath) => {
688
+ await discardStagedChain(location.stagingDir);
689
+ return restoreBackup({
690
+ destination: location.destination,
691
+ chainName: location.chainName,
692
+ driver: sirannon.driver,
693
+ destPath,
694
+ replaceExisting: true,
695
+ moment: request.moment,
696
+ ...location.destinationTimeoutMs === void 0 ? {} : { destinationTimeoutMs: location.destinationTimeoutMs },
697
+ ...request.batchSize === void 0 ? {} : { batchSize: request.batchSize },
698
+ onProgress: (progress) => runs.progressed(databaseId, progress)
699
+ });
700
+ });
701
+ if (!outcome.ok) {
702
+ runs.failed(databaseId, outcome.failure);
703
+ return;
704
+ }
705
+ runs.finished(databaseId, outcome.value, outcome.reopenFailure);
706
+ } catch (err) {
707
+ runs.failed(databaseId, err);
708
+ }
709
+ }
710
+ function handleBackupRestore(sirannon, accepted, runs) {
711
+ return async (res, dbId, rawBody, abort) => {
712
+ if (!accepted) {
713
+ sendError(res, 403, "BACKUP_RESTORE_NOT_ACCEPTED", BACKUP_RESTORE_NOT_ACCEPTED_MESSAGE);
714
+ return;
715
+ }
716
+ const body = parseOptionalBody(res, rawBody);
717
+ if (!body) return;
718
+ const validationError = restoreRequestValidationError(body);
719
+ if (validationError !== null) {
720
+ sendError(res, 400, "INVALID_REQUEST", validationError);
721
+ return;
722
+ }
723
+ if (runs.read(dbId).state === "running") {
724
+ sendError(res, 409, "BACKUP_RESTORE_IN_PROGRESS", restoreAlreadyRunning(dbId));
725
+ return;
726
+ }
727
+ const database = await resolveBackupDatabase(res, abort, sirannon, dbId);
728
+ if (!database) return;
729
+ let location;
730
+ try {
731
+ location = database.backupLocation();
732
+ } catch (err) {
733
+ sendCaughtError(res, abort, err);
734
+ return;
735
+ }
736
+ const moment = body.moment === void 0 ? Date.now() : body.moment;
737
+ if (!runs.claim(dbId, moment)) {
738
+ sendError(res, 409, "BACKUP_RESTORE_IN_PROGRESS", restoreAlreadyRunning(dbId));
739
+ return;
740
+ }
741
+ void rebuildDatabase({
742
+ sirannon,
743
+ runs,
744
+ databaseId: dbId,
745
+ location,
746
+ moment,
747
+ ...body.batchSize === void 0 ? {} : { batchSize: body.batchSize }
748
+ });
749
+ if (abort.aborted) return;
750
+ sendJson(res, { started: true }, "202 Accepted");
751
+ };
752
+ }
753
+ function handleBackupRestoreStatus(runs) {
754
+ return async (res, dbId, _ctx, abort) => {
755
+ if (abort.aborted) return;
756
+ sendJson(res, runs.read(dbId));
757
+ };
758
+ }
759
+
433
760
  // src/server/sync-protocol.ts
434
761
  var MAX_SYNC_BATCH_CHANGES = 1e3;
435
762
  var NODE_ID_RE = /^[0-9a-f]{32}$/;
@@ -1170,6 +1497,14 @@ function handleSnapshotPage(sirannon) {
1170
1497
  var DEFAULT_MAX_BODY_BYTES = 1048576;
1171
1498
  var DEFAULT_WS_BACKPRESSURE_BYTES = 16 * 1048576;
1172
1499
  var UWS_MAX_LIMIT_BYTES = 4294967295;
1500
+ function assertBackupRestoreAuthenticated(acceptBackupRestore, hasAuthenticate) {
1501
+ if (acceptBackupRestore && !hasAuthenticate) {
1502
+ throw new SirannonError(
1503
+ "ServerOptions.acceptBackupRestore opens a route that replaces a running database, so it needs an authenticate hook to name the caller",
1504
+ "INVALID_BACKUP_RESTORE"
1505
+ );
1506
+ }
1507
+ }
1173
1508
  function resolveMaxBodyBytes(value) {
1174
1509
  if (value === void 0) return DEFAULT_MAX_BODY_BYTES;
1175
1510
  if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
@@ -2968,6 +3303,8 @@ var SirannonServer = class {
2968
3303
  cors;
2969
3304
  authenticateHook;
2970
3305
  acceptSql;
3306
+ acceptBackupRestore;
3307
+ restoreRuns = new BackupRestoreRuns();
2971
3308
  operations;
2972
3309
  registryDigest;
2973
3310
  resolveExecutionTarget;
@@ -2985,6 +3322,8 @@ var SirannonServer = class {
2985
3322
  this.cors = resolveCors(options?.cors);
2986
3323
  this.authenticateHook = options?.authenticate;
2987
3324
  this.acceptSql = options?.acceptSql === true;
3325
+ this.acceptBackupRestore = options?.acceptBackupRestore === true;
3326
+ assertBackupRestoreAuthenticated(this.acceptBackupRestore, this.authenticateHook !== void 0);
2988
3327
  this.operations = options?.operations;
2989
3328
  this.registryDigest = operationRegistryDigest(options?.operations);
2990
3329
  this.resolveExecutionTarget = options?.resolveExecutionTarget;
@@ -3087,6 +3426,7 @@ var SirannonServer = class {
3087
3426
  "/db/:id/execute/:name",
3088
3427
  this.wrapOperationRoute(handleOperationExecute(this.sirannon, this.operations, this.resolveExecutionTarget))
3089
3428
  );
3429
+ this.registerBackupRoutes();
3090
3430
  this.app.post("/db/:id/changes", this.wrapDbRoute(handleChanges(this.sirannon, this.resolveExecutionTarget)));
3091
3431
  this.app.post("/db/:id/migrations", this.wrapDbRoute(handleMigrationList(this.sirannon)));
3092
3432
  this.app.post("/db/:id/snapshot", this.wrapDbRoute(handleSnapshotManifest(this.sirannon)));
@@ -3096,6 +3436,18 @@ var SirannonServer = class {
3096
3436
  sendError(res, 404, "NOT_FOUND", "Route not found");
3097
3437
  });
3098
3438
  }
3439
+ registerBackupRoutes() {
3440
+ this.app.post("/db/:id/backup", this.wrapDbRoute(handleBackupTrigger(this.sirannon)));
3441
+ this.app.get("/db/:id/backup", this.wrapDbGetRoute(handleBackupStatus(this.sirannon)));
3442
+ this.app.get("/db/:id/backup/chain", this.wrapDbGetRoute(handleBackupChain(this.sirannon)));
3443
+ this.app.post("/db/:id/backup/verify", this.wrapDbRoute(handleBackupVerify(this.sirannon)));
3444
+ this.app.post("/db/:id/backup/safe-to-delete", this.wrapDbRoute(handleBackupSafeToDelete(this.sirannon)));
3445
+ this.app.post(
3446
+ "/db/:id/backup/restore",
3447
+ this.wrapDbRoute(handleBackupRestore(this.sirannon, this.acceptBackupRestore, this.restoreRuns))
3448
+ );
3449
+ this.app.get("/db/:id/backup/restore", this.wrapDbGetRoute(handleBackupRestoreStatus(this.restoreRuns)));
3450
+ }
3099
3451
  registerWebSocketRoute() {
3100
3452
  registerWebSocketRoute({
3101
3453
  app: this.app,
@@ -1,6 +1,6 @@
1
1
  import { a as OperationRegistry } from './operation-registry-C1FmwQWR.js';
2
2
  import { R as ReplicationBatch, C as ConflictResolver, A as ApplyResult } from './types-CjhxcjhA.js';
3
- import { N as NodeHealth, T as Transaction, C as ClusterStatusInfo } from './types-Xb0L3o3w.js';
3
+ import { N as NodeHealth, T as Transaction, H as ClusterStatusInfo } from './types-DApb7dvw.js';
4
4
  import { P as Params, Q as QueryOptions, E as ExecuteResult } from './query-types-Cv8N7pXj.js';
5
5
 
6
6
  interface AppliedMigrationRow {
@@ -165,6 +165,13 @@ interface ServerOptions<Identity = unknown> {
165
165
  operations?: OperationRegistry<Identity>;
166
166
  /** Opens the five statement routes and their WebSocket messages. Default: false. */
167
167
  acceptSql?: boolean;
168
+ /**
169
+ * Opens the route that rebuilds a database from its backups. A restore
170
+ * replaces the database that is serving traffic, so the route stays shut
171
+ * until you open it here, and a server that opens it without an
172
+ * `authenticate` hook refuses to start. Default: false.
173
+ */
174
+ acceptBackupRestore?: boolean;
168
175
  /** Finds what the server runs a database's statements against. */
169
176
  resolveExecutionTarget?: ServerExecutionTargetResolver;
170
177
  /** Supplies the replication figures the readiness endpoint reports. */
@@ -1,5 +1,24 @@
1
- import { D as Database } from './database-955qag_W.js';
2
- import { S as SirannonOptions, a as SQLiteDriver, D as DatabaseOptions, M as Migration, B as BeforeQueryHook, A as AfterQueryHook, b as BeforeConnectHook, c as DatabaseOpenHook, d as DatabaseCloseHook } from './types-Xb0L3o3w.js';
1
+ import { D as Database } from './database-BlhUdJAO.js';
2
+ import { a as SirannonOptions, S as SQLiteDriver, D as DatabaseOptions, M as Migration, b as BeforeQueryHook, A as AfterQueryHook, c as BeforeConnectHook, d as DatabaseOpenHook, e as DatabaseCloseHook } from './types-DApb7dvw.js';
3
+
4
+ /** What one database taken out of service produced.
5
+ * @internal
6
+ */
7
+ type OfflineOutcome<T> = {
8
+ /** True where the action returned. */
9
+ ok: true;
10
+ /** What the action produced. */
11
+ value: T;
12
+ /** What the reopen threw, where it threw. The action's own result still stands. */
13
+ reopenFailure?: unknown;
14
+ } | {
15
+ /** False where the action threw. */
16
+ ok: false;
17
+ /** What the action threw. */
18
+ failure: unknown;
19
+ /** What the reopen threw, where it threw. */
20
+ reopenFailure?: unknown;
21
+ };
3
22
 
4
23
  /**
5
24
  * A registry of open SQLite databases, keyed by identifier.
@@ -10,14 +29,17 @@ import { S as SirannonOptions, a as SQLiteDriver, D as DatabaseOptions, M as Mig
10
29
  */
11
30
  declare class Sirannon {
12
31
  private readonly dbs;
32
+ private readonly openedWith;
33
+ private readonly offline;
34
+ private readonly offlineWork;
13
35
  private readonly opening;
14
36
  private readonly resolving;
15
- private migrationSet;
16
37
  private _shutdown;
17
38
  private readonly _driver;
18
39
  private readonly hookRegistry;
19
40
  private readonly metricsCollector;
20
41
  private readonly lifecycleManager;
42
+ private readonly migrations;
21
43
  /** The driver, hooks, metrics, lifecycle, migrations, and writer-worker default this registry was built with. */
22
44
  readonly options: SirannonOptions;
23
45
  /**
@@ -45,6 +67,24 @@ declare class Sirannon {
45
67
  * @param id - Identifier of the database to close.
46
68
  */
47
69
  close(id: string): Promise<void>;
70
+ /**
71
+ * Closes one database, calls an action against the file behind it, and then
72
+ * opens that database again under the same identifier with the settings it
73
+ * had before.
74
+ *
75
+ * A restore rebuilds a database at the path it already occupies, so no
76
+ * connection may be open on that file while Sirannon replaces its bytes. The
77
+ * identifier answers nothing until that database is open again, though an
78
+ * action that failed still leaves a database open at the end.
79
+ *
80
+ * @param id - Identifier of the database to take offline.
81
+ * @param action - Runs with the database closed, and receives its file path.
82
+ * @returns Whether the action returned, what it produced or threw, and what a failed reopen threw.
83
+ * @throws When no database is open under the identifier, when it refuses writes, or when the close fails.
84
+ *
85
+ * @internal
86
+ */
87
+ withDatabaseOffline<T>(id: string, action: (path: string) => Promise<T>): Promise<OfflineOutcome<T>>;
48
88
  /**
49
89
  * Returns an already-open database.
50
90
  *
@@ -56,8 +96,6 @@ declare class Sirannon {
56
96
  resolve(id: string): Promise<Database | undefined>;
57
97
  /** @internal */
58
98
  registryMigrations(): Promise<Migration[]>;
59
- private applyRegistryMigrations;
60
- private loadMigrationSet;
61
99
  /**
62
100
  * Reports whether a database is open under an identifier.
63
101
  *
@@ -73,6 +111,10 @@ declare class Sirannon {
73
111
  databases(): Map<string, Database>;
74
112
  /**
75
113
  * Closes every open database and stops the lifecycle timers.
114
+ *
115
+ * A database whose file Sirannon is replacing delays the shutdown until that
116
+ * work finishes, since a process that exits part-way through would leave the
117
+ * path with no database on it at all.
76
118
  */
77
119
  shutdown(): Promise<void>;
78
120
  /**
@@ -106,6 +148,7 @@ declare class Sirannon {
106
148
  */
107
149
  onDatabaseClose(hook: DatabaseCloseHook): void;
108
150
  private ensureRunning;
151
+ private ensureOpenAllowed;
109
152
  }
110
153
 
111
154
  export { Sirannon as S };
@@ -1,10 +1,10 @@
1
1
  import { BinaryWriter, BinaryReader } from '@bufbuild/protobuf/wire';
2
2
  import { Client, ClientDuplexStream, CallOptions, Metadata, ServiceError, ClientUnaryCall, ChannelCredentials, ClientOptions, ServerDuplexStream, Server } from '@grpc/grpc-js';
3
3
  import { HealthImplementation } from 'grpc-health-check';
4
- import { R as ReplicationAck, F as ForwardedTransaction, a as ForwardedTransactionResult, N as NodeInfo, S as SyncRequest, b as SyncBatch, c as SyncComplete, d as SyncAck, e as ReplicationTransport, f as TopologyRole, T as TransportConfig } from '../types-BEjS69rs.js';
4
+ import { R as ReplicationAck, F as ForwardedTransaction, a as ForwardedTransactionResult, N as NodeInfo, S as SyncRequest, b as SyncBatch, c as SyncComplete, d as SyncAck, e as ReplicationTransport, f as TopologyRole, T as TransportConfig } from '../types-C1yBGTdx.js';
5
5
  import { R as ReplicationBatch } from '../types-CjhxcjhA.js';
6
- import '../change-tracker-DXHPFe9J.js';
7
- import '../types-Xb0L3o3w.js';
6
+ import '../change-tracker-uG9gWvsY.js';
7
+ import '../types-DApb7dvw.js';
8
8
  import '../query-types-Cv8N7pXj.js';
9
9
  import '../types-CMBcFPhb.js';
10
10
 
@@ -1,7 +1,7 @@
1
- import { R as ReplicationAck, F as ForwardedTransaction, a as ForwardedTransactionResult, S as SyncRequest, b as SyncBatch, c as SyncComplete, d as SyncAck, N as NodeInfo, e as ReplicationTransport, T as TransportConfig } from '../types-BEjS69rs.js';
1
+ import { R as ReplicationAck, F as ForwardedTransaction, a as ForwardedTransactionResult, S as SyncRequest, b as SyncBatch, c as SyncComplete, d as SyncAck, N as NodeInfo, e as ReplicationTransport, T as TransportConfig } from '../types-C1yBGTdx.js';
2
2
  import { R as ReplicationBatch } from '../types-CjhxcjhA.js';
3
- import '../change-tracker-DXHPFe9J.js';
4
- import '../types-Xb0L3o3w.js';
3
+ import '../change-tracker-uG9gWvsY.js';
4
+ import '../types-DApb7dvw.js';
5
5
  import '../query-types-Cv8N7pXj.js';
6
6
  import '../types-CMBcFPhb.js';
7
7
 
@@ -1,5 +1,5 @@
1
- import { C as ChangeTracker } from './change-tracker-DXHPFe9J.js';
2
- import { i as SQLiteConnection, N as NodeHealth } from './types-Xb0L3o3w.js';
1
+ import { C as ChangeTracker } from './change-tracker-uG9gWvsY.js';
2
+ import { I as SQLiteConnection, N as NodeHealth } from './types-DApb7dvw.js';
3
3
  import { S as SyncTableManifest, R as ReplicationBatch, C as ConflictResolver } from './types-CjhxcjhA.js';
4
4
  import { C as ClusterCoordinator, j as CoordinatorCompatibilityMetadata, c as ReplicationGroupState } from './types-CMBcFPhb.js';
5
5