@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,8 +1,12 @@
1
- import { checkpointLog, logFrameOffset, LOG_HEADER_BYTES, readLogHeader, readValidLogFrame } from './chunk-72I5UI5C.mjs';
2
- import { BackupManager, BackupScheduler, DEFAULT_PIECE_BYTES, sendFileInPieces } from './chunk-BQTVNI5H.mjs';
3
- import { destinationWithDeadline, DEFAULT_DESTINATION_TIMEOUT_MS } from './chunk-GBM4AU24.mjs';
4
- import { DEFAULT_CHAIN_NAME, readBackupChains, readChainHeadAt, readChainHeads, appendChainRecord, appendChainHead, isBackupChainPosition } from './chunk-YVF2ZJTE.mjs';
1
+ import { verifyBackupRecord } from './chunk-7SC2TZAS.mjs';
2
+ import { BackupManager, BackupScheduler, DEFAULT_PIECE_BYTES, sendFileInPieces } from './chunk-DXXCLTFF.mjs';
3
+ import { DEFAULT_BACKUP_NAME_PREFIX, defaultStagingDir, DEFAULT_CAPTURE_INTERVAL_MS, DEFAULT_FULL_COPY_INTERVAL_MS } from './chunk-LXCGPZPA.mjs';
5
4
  import { WORKER_CANCELLED_CODE, deserializeError } from './chunk-YLAVDZCS.mjs';
5
+ import { readCycleState, writeCycleState, removeCycleState, stagedCapturePath, captureAndCheckpointTurn } from './chunk-W77EEVOT.mjs';
6
+ import { checkpointLog } from './chunk-U445A3C4.mjs';
7
+ import { logPathFor } from './chunk-2QCPVE67.mjs';
8
+ import { destinationWithDeadline, DEFAULT_DESTINATION_TIMEOUT_MS } from './chunk-DVS4QSIZ.mjs';
9
+ import { DEFAULT_CHAIN_NAME, readBackupChains, appendChainRecord, appendChainHead, readChainHeadAt, readChainHeads } from './chunk-YVF2ZJTE.mjs';
6
10
  import { randomHex } from './chunk-V5FUJEYY.mjs';
7
11
  import { SirannonError, ExtensionError } from './chunk-PBRXXISQ.mjs';
8
12
  import { Worker } from 'worker_threads';
@@ -10,7 +14,7 @@ import { existsSync } from 'fs';
10
14
  import { resolve, dirname, join, sep } from 'path';
11
15
  import { fileURLToPath, pathToFileURL } from 'url';
12
16
  import { AsyncLocalStorage } from 'async_hooks';
13
- import { mkdir, rm, readFile, writeFile, rename, stat, open } from 'fs/promises';
17
+ import { mkdir, rm, stat } from 'fs/promises';
14
18
  import { createRequire } from 'module';
15
19
 
16
20
  // src/core/driver/extension.ts
@@ -403,196 +407,6 @@ function clearPendingTimers(entry) {
403
407
  if (entry.timer) clearTimeout(entry.timer);
404
408
  if (entry.graceTimer) clearTimeout(entry.graceTimer);
405
409
  }
