@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.
- package/README.md +27 -4
- package/dist/API/DirectFileManipulatorV2.d.ts +1 -0
- package/dist/API/DirectFileManipulatorV2.js +6 -12
- package/dist/API/processSetting.d.ts +1 -0
- package/dist/common/models/setting.const.defaults.js +1 -0
- package/dist/common/models/setting.const.qr.js +1 -0
- package/dist/common/models/setting.policy.d.ts +4 -0
- package/dist/common/models/setting.policy.js +1 -0
- package/dist/common/models/setting.type.d.ts +2 -0
- package/dist/common/models/tweak.definition.d.ts +1 -0
- package/dist/common/pathNamespace.d.ts +4 -0
- package/dist/common/pathNamespace.js +10 -0
- package/dist/common/settingConstants.d.ts +1 -0
- package/dist/common/settingConstants.js +4 -0
- package/dist/platform/AmbiguousNormalisedPathError.d.ts +16 -0
- package/dist/platform/AmbiguousNormalisedPathError.js +21 -0
- package/dist/platform/StoragePathResolver.d.ts +15 -0
- package/dist/platform/StoragePathResolver.js +113 -0
- package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.d.ts +74 -0
- package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.js +169 -0
- package/dist/platform/browser/index.d.ts +1 -0
- package/dist/platform/browser/index.js +2 -0
- package/dist/platform/browser/storage.d.ts +23 -0
- package/dist/platform/browser/storage.js +138 -0
- package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.d.ts +79 -0
- package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.js +218 -0
- package/dist/platform/node/index.d.ts +1 -0
- package/dist/platform/node/index.js +2 -0
- package/dist/platform/node/storage.d.ts +30 -1
- package/dist/platform/node/storage.js +88 -0
- package/dist/platform/pathComparison.d.ts +53 -0
- package/dist/platform/pathComparison.js +39 -0
- package/dist/platform/pathTypes.d.ts +154 -0
- package/dist/platform/pathTypes.js +0 -0
- package/dist/platform/storagePathResolution.d.ts +13 -0
- package/dist/platform/storagePathResolution.js +9 -0
- package/dist/replication/SyncParamsHandler.d.ts +1 -0
- package/dist/replication/SyncParamsHandler.js +5 -1
- package/dist/replication/couchdb/LiveSyncReplicator.js +5 -3
- package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
- package/dist/replication/journal/JournalReceiveFailure.js +16 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
- package/dist/replication/journal/JournalSyncCore.js +288 -166
- package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
- package/dist/replication/journal/journalCompatibility.d.ts +9 -0
- package/dist/replication/journal/journalCompatibility.js +58 -0
- package/dist/replication/journal/journalReceive.d.ts +23 -0
- package/dist/replication/journal/journalReceive.js +44 -0
- package/dist/replication/journal/journalRecords.d.ts +10 -0
- package/dist/replication/journal/journalRecords.js +96 -0
- package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
- package/dist/serviceModules/Rebuilder.js +2 -1
- package/dist/services/base/FileProcessingService.d.ts +2 -2
- package/dist/services/base/RemoteConnection.d.ts +2 -0
- package/dist/services/base/RemoteService.d.ts +1 -1
- package/dist/services/base/RemoteService.js +13 -5
- package/dist/services/implements/injectable/InjectableVaultService.d.ts +1 -1
- package/dist/services/lib/couchdbHttpCache.d.ts +5 -0
- package/dist/services/lib/couchdbHttpCache.js +36 -0
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +19 -0
- package/dist/string_and_binary/path.js +4 -7
- package/dist/worker/bgWorker.js +1 -1
- package/docs/adr/2026_11_shared_storage_path_resolution.md +75 -0
- package/docs/design_docs/storage_path_resolution.md +306 -0
- package/docs/development.md +11 -1
- package/docs/journal-receive-recovery.md +66 -0
- package/docs/platform-storage.md +61 -0
- package/docs/releasing.md +25 -0
- package/docs/unicode-path-identity.md +49 -17
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
636
|
-
Logger(
|
|
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(
|
|
661
|
-
const
|
|
662
|
-
if (
|
|
663
|
-
Logger(
|
|
664
|
-
|
|
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
|
-
|
|
671
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
receivedFiles
|
|
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(
|
|
779
|
+
Logger("Processing journal: " + key + " has been processed", LOG_LEVEL_INFO);
|
|
737
780
|
}
|
|
738
781
|
});
|
|
739
782
|
}
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
748
|
-
if (
|
|
749
|
-
|
|
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;
|