@vrtmrz/livesync-commonlib 0.3.0 → 0.3.2

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 (73) hide show
  1. package/README.md +27 -4
  2. package/dist/API/DirectFileManipulatorV2.d.ts +1 -0
  3. package/dist/API/DirectFileManipulatorV2.js +6 -12
  4. package/dist/API/processSetting.d.ts +1 -0
  5. package/dist/common/models/setting.const.defaults.js +1 -0
  6. package/dist/common/models/setting.const.qr.js +1 -0
  7. package/dist/common/models/setting.policy.d.ts +4 -0
  8. package/dist/common/models/setting.policy.js +1 -0
  9. package/dist/common/models/setting.type.d.ts +2 -0
  10. package/dist/common/models/tweak.definition.d.ts +1 -0
  11. package/dist/common/pathNamespace.d.ts +4 -0
  12. package/dist/common/pathNamespace.js +10 -0
  13. package/dist/common/settingConstants.d.ts +1 -0
  14. package/dist/common/settingConstants.js +4 -0
  15. package/dist/platform/AmbiguousNormalisedPathError.d.ts +16 -0
  16. package/dist/platform/AmbiguousNormalisedPathError.js +21 -0
  17. package/dist/platform/StoragePathResolver.d.ts +15 -0
  18. package/dist/platform/StoragePathResolver.js +113 -0
  19. package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.d.ts +74 -0
  20. package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.js +169 -0
  21. package/dist/platform/browser/index.d.ts +1 -0
  22. package/dist/platform/browser/index.js +2 -0
  23. package/dist/platform/browser/storage.d.ts +23 -0
  24. package/dist/platform/browser/storage.js +138 -0
  25. package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.d.ts +79 -0
  26. package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.js +218 -0
  27. package/dist/platform/node/index.d.ts +1 -0
  28. package/dist/platform/node/index.js +2 -0
  29. package/dist/platform/node/storage.d.ts +30 -1
  30. package/dist/platform/node/storage.js +88 -0
  31. package/dist/platform/pathComparison.d.ts +53 -0
  32. package/dist/platform/pathComparison.js +39 -0
  33. package/dist/platform/pathTypes.d.ts +154 -0
  34. package/dist/platform/pathTypes.js +0 -0
  35. package/dist/platform/storagePathResolution.d.ts +13 -0
  36. package/dist/platform/storagePathResolution.js +9 -0
  37. package/dist/replication/SyncParamsHandler.d.ts +1 -0
  38. package/dist/replication/SyncParamsHandler.js +5 -1
  39. package/dist/replication/couchdb/LiveSyncReplicator.js +5 -3
  40. package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
  41. package/dist/replication/journal/JournalReceiveFailure.js +16 -0
  42. package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
  43. package/dist/replication/journal/JournalSyncCore.js +288 -166
  44. package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
  45. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
  46. package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
  47. package/dist/replication/journal/journalCompatibility.d.ts +9 -0
  48. package/dist/replication/journal/journalCompatibility.js +58 -0
  49. package/dist/replication/journal/journalReceive.d.ts +23 -0
  50. package/dist/replication/journal/journalReceive.js +44 -0
  51. package/dist/replication/journal/journalRecords.d.ts +10 -0
  52. package/dist/replication/journal/journalRecords.js +96 -0
  53. package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
  54. package/dist/serviceModules/Rebuilder.js +2 -1
  55. package/dist/services/base/FileProcessingService.d.ts +2 -2
  56. package/dist/services/base/RemoteConnection.d.ts +2 -0
  57. package/dist/services/base/RemoteService.d.ts +1 -1
  58. package/dist/services/base/RemoteService.js +13 -5
  59. package/dist/services/implements/injectable/InjectableVaultService.d.ts +1 -1
  60. package/dist/services/lib/couchdbHttpCache.d.ts +5 -0
  61. package/dist/services/lib/couchdbHttpCache.js +36 -0
  62. package/dist/storage.d.ts +4 -0
  63. package/dist/storage.js +19 -0
  64. package/dist/string_and_binary/path.js +4 -7
  65. package/dist/worker/bgWorker.js +1 -1
  66. package/docs/adr/2026_11_shared_storage_path_resolution.md +75 -0
  67. package/docs/design_docs/storage_path_resolution.md +306 -0
  68. package/docs/development.md +11 -1
  69. package/docs/journal-receive-recovery.md +66 -0
  70. package/docs/platform-storage.md +61 -0
  71. package/docs/releasing.md +25 -0
  72. package/docs/unicode-path-identity.md +49 -17
  73. package/package.json +9 -1