406
- var READ_CHUNK_BYTES = 4 * 1024 * 1024;
407
- async function openForReading(path) {
408
- try {
409
- return await open(path, "r");
410
- } catch (err) {
411
- if (err.code === "ENOENT") return void 0;
412
- throw err;
413
- }
414
- }
415
- async function readLogFileHeader(logPath) {
416
- const file = await openForReading(logPath);
417
- if (!file) return void 0;
418
- try {
419
- const buffer = Buffer.allocUnsafe(LOG_HEADER_BYTES);
420
- const { bytesRead } = await file.read(buffer, 0, LOG_HEADER_BYTES, 0);
421
- if (bytesRead < LOG_HEADER_BYTES) return void 0;
422
- return readLogHeader(buffer);
423
- } finally {
424
- await file.close();
425
- }
426
- }
427
- async function scanLogFrames(logPath, header, from) {
428
- const stopped = {
429
- lastCommitFrame: from.frame,
430
- endOffset: logFrameOffset(from.frame + 1, header.frameBytes),
431
- checksum: from.checksum
432
- };
433
- const file = await openForReading(logPath);
434
- if (!file) return stopped;
435
- try {
436
- const { size } = await file.stat();
437
- const framesInFile = Math.max(0, Math.floor((size - LOG_HEADER_BYTES) / header.frameBytes));
438
- const framesPerChunk = Math.max(1, Math.floor(READ_CHUNK_BYTES / header.frameBytes));
439
- const buffer = Buffer.allocUnsafe(framesPerChunk * header.frameBytes);
440
- const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
441
- let running = from.checksum;
442
- let found = stopped;
443
- let frame = from.frame + 1;
444
- while (frame <= framesInFile) {
445
- const frames = Math.min(framesPerChunk, framesInFile - frame + 1);
446
- const wanted = frames * header.frameBytes;
447
- let filled = 0;
448
- while (filled < wanted) {
449
- const { bytesRead } = await file.read(
450
- buffer,
451
- filled,
452
- wanted - filled,
453
- logFrameOffset(frame, header.frameBytes) + filled
454
- );
455
- if (bytesRead === 0) break;
456
- filled += bytesRead;
457
- }
458
- const readable = Math.floor(filled / header.frameBytes);
459
- if (readable === 0) break;
460
- for (let inChunk = 0; inChunk < readable; inChunk++) {
461
- const read = readValidLogFrame(view, inChunk * header.frameBytes, header, running);
462
- if (!read) return found;
463
- running = read.checksum;
464
- if (read.frame.databasePages !== 0) {
465
- found = {
466
- lastCommitFrame: frame + inChunk,
467
- endOffset: logFrameOffset(frame + inChunk + 1, header.frameBytes),
468
- checksum: running
469
- };
470
- }
471
- }
472
- frame += readable;
473
- }
474
- return found;
475
- } finally {
476
- await file.close();
477
- }
478
- }
479
- async function copyLogRange(logPath, startOffset, endOffset, destPath) {
480
- const source = await open(logPath, "r");
481
- try {
482
- const dest = await open(destPath, "w");
483
- try {
484
- const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, Math.max(endOffset - startOffset, 1)));
485
- let at = startOffset;
486
- let written = 0;
487
- while (at < endOffset) {
488
- const wanted = Math.min(buffer.byteLength, endOffset - at);
489
- const { bytesRead } = await source.read(buffer, 0, wanted, at);
490
- if (bytesRead === 0) {
491
- throw new SirannonError(
492
- `The write-ahead log '${logPath}' ends at byte ${at} while the frames captured from it run to byte ${endOffset}, so it lost frames while Sirannon was reading it. Another connection checkpointed the log. Route every write and every checkpoint through Sirannon, and take a fresh full copy so a new chain starts from a known state.`,
493
- "BACKUP_LOG_REWOUND"
494
- );
495
- }
496
- let put = 0;
497
- while (put < bytesRead) {
498
- const { bytesWritten } = await dest.write(buffer, put, bytesRead - put);
499
- if (bytesWritten === 0) {
500
- throw new SirannonError(
501
- `The capture of '${logPath}' could write no more than ${written + put} of its ${endOffset - startOffset} bytes into '${destPath}'. Check the free space and the permissions on that directory.`,
502
- "BACKUP_ERROR"
503
- );
504
- }
505
- put += bytesWritten;
506
- }
507
- at += bytesRead;
508
- written += bytesRead;
509
- }
510
- return written;
511
- } finally {
512
- await dest.close();
513
- }
514
- } finally {
515
- await source.close();
516
- }
517
- }
518
- function cursorChecksum(cursor) {
519
- return { first: cursor.checksum1, second: cursor.checksum2 };
520
- }
521
- function sameLog(header, cursor) {
522
- return header.salt1 === cursor.salt1 && header.salt2 === cursor.salt2;
523
- }
524
-
525
- // src/core/backup/cycle-capture.ts
526
- function stagedCapturePath(stagingDir, sequence) {
527
- return join(stagingDir, `capture-${sequence}.wal`);
528
- }
529
- function rewoundError(request, detail) {
530
- return new SirannonError(
531
- `The write-ahead log of '${request.sourcePath}' ${detail}, so the frames written before it did are in no backup. Another connection checkpointed the log, or the database file was replaced. Route every write and every checkpoint through Sirannon, and take a fresh full copy so a new chain starts from a known state.`,
532
- "BACKUP_LOG_REWOUND"
533
- );
534
- }
535
- async function captureLogFrames(request) {
536
- const startedAt = Date.now();
537
- const cursor = request.cursor;
538
- const header = await readLogFileHeader(request.logPath);
539
- const holdsEveryCapturedFrame = cursor !== null && header !== void 0 && sameLog(header, cursor);
540
- const newLogAllowed = cursor === null || cursor.checkpointed || request.expectNewLog;
541
- if (!holdsEveryCapturedFrame && !newLogAllowed && cursor) {
542
- throw rewoundError(
543
- request,
544
- header ? `restarted at checkpoint sequence ${header.logSequence} while the chain reaches frame ${cursor.lastFrame} of sequence ${cursor.logSequence}` : `holds no frames while the chain reaches frame ${cursor.lastFrame} of sequence ${cursor.logSequence}`
545
- );
546
- }
547
- if (!header) return void 0;
548
- const from = cursor && holdsEveryCapturedFrame ? { frame: cursor.lastFrame, checksum: cursorChecksum(cursor) } : { frame: 0, checksum: header.checksum };
549
- const scan = await scanLogFrames(request.logPath, header, from);
550
- if (scan.lastCommitFrame <= from.frame) {
551
- if (from.frame > 0 && scan.lastCommitFrame < from.frame) {
552
- throw rewoundError(
553
- request,
554
- `now ends at frame ${scan.lastCommitFrame} while the chain reaches frame ${from.frame}`
555
- );
556
- }
557
- return void 0;
558
- }
559
- const firstFrame = from.frame + 1;
560
- const startOffset = firstFrame === 1 ? 0 : logFrameOffset(firstFrame, header.frameBytes);
561
- const byteLength = await copyLogRange(
562
- request.logPath,
563
- startOffset,
564
- scan.endOffset,
565
- stagedCapturePath(request.stagingDir, request.sequence)
566
- );
567
- const capturedAt = Date.now();
568
- return {
569
- name: `${request.namePrefix}-${request.chainId}-${String(request.sequence).padStart(6, "0")}.wal`,
570
- runId: randomHex(8),
571
- sequence: request.sequence,
572
- position: {
573
- logSequence: header.logSequence,
574
- salt1: header.salt1,
575
- salt2: header.salt2,
576
- firstFrame,
577
- lastFrame: scan.lastCommitFrame
578
- },
579
- cursor: {
580
- logSequence: header.logSequence,
581
- salt1: header.salt1,
582
- salt2: header.salt2,
583
- lastFrame: scan.lastCommitFrame,
584
- checksum1: scan.checksum.first,
585
- checksum2: scan.checksum.second,
586
- checkpointed: false
587
- },
588
- startedAt,
589
- capturedAt,
590
- copyMs: capturedAt - startedAt,
591
- frameCount: scan.lastCommitFrame - from.frame,
592
- byteLength,
593
- pageSize: header.pageSize
594
- };
595
- }
596
410
 
597
411
  // src/core/backup/preferred-node.ts
598
412
  function preferredBackupNode(membership, preference) {
@@ -726,15 +540,209 @@ async function logGrownPastLimit(logPath, maxBytes, databaseId) {
726
540
  "BACKUP_CHAIN_BROKEN"
727
541
  );
728
542
  }
543
+ async function chainLostFromList(request, chainName, state) {
544
+ return chainMissingFromList(request.destination, chainName, state.chainId, request.databaseId, state.headIndex);
545
+ }
729
546
 
730
- // src/core/backup/cycle-options.ts
731
- var DEFAULT_CAPTURE_INTERVAL_MS = 6e4;
732
- var DEFAULT_FULL_COPY_INTERVAL_MS = 24 * 60 * 60 * 1e3;
733
- var DEFAULT_BACKUP_NAME_PREFIX = "sirannon-backup";
734
- function defaultStagingDir(sourcePath) {
735
- return `${sourcePath}-backup`;
547
+ // src/core/backup/cycle-callbacks.ts
548
+ function reportQuietly(callback, value) {
549
+ if (!callback) return;
550
+ try {
551
+ callback(value);
552
+ } catch {
553
+ }
736
554
  }
737
555
 
556
+ // src/core/backup/cycle-status.ts
557
+ var BackupCycleStatusRecorder = class {
558
+ turnRunning = false;
559
+ turnStartedAt = 0;
560
+ latestProgress;
561
+ latestRun;
562
+ latestSkip;
563
+ latestError;
564
+ /** Marks a turn as under way and clears the progress the previous turn recorded. */
565
+ turnStarted() {
566
+ this.turnRunning = true;
567
+ this.turnStartedAt = Date.now();
568
+ this.latestProgress = void 0;
569
+ }
570
+ /** Marks the turn as finished. A caller then reads the outcome from the last run, skip, or failure. */
571
+ turnFinished() {
572
+ this.turnRunning = false;
573
+ this.turnStartedAt = 0;
574
+ this.latestProgress = void 0;
575
+ }
576
+ /** Records how far the turn under way has got. */
577
+ progressed(progress) {
578
+ this.latestProgress = progress;
579
+ }
580
+ /** Records what a finished turn wrote. */
581
+ ran(report) {
582
+ this.latestRun = report;
583
+ }
584
+ /** Records a turn the cycle passed over. */
585
+ skipped(skip) {
586
+ this.latestSkip = skip;
587
+ }
588
+ /**
589
+ * Records a failure, under the code a caller matches on, along with how far
590
+ * the run had got and which chain it was extending.
591
+ *
592
+ * @param err - What the turn failed with.
593
+ * @param chainId - The chain the cycle was extending, where it held one.
594
+ */
595
+ failed(err, chainId) {
596
+ const at = Date.now();
597
+ this.latestError = {
598
+ code: err instanceof SirannonError ? err.code : "BACKUP_ERROR",
599
+ message: err.message,
600
+ at,
601
+ durationMs: this.turnStartedAt === 0 ? 0 : at - this.turnStartedAt,
602
+ ...chainId === void 0 ? {} : { chainId },
603
+ ...this.latestProgress === void 0 ? {} : { progress: this.latestProgress }
604
+ };
605
+ }
606
+ /**
607
+ * Reads everything recorded so far.
608
+ *
609
+ * @param chainId - The chain the cycle is extending, where it has one.
610
+ * @returns What the cycle is doing, and what its recent turns produced.
611
+ */
612
+ read(chainId) {
613
+ return {
614
+ running: this.turnRunning,
615
+ ...chainId === void 0 ? {} : { chainId },
616
+ ...this.latestProgress === void 0 ? {} : { progress: this.latestProgress },
617
+ ...this.latestRun === void 0 ? {} : { lastRun: this.latestRun },
618
+ ...this.latestSkip === void 0 ? {} : { lastSkip: this.latestSkip },
619
+ ...this.latestError === void 0 ? {} : { lastError: this.latestError }
620
+ };
621
+ }
622
+ };
623
+
624
+ // src/core/backup/cycle-reporting.ts
625
+ var BackupTurnLog = class {
626
+ constructor(audience, logPath) {
627
+ this.audience = audience;
628
+ this.logPath = logPath;
629
+ }
630
+ record = new BackupCycleStatusRecorder();
631
+ announced = /* @__PURE__ */ new Set();
632
+ /**
633
+ * Records a finished run and passes it to the caller.
634
+ *
635
+ * @param report - What that run produced.
636
+ */
637
+ ran(report) {
638
+ this.record.ran(report);
639
+ reportQuietly(this.audience.onRun, report);
640
+ }
641
+ /**
642
+ * Records how far the run under way has reached and passes that to the caller.
643
+ *
644
+ * @param progress - The counters that run has reached.
645
+ */
646
+ progressed(progress) {
647
+ this.record.progressed(progress);
648
+ reportQuietly(this.audience.onProgress, progress);
649
+ }
650
+ /** Marks a turn as under way, and forgets whatever failures the previous turn announced. */
651
+ turnStarted() {
652
+ this.announced.clear();
653
+ this.record.turnStarted();
654
+ }
655
+ /** Marks the turn as finished. */
656
+ turnFinished() {
657
+ this.record.turnFinished();
658
+ }
659
+ /**
660
+ * Records a failure as the turn's outcome and passes it to the caller.
661
+ *
662
+ * @param err - What the turn failed with.
663
+ * @param chainId - The chain to record it against, where the cycle holds one.
664
+ */
665
+ failed(err, chainId) {
666
+ this.announced.add(err);
667
+ const failure = toBackupError(err);
668
+ this.record.failed(failure, chainId);
669
+ reportQuietly(this.audience.onError, failure);
670
+ }
671
+ /**
672
+ * Answers whether this turn has already passed one failure to the caller.
673
+ *
674
+ * @param err - The failure to look for.
675
+ * @returns Whether the caller has already heard about that failure.
676
+ */
677
+ hasAnnounced(err) {
678
+ return this.announced.has(err);
679
+ }
680
+ /**
681
+ * Records a turn the cycle passed over and passes it to the caller, stating
682
+ * how much log this node was still holding as it skipped.
683
+ *
684
+ * @param skip - Why the cycle passed the turn over.
685
+ */
686
+ async skipped(skip) {
687
+ const held = await uncapturedLogBytes(this.logPath);
688
+ const passed = held === void 0 ? skip : { ...skip, uncapturedLogBytes: held };
689
+ this.record.skipped(passed);
690
+ reportQuietly(this.audience.onSkip, passed);
691
+ }
692
+ /**
693
+ * Reads everything recorded so far.
694
+ *
695
+ * @param chainId - The chain the cycle is extending, where it has one.
696
+ * @returns What the cycle is doing, and what its recent turns produced.
697
+ */
698
+ read(chainId) {
699
+ return this.record.read(chainId);
700
+ }
701
+ };
702
+
703
+ // src/core/backup/cycle-scheduling.ts
704
+ var BackupCycleTimer = class {
705
+ handle = null;
706
+ /**
707
+ * Starts repeating. At an interval of zero or less the cycle takes a turn
708
+ * only when somebody asks it to.
709
+ *
710
+ * @param intervalMs - Milliseconds between one turn and the next.
711
+ * @param tick - Called on every interval.
712
+ */
713
+ arm(intervalMs, tick) {
714
+ this.disarm();
715
+ if (intervalMs <= 0) return;
716
+ this.handle = setInterval(tick, intervalMs);
717
+ this.handle.unref?.();
718
+ }
719
+ /** Stops repeating. */
720
+ disarm() {
721
+ if (!this.handle) return;
722
+ clearInterval(this.handle);
723
+ this.handle = null;
724
+ }
725
+ };
726
+ var SerialTurns = class {
727
+ inFlight = Promise.resolve();
728
+ /**
729
+ * Starts an operation once every turn asked for before it has settled.
730
+ *
731
+ * @param op - The turn to take.
732
+ * @returns Whatever that turn produced.
733
+ */
734
+ run(op) {
735
+ const turn = this.inFlight.then(op, op);
736
+ this.inFlight = turn.then(
737
+ () => {
738
+ },
739
+ () => {
740
+ }
741
+ );
742
+ return turn;
743
+ }
744
+ };
745
+
738
746
  // src/core/backup/cycle-standdown.ts
739
747
  async function sendBeforeStandDown(cycle) {
740
748
  try {
@@ -762,43 +770,13 @@ async function releaseChainPastLogLimit(cycle) {
762
770
  cycle.report(lost);
763
771
  return true;
764
772
  }
765
- var STATE_FILE_NAME = "cycle.json";
766
- function isWholeNumber(value) {
767
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
768
- }
769
- function isCursor(value) {
770
- const cursor = value;
771
- return isWholeNumber(cursor?.logSequence) && isWholeNumber(cursor.salt1) && isWholeNumber(cursor.salt2) && isWholeNumber(cursor.lastFrame) && isWholeNumber(cursor.checksum1) && isWholeNumber(cursor.checksum2) && typeof cursor.checkpointed === "boolean";
772
- }
773
- function isPendingCapture(value) {
774
- const pending = value;
775
- return typeof pending?.name === "string" && typeof pending.runId === "string" && isWholeNumber(pending.sequence) && pending.sequence >= 1 && isBackupChainPosition(pending.position) && isCursor(pending.cursor) && isWholeNumber(pending.startedAt) && isWholeNumber(pending.capturedAt) && isWholeNumber(pending.copyMs) && isWholeNumber(pending.frameCount) && isWholeNumber(pending.byteLength) && isWholeNumber(pending.pageSize);
776
- }
777
- function isCycleState(value) {
778
- const state = value;
779
- return typeof state?.chainName === "string" && typeof state.chainId === "string" && isWholeNumber(state.chainStartedAt) && (state.headIndex === void 0 || isWholeNumber(state.headIndex)) && isWholeNumber(state.records) && (state.cursor === null || isCursor(state.cursor)) && (state.pending === null || isPendingCapture(state.pending)) && typeof state.closedCleanly === "boolean";
780
- }
781
- function cycleStatePath(stagingDir) {
782
- return join(stagingDir, STATE_FILE_NAME);
783
- }
784
- async function readCycleState(stagingDir) {
785
- try {
786
- const text = await readFile(cycleStatePath(stagingDir), "utf8");
787
- const state = JSON.parse(text);
788
- return isCycleState(state) ? state : void 0;
789
- } catch {
790
- return void 0;
791
- }
792
- }
793
- async function writeCycleState(stagingDir, state) {
794
- const path = cycleStatePath(stagingDir);
795
- await mkdir(dirname(path), { recursive: true });
796
- const staged = `${path}.writing`;
797
- await writeFile(staged, JSON.stringify(state), "utf8");
798
- await rename(staged, path);
799
- }
800
- async function removeCycleState(stagingDir) {
801
- await rm(cycleStatePath(stagingDir), { force: true });
773
+ async function confirmChainStillListed(grip) {
774
+ if (grip.verified || !grip.state) return grip.verified;
775
+ const lost = await chainLostFromList(grip.request, grip.chainName, grip.state);
776
+ if (!lost) return true;
777
+ await grip.discardState();
778
+ grip.report(lost);
779
+ return false;
802
780
  }
803
781
  async function startChain(request, chainName, namePrefix, previousChainId) {
804
782
  const chainId = randomHex(8);
@@ -814,7 +792,8 @@ async function startChain(request, chainName, namePrefix, previousChainId) {
814
792
  ...request.restartLimit === void 0 ? {} : { restartLimit: request.restartLimit },
815
793
  ...request.stallTimeoutMs === void 0 ? {} : { stallTimeoutMs: request.stallTimeoutMs },
816
794
  ...request.destinationTimeoutMs === void 0 ? {} : { destinationTimeoutMs: request.destinationTimeoutMs },
817
- ...request.noProgressStepLimit === void 0 ? {} : { noProgressStepLimit: request.noProgressStepLimit }
795
+ ...request.noProgressStepLimit === void 0 ? {} : { noProgressStepLimit: request.noProgressStepLimit },
796
+ ...request.onProgress === void 0 ? {} : { onProgress: request.onProgress }
818
797
  });
819
798
  const base = {
820
799
  kind: "full",
@@ -845,8 +824,15 @@ async function transferCapture(request, chainName, chainId, pending, recordIndex
845
824
  pending.name,
846
825
  pieceBytes,
847
826
  request.fingerprint ?? true,
848
- () => {
849
- }
827
+ (piecesWritten, bytesWritten) => request.onProgress?.({
828
+ runId: pending.runId,
829
+ phase: "transfer",
830
+ totalPages: pending.frameCount,
831
+ remainingPages: 0,
832
+ restarts: 0,
833
+ piecesWritten,
834
+ bytesWritten
835
+ })
850
836
  );
851
837
  const finishedAt = Date.now();
852
838
  const record = {
@@ -907,20 +893,24 @@ async function sendStagedCapture(request, chainName, stagingDir, state) {
907
893
  request.onRun?.(report);
908
894
  return report;
909
895
  }
896
+ async function beginReplacementChain(request, chainName, namePrefix, stagingDir, previousChainId) {
897
+ const started = await startChain(request, chainName, namePrefix, previousChainId);
898
+ const state = {
899
+ chainName,
900
+ chainId: started.chainId,
901
+ chainStartedAt: started.startedAt,
902
+ headIndex: started.headIndex,
903
+ records: 1,
904
+ cursor: null,
905
+ pending: null,
906
+ closedCleanly: false
907
+ };
908
+ await writeCycleState(stagingDir, state);
909
+ return { state, report: started.report };
910
+ }
910
911
 
911
912
  // src/core/backup/cycle.ts
912
913
  var BackupCycle = class {
913
- /** @internal */
914
- constructor(request) {
915
- this.request = request;
916
- this.chainName = request.chainName ?? DEFAULT_CHAIN_NAME;
917
- this.namePrefix = request.namePrefix ?? DEFAULT_BACKUP_NAME_PREFIX;
918
- this.stagingDir = request.stagingDir ?? defaultStagingDir(request.sourcePath);
919
- this.logPath = `${request.sourcePath}-wal`;
920
- this.intervalMs = request.intervalMs ?? DEFAULT_CAPTURE_INTERVAL_MS;
921
- this.fullCopyIntervalMs = request.fullCopyIntervalMs ?? DEFAULT_FULL_COPY_INTERVAL_MS;
922
- this.preferredNode = request.preferredNode ?? "replica";
923
- }
924
914
  chainName;
925
915
  namePrefix;
926
916
  stagingDir;
@@ -928,15 +918,34 @@ var BackupCycle = class {
928
918
  intervalMs;
929
919
  fullCopyIntervalMs;
930
920
  preferredNode;
931
- timer = null;
921
+ timer = new BackupCycleTimer();
932
922
  state = null;
933
- inFlight = Promise.resolve();
923
+ queued = null;
924
+ turns = new SerialTurns();
934
925
  started = false;
935
926
  verified = false;
936
927
  busy = false;
937
928
  captured = false;
938
929
  sendRefused = false;
939
930
  stopped = false;
931
+ request;
932
+ turnLog;
933
+ /** @internal */
934
+ constructor(request) {
935
+ this.logPath = logPathFor(request.sourcePath);
936
+ this.turnLog = new BackupTurnLog(request, this.logPath);
937
+ this.request = {
938
+ ...request,
939
+ onRun: (report) => this.turnLog.ran(report),
940
+ onProgress: (progress) => this.turnLog.progressed(progress)
941
+ };
942
+ this.chainName = request.chainName ?? DEFAULT_CHAIN_NAME;
943
+ this.namePrefix = request.namePrefix ?? DEFAULT_BACKUP_NAME_PREFIX;
944
+ this.stagingDir = request.stagingDir ?? defaultStagingDir(request.sourcePath);
945
+ this.intervalMs = request.intervalMs ?? DEFAULT_CAPTURE_INTERVAL_MS;
946
+ this.fullCopyIntervalMs = request.fullCopyIntervalMs ?? DEFAULT_FULL_COPY_INTERVAL_MS;
947
+ this.preferredNode = request.preferredNode ?? "replica";
948
+ }
940
949
  /**
941
950
  * Picks a chain up where the previous run left it, or starts a new one with a
942
951
  * full copy, and then repeats on the interval. A chain picked up takes an
@@ -957,22 +966,33 @@ var BackupCycle = class {
957
966
  this.report(err);
958
967
  }
959
968
  });
960
- this.arm();
969
+ if (!this.stopped) this.timer.arm(this.intervalMs, () => void this.tick());
961
970
  }
962
971
  /**
963
- * Runs one turn now. A turn sends any capture still waiting, reads the frames
964
- * written since the previous turn, and checkpoints the log.
972
+ * Takes one turn now. A turn sends any capture still waiting, reads the
973
+ * frames written since the previous turn, and then checkpoints the log.
974
+ *
975
+ * One turn waits at a time. A call made while a turn is under way queues one
976
+ * behind it, and every call after that joins the queued turn, which reads the
977
+ * log once it begins and so covers their writes as well. Without that bound,
978
+ * a caller asking repeatedly during a long full copy would build a queue of
979
+ * turns with nothing left for any of them to capture.
965
980
  *
966
- * A turn on a node its group backs up from somewhere else writes nothing and
967
- * reports a skip instead.
981
+ * A turn on a node whose group backs up from somewhere else writes nothing
982
+ * and reports a skip.
968
983
  *
969
984
  * @returns What the turn wrote, or undefined where it wrote nothing.
970
985
  */
971
986
  runOnce() {
972
- return this.runTurn(async () => {
987
+ const queued = this.queued;
988
+ if (queued) return queued;
989
+ const turn = this.runTurn(async () => {
990
+ this.queued = null;
973
991
  if (!await this.takesTheTurn()) return void 0;
974
992
  return this.turnOrStartOver();
975
993
  });
994
+ this.queued = turn;
995
+ return turn;
976
996
  }
977
997
  /**
978
998
  * Stops the cycle. It captures the log one final time first, so the writes
@@ -982,10 +1002,7 @@ var BackupCycle = class {
982
1002
  async stop() {
983
1003
  if (this.stopped) return;
984
1004
  this.stopped = true;
985
- if (this.timer) {
986
- clearInterval(this.timer);
987
- this.timer = null;
988
- }
1005
+ this.timer.disarm();
989
1006
  await this.runTurn(async () => {
990
1007
  const state = this.state;
991
1008
  if (!state) return;
@@ -1006,16 +1023,51 @@ var BackupCycle = class {
1006
1023
  chains() {
1007
1024
  return readBackupChains(this.request.destination, this.chainName);
1008
1025
  }
1026
+ /**
1027
+ * Reads what the cycle is doing at this moment, and what its recent turns
1028
+ * produced.
1029
+ *
1030
+ * A caller following a long full copy reads this without having to wait on
1031
+ * the turn, and it answers between turns as well.
1032
+ *
1033
+ * @returns Whether a turn is under way, how far it has got, and the last run, skip, and failure.
1034
+ */
1035
+ status() {
1036
+ return this.turnLog.read(this.state?.chainId);
1037
+ }
1038
+ /**
1039
+ * Reads one of this chain's backups back out of the destination and compares
1040
+ * it against the record the backup that wrote it left behind.
1041
+ *
1042
+ * @param name - Name the backup is stored under.
1043
+ * @returns The pieces read, the bytes they add up to, and the digest where the backup recorded one.
1044
+ */
1045
+ async verify(name) {
1046
+ return verifyBackupRecord(this.request.destination, await this.chains(), name);
1047
+ }
1048
+ /**
1049
+ * Runs one turn and passes whatever broke it to the operator. A deeper step
1050
+ * announces some failures itself and then raises them, so this announces a
1051
+ * failure only where no step has announced it already. The operator therefore
1052
+ * hears each failure exactly once. Sirannon empties a log the turn has
1053
+ * outgrown once all of that is done, and the report of writes reaching no
1054
+ * backup replaces whatever failure the turn recorded.
1055
+ */
1009
1056
  runTurn(op) {
1010
- return this.serialise(async () => {
1057
+ return this.turns.run(async () => {
1011
1058
  this.busy = true;
1012
1059
  this.captured = false;
1013
1060
  this.sendRefused = false;
1061
+ this.turnLog.turnStarted();
1014
1062
  try {
1015
1063
  return await op();
1064
+ } catch (err) {
1065
+ if (!this.turnLog.hasAnnounced(err)) this.report(err);
1066
+ throw err;
1016
1067
  } finally {
1017
1068
  if (!this.captured) await this.releaseLogPastLimit();
1018
1069
  this.busy = false;
1070
+ this.turnLog.turnFinished();
1019
1071
  }
1020
1072
  });
1021
1073
  }
@@ -1062,29 +1114,9 @@ var BackupCycle = class {
1062
1114
  }
1063
1115
  return false;
1064
1116
  }
1065
- /** Passes a skipped turn on, counting the log this node was holding as it skipped. */
1117
+ /** Passes a skipped turn on, where the cycle has a reason to give for it. */
1066
1118
  async reportSkip(skip) {
1067
- if (!skip || !this.request.onSkip) return;
1068
- const held = await uncapturedLogBytes(this.logPath);
1069
- try {
1070
- this.request.onSkip(held === void 0 ? skip : { ...skip, uncapturedLogBytes: held });
1071
- } catch {
1072
- }
1073
- }
1074
- serialise(op) {
1075
- const run = this.inFlight.then(op, op);
1076
- this.inFlight = run.then(
1077
- () => {
1078
- },
1079
- () => {
1080
- }
1081
- );
1082
- return run;
1083
- }
1084
- arm() {
1085
- if (this.stopped || this.intervalMs <= 0) return;
1086
- this.timer = setInterval(() => void this.tick(), this.intervalMs);
1087
- this.timer.unref?.();
1119
+ if (skip) await this.turnLog.skipped(skip);
1088
1120
  }
1089
1121
  async tick() {
1090
1122
  if (this.stopped) return;
@@ -1092,35 +1124,18 @@ var BackupCycle = class {
1092
1124
  await this.reportSkip(previousRunStillActive());
1093
1125
  return;
1094
1126
  }
1095
- try {
1096
- await this.runOnce();
1097
- } catch (err) {
1098
- this.report(err);
1099
- }
1127
+ await this.runOnce().catch(() => {
1128
+ });
1100
1129
  }
1101
- /**
1102
- * Checks that the destination still lists the chain the state file names,
1103
- * including one this cycle started itself, because a record appended under a
1104
- * chain no listing names is a record no restore reaches. A check that cannot
1105
- * reach the destination leaves the chain unverified for the next turn to try
1106
- * again before it appends anything.
1107
- */
1108
1130
  async verifyChain() {
1109
- if (this.verified || !this.state) return;
1110
- const request = this.request;
1111
- const lost = await chainMissingFromList(
1112
- request.destination,
1113
- this.chainName,
1114
- this.state.chainId,
1115
- request.databaseId,
1116
- this.state.headIndex
1117
- );
1118
- if (!lost) {
1119
- this.verified = true;
1120
- return;
1121
- }
1122
- await this.discardState();
1123
- this.report(lost);
1131
+ this.verified = await confirmChainStillListed({
1132
+ request: this.request,
1133
+ chainName: this.chainName,
1134
+ state: this.state,
1135
+ verified: this.verified,
1136
+ discardState: () => this.discardState(),
1137
+ report: (err) => this.report(err)
1138
+ });
1124
1139
  }
1125
1140
  async discardState() {
1126
1141
  const pending = this.state?.pending;
@@ -1131,14 +1146,19 @@ var BackupCycle = class {
1131
1146
  * Runs one turn. A log that restarted before the capture reached it leaves a
1132
1147
  * chain nothing can extend, so this starts a fresh one. The caller still gets
1133
1148
  * the error: those writes are in no backup, and an operator has to hear that.
1149
+ * Where the replacement chain also fails to start, Sirannon records that
1150
+ * second failure against the chain the restart broke, because the cycle holds
1151
+ * no chain of its own by then and an operator reading the status has to know
1152
+ * which one they have lost.
1134
1153
  */
1135
1154
  async turnOrStartOver() {
1136
1155
  try {
1137
1156
  return await this.turn();
1138
1157
  } catch (err) {
1139
1158
  if (startsAFreshChain(err)) {
1140
- this.report(err);
1141
- await this.replaceChain().catch((chainErr) => this.report(chainErr));
1159
+ const broken = this.state?.chainId;
1160
+ this.reportAgainstChain(err, broken);
1161
+ await this.replaceChain().catch((chainErr) => this.reportAgainstChain(chainErr, broken));
1142
1162
  }
1143
1163
  throw err;
1144
1164
  }
@@ -1156,58 +1176,46 @@ var BackupCycle = class {
1156
1176
  last = await this.sendWaitingCapture() ?? last;
1157
1177
  return last;
1158
1178
  }
1179
+ /**
1180
+ * Passes a failure to the operator and records it as the turn's outcome.
1181
+ *
1182
+ * @param err - What the turn failed with.
1183
+ * @param chainId - The chain to record it against, which defaults to the one the cycle holds now.
1184
+ */
1159
1185
  report(err) {
1160
- if (!this.request.onError) return;
1161
- try {
1162
- this.request.onError(toBackupError(err));
1163
- } catch {
1164
- }
1186
+ this.turnLog.failed(err, this.state?.chainId);
1187
+ }
1188
+ /** Records a failure against a chain the cycle may already have let go of. */
1189
+ reportAgainstChain(err, chainId) {
1190
+ this.turnLog.failed(err, chainId);
1165
1191
  }
1166
1192
  async replaceChain() {
1167
1193
  const previousChainId = this.state?.chainId;
1168
1194
  await this.discardState();
1169
- const started = await startChain(this.request, this.chainName, this.namePrefix, previousChainId);
1195
+ const begun = await beginReplacementChain(
1196
+ this.request,
1197
+ this.chainName,
1198
+ this.namePrefix,
1199
+ this.stagingDir,
1200
+ previousChainId
1201
+ );
1170
1202
  this.verified = false;
1171
- this.state = {
1172
- chainName: this.chainName,
1173
- chainId: started.chainId,
1174
- chainStartedAt: started.startedAt,
1175
- headIndex: started.headIndex,
1176
- records: 1,
1177
- cursor: null,
1178
- pending: null,
1179
- closedCleanly: false
1180
- };
1181
- await writeCycleState(this.stagingDir, this.state);
1203
+ this.state = begun.state;
1182
1204
  this.captured = true;
1183
- this.request.onRun?.(started.report);
1184
- return started.report;
1205
+ this.request.onRun?.(begun.report);
1206
+ return begun.report;
1185
1207
  }
1186
1208
  async captureAndCheckpoint() {
1187
1209
  const state = this.state;
1188
1210
  if (!state) return;
1189
- await this.request.runExclusive(async () => {
1190
- const captured = await captureLogFrames({
1191
- sourcePath: this.request.sourcePath,
1192
- logPath: this.logPath,
1193
- stagingDir: this.stagingDir,
1194
- chainId: state.chainId,
1195
- namePrefix: this.namePrefix,
1196
- sequence: state.records,
1197
- cursor: state.cursor,
1198
- expectNewLog: state.closedCleanly
1199
- });
1200
- if (captured) {
1201
- state.pending = captured;
1202
- await writeCycleState(this.stagingDir, state);
1203
- }
1204
- const checkpointed = (await checkpointLog(this.request.acquireWriter())).emptied;
1205
- const cursor = captured?.cursor ?? state.cursor;
1206
- if (cursor) cursor.checkpointed = checkpointed;
1207
- state.closedCleanly = false;
1208
- await writeCycleState(this.stagingDir, state);
1209
- this.captured = true;
1211
+ await captureAndCheckpointTurn({
1212
+ request: this.request,
1213
+ state,
1214
+ logPath: this.logPath,
1215
+ stagingDir: this.stagingDir,
1216
+ namePrefix: this.namePrefix
1210
1217
  });
1218
+ this.captured = true;
1211
1219
  }
1212
1220
  /**
1213
1221
  * Sends the capture staged against the chain this cycle holds. A turn offers
@@ -1314,7 +1322,7 @@ function nodeBackupEngine(streaming) {
1314
1322
  copyToDestination: (conn, request) => manager.copyToDestination(conn, request),
1315
1323
  streamsToDestination: () => manager.streamsToDestination(),
1316
1324
  createCycle: (request) => createBackupCycle(request),
1317
- schedule: (conn, options, runExclusive) => scheduler.schedule(conn, options, runExclusive)
1325
+ schedule: (conn, request) => scheduler.schedule(conn, request)
1318
1326
  };
1319
1327
  }
1320
1328