@@ -11,13 +11,15 @@ import {
11
11
  import { Logger } from "../../common/logger.js";
12
12
  import {
13
13
  concatUInt8Array,
14
- escapeNewLineFromString,
15
14
  setAllItems,
16
- unescapeNewLineFromString
15
+ pickBucketSyncSettings,
16
+ pickEncryptionSettings
17
17
  } from "../../common/utils.js";
18
18
  import { shareRunningResult } from "octagonal-wheels/concurrency/lock";
19
19
  import { wrappedDeflate, wrappedInflate } from "../../pouchdb/compress.js";
20
- import { CheckPointInfoDefault } from "./JournalSyncTypes.js";
20
+ import {
21
+ CheckPointInfoDefault
22
+ } from "./JournalSyncTypes.js";
21
23
  import {
22
24
  JournalStorageReadStatuses
23
25
  } from "./objectstore/JournalStorageAdapter.js";
@@ -25,7 +27,8 @@ import {
25
27
  createSyncParamsHandler,
26
28
  SyncParamsFetchError,
27
29
  SyncParamsNotFoundError,
28
- SyncParamsUpdateError
30
+ SyncParamsUpdateError,
31
+ isSyncParamsError
29
32
  } from "../SyncParamsHandler.js";
30
33
  import { REMOTE_CHUNK_FETCHED } from "../../pouchdb/LiveSyncLocalDB.js";
31
34
  import { decryptBinary, encryptBinary } from "octagonal-wheels/encryption/encryption";
@@ -34,18 +37,16 @@ import {
34
37
  decryptBinary as decryptBinaryHKDF
35
38
  } from "octagonal-wheels/encryption/hkdf";
36
39
  import { assertDatabasePathIdentity } from "../../pouchdb/pathIdentityCompatibility.js";
37
- const RECORD_SPLIT = `
38
- `;
39
- const UNIT_SPLIT = ``;
40
- const te = new TextEncoder();
41
- function serializeDoc(doc) {
42
- if (doc._id.startsWith("h:")) {
43
- const data = doc.data;
44
- const writeData = escapeNewLineFromString(data);
45
- return te.encode(`~${doc._id}${UNIT_SPLIT}${writeData}${RECORD_SPLIT}`);
46
- }
47
- return te.encode(JSON.stringify(doc) + RECORD_SPLIT);
48
- }
40
+ import { decodeJournalRecords, serializeDoc } from "./journalRecords.js";
41
+ import { JournalReceiveFailure, isJournalReceiveFailure } from "./JournalReceiveFailure.js";
42
+ import {
43
+ partitionJournalDocuments,
44
+ selectChunksToSave,
45
+ assessChunkWrites,
46
+ journalMetadataRevisions,
47
+ selectMetadataToSave,
48
+ hasJournalWriteErrors
49
+ } from "./journalReceive.js";
49
50
  function isJournalKey(key) {
50
51
  return !key.startsWith("_");
51
52
  }
@@ -65,6 +66,38 @@ class JournalSyncCore {
65
66
  store;
66
67
  requestedStop = false;
67
68
  syncParamsHandler;
69
+ receiveTask;
70
+ stoppedJournal;
71
+ checkpointGeneration = 0;
72
+ receiveConfiguration() {
73
+ return JSON.stringify([
74
+ pickBucketSyncSettings({ ...this.currentSettings, ...this._settings }),
75
+ pickEncryptionSettings(this.currentSettings),
76
+ this.currentSettings.pathNormalizationVersion,
77
+ this.currentSettings.handleFilenameCaseSensitive
78
+ ]);
79
+ }
80
+ matchesStoppedContext(stopped) {
81
+ return stopped.database === this.db && stopped.store === this.store && stopped.configuration === this.receiveConfiguration() && stopped.checkpointGeneration === this.checkpointGeneration;
82
+ }
83
+ /** Return the current failure for presentation before an explicit single-Journal skip. */
84
+ getStoppedJournal() {
85
+ if (this.stoppedJournal && !this.matchesStoppedContext(this.stoppedJournal)) {
86
+ this.stoppedJournal = void 0;
87
+ }
88
+ return this.stoppedJournal?.snapshot;
89
+ }
90
+ /**
91
+ * Acknowledge only the exact failure presented to the user, then resume receipt.
92
+ * Stale snapshots, another active receive, and a later Stop request are rejected.
93
+ * Any subsequent Journal failure stops the resumed operation normally.
94
+ */
95
+ skipStoppedJournalAndResume(stopped, showMessage = false) {
96
+ if (!stopped || this.receiveTask || this.requestedStop || this.getStoppedJournal() !== stopped) {
97
+ return Promise.resolve(false);
98
+ }
99
+ return this.startReceive(showMessage, this.stoppedJournal);
100
+ }
68
101
  getInitialSyncParameters() {
69
102
  return Promise.resolve({
70
103
  ...DEFAULT_SYNC_PARAMETERS,
@@ -184,11 +217,13 @@ class JournalSyncCore {
184
217
  };
185
218
  }
186
219
  async updateCheckPointInfo(func) {
220
+ this.stoppedJournal = void 0;
221
+ this.checkpointGeneration++;
187
222
  const checkPointKey = `bucketsync-checkpoint-${this.hash}`;
188
223
  const old = await this.getCheckpointInfo();
189
224
  const newInfo = func(old);
190
- this._currentCheckPointInfo = newInfo;
191
225
  await this.store.set(checkPointKey, newInfo);
226
+ this._currentCheckPointInfo = newInfo;
192
227
  return newInfo;
193
228
  }
194
229
  _currentCheckPointInfo = { ...CheckPointInfoDefault };
@@ -341,6 +376,9 @@ class JournalSyncCore {
341
376
  const decrypted = await this.decryptDataV2(u, set);
342
377
  return decrypted;
343
378
  } catch (ex) {
379
+ if (isSyncParamsError(ex)) {
380
+ throw ex;
381
+ }
344
382
  Logger(`Failed to decrypt in v2. Falling back to v1: ${key}`, LOG_LEVEL_INFO);
345
383
  try {
346
384
  const r = await this.decryptDataV1(u, set);
@@ -348,8 +386,6 @@ class JournalSyncCore {
348
386
  return r;
349
387
  } catch (ex2) {
350
388
  Logger(`Could not decrypt in v1: ${key}`, LOG_LEVEL_VERBOSE);
351
- Logger(ex, LOG_LEVEL_VERBOSE);
352
- Logger(ex2, LOG_LEVEL_VERBOSE);
353
389
  throw ex2;
354
390
  }
355
391
  }
@@ -569,75 +605,67 @@ class JournalSyncCore {
569
605
  return files.sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
570
606
  }
571
607
  async processDocuments(allDocs) {
572
- let applyTotal = 0;
573
- let wholeItems = 0;
574
608
  try {
575
- const chunks = [];
576
- const docs = [];
577
- allDocs.forEach((e) => {
578
- if (e._id.startsWith("h:")) {
579
- chunks.push(e);
580
- } else {
581
- docs.push(e);
582
- }
583
- });
584
- try {
585
- const e1 = (await this.db.allDocs({ include_docs: true, keys: [...chunks.map((e) => e._id)] })).rows;
586
- const e2 = e1.map((e) => e.id ?? void 0);
587
- const existChunks = new Set(e2.filter((e) => e !== void 0));
588
- const saveChunks = chunks.filter((e) => !existChunks.has(e._id)).map((e) => ({ ...e, _rev: void 0 }));
589
- const ret = await this.db.bulkDocs(saveChunks, { new_edits: true });
590
- const saveError = ret.filter((e) => "error" in e).map((e) => e.id);
591
- saveChunks.filter((e) => saveError.indexOf(e._id) === -1).forEach(
592
- (doc) => this.env.services.context.events.emitEvent(REMOTE_CHUNK_FETCHED, doc)
593
- );
594
- await this.updateCheckPointInfo((info) => ({
595
- ...info,
596
- knownIDs: setAllItems(
597
- info.knownIDs,
598
- chunks.map((e) => this.getDocKey(e))
599
- )
600
- }));
601
- } catch (ex) {
602
- Logger(`Applying chunks failed`, LOG_LEVEL_INFO);
603
- Logger(ex, LOG_LEVEL_VERBOSE);
604
- }
605
- const params = docs.map((e) => [e._id, [e._rev]]);
606
- const docsRevs = params.reduce(
607
- (acc, [id, revs]) => {
608
- return { ...acc, [id]: [...acc[id] ?? [], ...revs] };
609
- },
610
- {}
611
- );
612
- const diffRevs = await this.db.revsDiff(docsRevs);
613
- const saveDocs = docs.filter(
614
- (e) => e._id in diffRevs && "missing" in diffRevs[e._id] && (diffRevs[e._id].missing?.indexOf(e._rev) ?? 0) !== -1
615
- );
616
- await this.db.bulkDocs(saveDocs, { new_edits: false });
617
- const writeDoc = !this.env.services.setting.currentSettings().suspendParseReplicationResult;
618
- if (writeDoc) {
619
- await this.processReplication(saveDocs);
620
- }
621
- await this.updateCheckPointInfo((info) => ({
622
- ...info,
623
- knownIDs: setAllItems(
624
- info.knownIDs,
625
- docs.map((e) => this.getDocKey(e))
626
- )
627
- }));
628
- applyTotal += saveDocs.length;
629
- wholeItems += docs.length;
630
- Logger(
631
- `Applied ${applyTotal} of ${wholeItems} docs (${wholeItems - applyTotal} skipped)`,
632
- LOG_LEVEL_VERBOSE
633
- );
609
+ await this.processDocumentsForReceive(allDocs);
634
610
  return true;
635
- } catch (ex) {
636
- Logger(`Applying journal failed`, LOG_LEVEL_INFO);
637
- Logger(ex, LOG_LEVEL_VERBOSE);
611
+ } catch {
612
+ Logger("Applying journal failed", LOG_LEVEL_INFO);
638
613
  return false;
639
614
  }
640
615
  }
616
+ async processDocumentsForReceive(allDocs, key) {
617
+ const { chunks, docs } = partitionJournalDocuments(allDocs);
618
+ const chunkRows = (await this.db.allDocs({ include_docs: true, keys: chunks.map((entry) => entry._id) })).rows;
619
+ const saveChunks = selectChunksToSave(chunks, chunkRows);
620
+ let chunkResults;
621
+ try {
622
+ chunkResults = await this.db.bulkDocs(saveChunks, { new_edits: true });
623
+ } catch (error) {
624
+ throw new JournalReceiveFailure("chunk-write", key);
625
+ }
626
+ const chunkWrites = assessChunkWrites(saveChunks, chunkResults);
627
+ chunkWrites.persisted.forEach(
628
+ (entry) => this.env.services.context.events.emitEvent(REMOTE_CHUNK_FETCHED, entry)
629
+ );
630
+ if (chunkWrites.failed) {
631
+ throw new JournalReceiveFailure("chunk-write", key);
632
+ }
633
+ await this.updateCheckPointInfo((info) => ({
634
+ ...info,
635
+ knownIDs: setAllItems(
636
+ info.knownIDs,
637
+ chunks.map((entry) => this.getDocKey(entry))
638
+ )
639
+ }));
640
+ const diffRevs = await this.db.revsDiff(journalMetadataRevisions(docs));
641
+ const saveDocs = selectMetadataToSave(docs, diffRevs);
642
+ let metadataResults;
643
+ try {
644
+ metadataResults = await this.db.bulkDocs(saveDocs, { new_edits: false });
645
+ } catch (error) {
646
+ throw new JournalReceiveFailure("metadata-write", key);
647
+ }
648
+ if (hasJournalWriteErrors(metadataResults)) {
649
+ throw new JournalReceiveFailure("metadata-write", key);
650
+ }
651
+ const writeDoc = !this.currentSettings.suspendParseReplicationResult;
652
+ if (writeDoc) {
653
+ await this.processReplication(docs);
654
+ }
655
+ await this.updateCheckPointInfo((info) => ({
656
+ ...info,
657
+ knownIDs: setAllItems(
658
+ info.knownIDs,
659
+ docs.map((entry) => this.getDocKey(entry))
660
+ )
661
+ }));
662
+ const applyTotal = saveDocs.length;
663
+ const wholeItems = docs.length;
664
+ Logger(
665
+ "Applied " + applyTotal + " of " + wholeItems + " docs (" + (wholeItems - applyTotal) + " skipped)",
666
+ LOG_LEVEL_VERBOSE
667
+ );
668
+ }
641
669
  _createReceiveReadableStream(files) {
642
670
  return new ReadableStream({
643
671
  pull: (controller) => {
@@ -657,62 +685,60 @@ class JournalSyncCore {
657
685
  return new TransformStream({
658
686
  transform: async (key, controller) => {
659
687
  count++;
660
- Logger(`Receiving Journal: ${count}`, logLevel, "receivejournal");
661
- const checkPointInfo = await this.getCheckpointInfo();
662
- if (checkPointInfo.sentFiles.has(key) || checkPointInfo.receivedFiles.has(key)) {
663
- Logger(`Receiving Journal: ${key} is already processed`, LOG_LEVEL_VERBOSE);
664
- await this.updateCheckPointInfo((info) => ({
665
- ...info,
666
- receivedFiles: info.receivedFiles.add(key)
667
- }));
688
+ Logger("Receiving Journal: " + count, logLevel, "receivejournal");
689
+ const checkpointInfo = await this.getCheckpointInfo();
690
+ if (checkpointInfo.sentFiles.has(key) || checkpointInfo.receivedFiles.has(key)) {
691
+ Logger("Receiving Journal: " + key + " is already processed", LOG_LEVEL_VERBOSE);
692
+ controller.enqueue({ key, docs: [], alreadyProcessed: true });
668
693
  return;
669
694
  }
670
- try {
671
- const encryptedData = await this.storage.download(key, true);
672
- if (encryptedData === false) {
673
- throw new Error("Download Error");
674
- }
675
- const data = await this.decryptDownloaded(
695
+ const enqueueFailure = (stage) => {
696
+ controller.enqueue({
676
697
  key,
677
- encryptedData,
678
- this.currentSettings
679
- );
680
- const decompressed = await wrappedInflate(new Uint8Array(data), { consume: true });
681
- if (decompressed.length == 0) {
682
- controller.enqueue({ key, docs: [] });
683
- return;
698
+ docs: [],
699
+ failure: new JournalReceiveFailure(stage, key)
700
+ });
701
+ };
702
+ let downloadResult;
703
+ try {
704
+ downloadResult = await this.storage.downloadWithResult(key, true);
705
+ } catch (error) {
706
+ controller.error(error);
707
+ return;
708
+ }
709
+ if (downloadResult.status === JournalStorageReadStatuses.NOT_FOUND) {
710
+ enqueueFailure("download");
711
+ return;
712
+ }
713
+ if (downloadResult.status === JournalStorageReadStatuses.UNAVAILABLE) {
714
+ controller.error(new Error("Journal download unavailable"));
715
+ return;
716
+ }
717
+ const settings = this.currentSettings;
718
+ let data;
719
+ try {
720
+ data = await this.decryptDownloaded(key, downloadResult.value, settings);
721
+ } catch (error) {
722
+ if (isSyncParamsError(error)) {
723
+ controller.error(error);
724
+ } else {
725
+ enqueueFailure("decrypt");
684
726
  }
685
- let idxFrom = 0;
686
- let idxTo = 0;
687
- const d = new TextDecoder();
688
- const result = [];
689
- do {
690
- idxTo = decompressed.indexOf(10, idxFrom);
691
- if (idxTo == -1) break;
692
- const piece = decompressed.slice(idxFrom, idxTo);
693
- const strPiece = d.decode(piece);
694
- if (strPiece.startsWith("~")) {
695
- const separatorIndex = strPiece.indexOf(UNIT_SPLIT);
696
- if (separatorIndex === -1) {
697
- throw new Error("Invalid journal chunk record: missing separator");
698
- }
699
- const idPart = strPiece.substring(1, separatorIndex);
700
- const dataPart = strPiece.substring(separatorIndex + 1);
701
- result.push({
702
- _id: idPart,
703
- data: unescapeNewLineFromString(dataPart),
704
- type: "leaf",
705
- _rev: ""
706
- });
707
- } else {
708
- result.push(JSON.parse(strPiece));
709
- }
710
- idxFrom = idxTo + 1;
711
- } while (idxTo > 0);
712
- controller.enqueue({ key, docs: result });
713
- } catch (ex) {
714
- controller.error(ex);
727
+ return;
728
+ }
729
+ let decompressed;
730
+ try {
731
+ decompressed = await wrappedInflate(new Uint8Array(data), { consume: true });
732
+ } catch {
733
+ enqueueFailure("inflate");
734
+ return;
735
+ }
736
+ const decoded = decodeJournalRecords(decompressed);
737
+ if (decoded.ok === false) {
738
+ enqueueFailure(decoded.stage);
739
+ return;
715
740
  }
741
+ controller.enqueue({ key, docs: decoded.docs });
716
742
  }
717
743
  });
718
744
  }
@@ -721,49 +747,144 @@ class JournalSyncCore {
721
747
  return new WritableStream({
722
748
  write: async (chunk) => {
723
749
  const { key, docs } = chunk;
724
- if (docs.length > 0) {
725
- const success = await this.processDocuments(docs);
726
- if (!success) {
727
- throw new Error(`Could not process downloaded journals for ${key}`);
750
+ const checkpointInfo = await this.getCheckpointInfo();
751
+ if (chunk.alreadyProcessed || checkpointInfo.sentFiles.has(key) || checkpointInfo.receivedFiles.has(key)) {
752
+ await this.updateCheckPointInfo((info) => {
753
+ const receivedFiles = new Set(info.receivedFiles);
754
+ receivedFiles.add(key);
755
+ return { ...info, receivedFiles };
756
+ });
757
+ return;
758
+ }
759
+ let failure = chunk.failure;
760
+ if (!failure && docs.length > 0) {
761
+ try {
762
+ await this.processDocumentsForReceive(docs, key);
763
+ } catch (error) {
764
+ if (isJournalReceiveFailure(error)) {
765
+ failure = error;
766
+ } else {
767
+ throw error;
768
+ }
728
769
  }
729
770
  }
730
- await this.updateCheckPointInfo((info) => ({
731
- ...info,
732
- receivedFiles: info.receivedFiles.add(key)
733
- }));
771
+ if (failure) throw new JournalReceiveFailure(failure.stage, key);
772
+ await this.updateCheckPointInfo((info) => {
773
+ const receivedFiles = new Set(info.receivedFiles);
774
+ receivedFiles.add(key);
775
+ return { ...info, receivedFiles };
776
+ });
734
777
  downloaded++;
735
778
  this.updateInfo({ arrived: downloaded, maxPullSeq: downloaded, lastSyncPullSeq: downloaded });
736
- Logger(`Processing journal: ${key} has been processed`, LOG_LEVEL_INFO);
779
+ Logger("Processing journal: " + key + " has been processed", LOG_LEVEL_INFO);
737
780
  }
738
781
  });
739
782
  }
740
- async receiveRemoteJournal(showMessage = false) {
741
- await assertDatabasePathIdentity(this.db, this.currentSettings);
783
+ /** Receive until completion, a failure, or an explicit Stop request. */
784
+ receiveRemoteJournal(showMessage = false) {
785
+ return this.receiveTask ?? this.startReceive(showMessage);
786
+ }
787
+ startReceive(showMessage, stopped) {
788
+ this.stoppedJournal = void 0;
789
+ this.requestedStop = false;
790
+ const task = Promise.resolve().then(() => this.performReceive(showMessage, stopped)).finally(() => {
791
+ if (this.receiveTask === task) this.receiveTask = void 0;
792
+ });
793
+ this.receiveTask = task;
794
+ return task;
795
+ }
796
+ async prepareStoppedJournalSkip(files, stopped) {
797
+ const checkpoint = await this.getCheckpointInfo();
798
+ const nextKey = files.find((key) => !checkpoint.sentFiles.has(key) && !checkpoint.receivedFiles.has(key));
799
+ if (this.requestedStop || !this.matchesStoppedContext(stopped) || nextKey !== stopped.snapshot.key) {
800
+ Logger(
801
+ "The stopped Journal has changed. Retry replication before choosing a Journal to skip.",
802
+ LOG_LEVEL_NOTICE,
803
+ "receivejournal"
804
+ );
805
+ this.updateInfo({ syncStatus: this.requestedStop ? "CLOSED" : "ERRORED" });
806
+ return void 0;
807
+ }
808
+ await this.updateCheckPointInfo((info) => ({
809
+ ...info,
810
+ receivedFiles: setAllItems(new Set(info.receivedFiles), [stopped.snapshot.key])
811
+ }));
812
+ return files.filter((key) => key !== stopped.snapshot.key);
813
+ }
814
+ recordReceiveFailure(error, logLevel, context) {
815
+ if (!isJournalReceiveFailure(error)) return;
816
+ Logger("Receive Journal " + (error.key ?? "item") + " failed at " + error.stage, logLevel, "receivejournal");
817
+ if (!this.requestedStop && error.key && context.database === this.db && context.store === this.store && context.configuration === this.receiveConfiguration()) {
818
+ this.stoppedJournal = {
819
+ snapshot: Object.freeze({ key: error.key, stage: error.stage }),
820
+ ...context,
821
+ checkpointGeneration: this.checkpointGeneration
822
+ };
823
+ }
824
+ }
825
+ async receiveJournalFiles(files, logLevel) {
826
+ if (this.requestedStop) {
827
+ this.updateInfo({ syncStatus: "CLOSED" });
828
+ return false;
829
+ }
830
+ if (files.length === 0) {
831
+ Logger("Receiving Journal: No journals needs to be downloaded", logLevel, "receivejournal");
832
+ this.updateInfo({ syncStatus: "COMPLETED" });
833
+ return true;
834
+ }
835
+ const readable = this._createReceiveReadableStream(files);
836
+ const transform = this._createReceiveTransformStream(logLevel);
837
+ const writable = this._createReceiveWritableStream();
838
+ await readable.pipeThrough(transform).pipeTo(writable);
839
+ if (this.requestedStop) {
840
+ Logger("Receive Journal closed after the active operation", LOG_LEVEL_NOTICE, "receivejournal");
841
+ this.updateInfo({ syncStatus: "CLOSED" });
842
+ return false;
843
+ }
844
+ this.updateInfo({ syncStatus: "COMPLETED" });
845
+ return true;
846
+ }
847
+ async performReceive(showMessage, stopped) {
848
+ const database = this.db;
849
+ const store = this.store;
850
+ const configuration = this.receiveConfiguration();
851
+ await assertDatabasePathIdentity(database, this.currentSettings);
742
852
  this.updateInfo({ syncStatus: "JOURNAL_RECEIVE" });
743
- return await shareRunningResult("receive_journal_stream", async () => {
744
- this.requestedStop = false;
745
- const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
853
+ const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
854
+ let skippedKey;
855
+ try {
746
856
  Logger("Receiving Journal: Getting list of remote journal", logLevel, "receivejournal");
747
- const files = await this._getRemoteJournals();
748
- if (files.length == 0) {
749
- Logger(`Receiving Journal: No journals needs to be downloaded`, logLevel, "receivejournal");
750
- this.updateInfo({ syncStatus: "COMPLETED" });
751
- return true;
752
- }
753
- const readable = this._createReceiveReadableStream(files);
754
- const transform = this._createReceiveTransformStream(logLevel);
755
- const writable = this._createReceiveWritableStream();
756
- try {
757
- await readable.pipeThrough(transform).pipeTo(writable);
758
- this.updateInfo({ syncStatus: "COMPLETED" });
759
- return true;
760
- } catch (ex) {
761
- Logger(`Receive Journal Error`, logLevel);
762
- Logger(ex, LOG_LEVEL_VERBOSE);
763
- this.updateInfo({ syncStatus: "ERRORED" });
857
+ let files = await this._getRemoteJournals();
858
+ if (this.requestedStop) {
859
+ this.updateInfo({ syncStatus: "CLOSED" });
764
860
  return false;
765
861
  }
766
- });
862
+ if (stopped) {
863
+ const remainingFiles = await this.prepareStoppedJournalSkip(files, stopped);
864
+ if (!remainingFiles) return false;
865
+ skippedKey = stopped.snapshot.key;
866
+ files = remainingFiles;
867
+ Logger(
868
+ "Skipping Journal " + skippedKey + " at " + stopped.snapshot.stage + " by explicit request. Content or history may remain missing.",
869
+ LOG_LEVEL_NOTICE,
870
+ "receivejournal"
871
+ );
872
+ }
873
+ return await this.receiveJournalFiles(files, logLevel);
874
+ } catch (error) {
875
+ Logger(
876
+ this.requestedStop ? "Receive Journal closed after an active operation failed" : "Receive Journal Error",
877
+ this.requestedStop ? LOG_LEVEL_NOTICE : logLevel,
878
+ "receivejournal"
879
+ );
880
+ this.recordReceiveFailure(error, logLevel, { database, store, configuration });
881
+ this.updateInfo({ syncStatus: this.requestedStop ? "CLOSED" : "ERRORED" });
882
+ return false;
883
+ } finally {
884
+ if (skippedKey) {
885
+ Logger("Receiving Journal: explicitly skipped " + skippedKey, LOG_LEVEL_NOTICE, "receivejournal");
886
+ }
887
+ }
767
888
  }
768
889
  async sync(showResult = false) {
769
890
  return await shareRunningResult("replicate", async () => {
@@ -782,6 +903,7 @@ class JournalSyncCore {
782
903
  }) ?? false;
783
904
  }
784
905
  requestStop() {
906
+ this.stoppedJournal = void 0;
785
907
  this.requestedStop = true;
786
908
  }
787
909
  /** Stop this Journal operation owner and release its storage transport. */
@@ -7,3 +7,16 @@ export type CheckPointInfo = {
7
7
  sentFiles: Set<string>;
8
8
  };
9
9
  export declare const CheckPointInfoDefault: CheckPointInfo;
10
+ /** Processing stages at which one identified Journal can stop receipt. */
11
+ export type JournalReceiveFailureStage = "download" | "decrypt" | "inflate" | "decode" | "parse" | "chunk-write" | "metadata-write";
12
+ /**
13
+ * The exact failure presented for a single explicit skip operation.
14
+ * Pass the returned object unchanged; copies and snapshots from earlier attempts
15
+ * are rejected. This state is local to the active Journal client and is not saved.
16
+ */
17
+ export interface StoppedJournal {
18
+ /** Object key of the Journal which stopped receipt. */
19
+ readonly key: string;
20
+ /** Stage at which receipt stopped. */
21
+ readonly stage: JournalReceiveFailureStage;
22
+ }
@@ -3,7 +3,7 @@ import { type RemoteDBSettings, type ChunkVersionRange, type NodeData, type Remo
3
3
  import { JournalSyncCore } from "./JournalSyncCore.js";
4
4
  import { LiveSyncAbstractReplicator, type RemoteDBStatus } from "../LiveSyncAbstractReplicator.js";
5
5
  import { type ENSURE_DB_RESULT } from "../../pouchdb/LiveSyncDBFunctions.js";
6
- import type { CheckPointInfo } from "./JournalSyncTypes.js";
6
+ import type { CheckPointInfo, StoppedJournal } from "./JournalSyncTypes.js";
7
7
  import type { SimpleStore } from "../../common/utils.js";
8
8
  import type { LiveSyncJournalReplicatorEnv } from "./LiveSyncJournalReplicatorEnv.js";
9
9
  import { type CentralCompatibilityDecisionRecorder } from "../CentralCompatibility.js";
@@ -58,6 +58,10 @@ export declare class LiveSyncJournalReplicator extends LiveSyncAbstractReplicato
58
58
  private runDirectionalReplicationWithOutcome;
59
59
  replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise<boolean>;
60
60
  replicateAllToServerWithOutcome(setting: RemoteDBSettings, showingNotice?: boolean): Promise<ReplicationOutcome>;
61
+ /** Return the stopped Journal from the current transfer client without creating another client. */
62
+ getStoppedJournal(): StoppedJournal | undefined;
63
+ /** Recheck connectivity and compatibility, skip the confirmed Journal, and resume receipt. */
64
+ skipStoppedJournalAndResume(setting: RemoteDBSettings, stopped: StoppedJournal, showingNotice?: boolean): Promise<boolean>;
61
65
  replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise<boolean>;
62
66
  replicateAllFromServerWithOutcome(setting: RemoteDBSettings, showingNotice?: boolean): Promise<ReplicationOutcome>;
63
67
  checkReplicationConnectivity(skipCheck: boolean, ignoreCleanLock?: boolean, showMessage?: boolean, setting?: RemoteDBSettings, client?: JournalSyncCore, recordCompatibilityDecision?: CentralCompatibilityDecisionRecorder): Promise<boolean>;
@@ -80,3 +84,4 @@ export declare class LiveSyncJournalReplicator extends LiveSyncAbstractReplicato
80
84
  accepted_nodes: string[];
81
85
  }>;
82
86
  }
87
+ export declare function isLiveSyncJournalReplicator(value: unknown): value is LiveSyncJournalReplicator;