@vrtmrz/livesync-commonlib 0.1.10 → 0.1.12
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/dist/pouchdb/StreamingFetch.js +13 -9
- package/dist/replication/couchdb/LiveSyncReplicator.d.ts +1 -0
- package/dist/replication/couchdb/LiveSyncReplicator.js +144 -115
- package/dist/serviceFeatures/offlineScanner.d.ts +140 -11
- package/dist/serviceFeatures/offlineScanner.js +435 -53
- package/docs/development.md +1 -1
- package/docs/proven-in-use.md +4 -0
- package/package.json +1 -1
|
@@ -128,15 +128,19 @@ function generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint)
|
|
|
128
128
|
return {
|
|
129
129
|
async write(doc, sequence) {
|
|
130
130
|
let decryptedDoc;
|
|
131
|
-
|
|
132
|
-
decryptedDoc =
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
131
|
+
if (doc._deleted) {
|
|
132
|
+
decryptedDoc = doc;
|
|
133
|
+
} else {
|
|
134
|
+
try {
|
|
135
|
+
decryptedDoc = await decryptFunction(doc);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new StreamingFetchFailure(
|
|
138
|
+
"decryption",
|
|
139
|
+
`Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
|
|
140
|
+
false,
|
|
141
|
+
{ cause: error }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
140
144
|
}
|
|
141
145
|
let serialisedDoc;
|
|
142
146
|
try {
|
|
@@ -32,6 +32,7 @@ export declare class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicato
|
|
|
32
32
|
updateMaxTransferredSeqOnChunks(localDB: PouchDB.Database, remoteID: number, seqStatusMap: Record<number, boolean>): Promise<ReturnType<typeof this.getEmptyMaxEntry>>;
|
|
33
33
|
sendChunks(setting: RemoteDBSettings, remoteDB: PouchDB.Database<EntryDoc> | undefined, showResult: boolean, fromSeq?: number | string): Promise<boolean>;
|
|
34
34
|
openOneShotReplication(setting: RemoteDBSettings, showResult: boolean, retrying: boolean, syncMode: "sync" | "pullOnly" | "pushOnly", ignoreCleanLock?: boolean): Promise<boolean>;
|
|
35
|
+
private closeRemoteDatabase;
|
|
35
36
|
replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise<boolean>;
|
|
36
37
|
replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise<boolean>;
|
|
37
38
|
checkReplicationConnectivity(setting: RemoteDBSettings, keepAlive: boolean, skipCheck: boolean, showResult: boolean, ignoreCleanLock?: boolean): Promise<false | {
|
|
@@ -540,80 +540,94 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
540
540
|
);
|
|
541
541
|
return false;
|
|
542
542
|
}
|
|
543
|
-
this.maxPullSeq = Number(`${ret.info.update_seq}`.split("-")[0]);
|
|
544
|
-
this.maxPushSeq = Number(`${(await localDB.info()).update_seq}`.split("-")[0]);
|
|
545
|
-
if (showResult) {
|
|
546
|
-
Logger(this.translate("liveSyncReplicator.checkingLastSyncPoint"), LOG_LEVEL_NOTICE, "sync");
|
|
547
|
-
}
|
|
548
543
|
const { db, syncOptionBase } = ret;
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
syncHandler,
|
|
563
|
-
showResult,
|
|
564
|
-
docSentOnStart,
|
|
565
|
-
docArrivedOnStart,
|
|
566
|
-
syncMode,
|
|
567
|
-
retrying,
|
|
568
|
-
false
|
|
569
|
-
);
|
|
570
|
-
if (syncResult == "DONE") {
|
|
571
|
-
return true;
|
|
572
|
-
}
|
|
573
|
-
if (syncResult == "CANCELLED") {
|
|
574
|
-
return false;
|
|
575
|
-
}
|
|
576
|
-
if (syncResult == "FAILED") {
|
|
577
|
-
return false;
|
|
578
|
-
}
|
|
579
|
-
if (syncResult == "NEED_RESURRECT") {
|
|
544
|
+
try {
|
|
545
|
+
this.maxPullSeq = Number(`${ret.info.update_seq}`.split("-")[0]);
|
|
546
|
+
this.maxPushSeq = Number(`${(await localDB.info()).update_seq}`.split("-")[0]);
|
|
547
|
+
if (showResult) {
|
|
548
|
+
Logger(this.translate("liveSyncReplicator.checkingLastSyncPoint"), LOG_LEVEL_NOTICE, "sync");
|
|
549
|
+
}
|
|
550
|
+
this.syncStatus = "STARTED";
|
|
551
|
+
this.updateInfo();
|
|
552
|
+
const docArrivedOnStart = this.docArrived;
|
|
553
|
+
const docSentOnStart = this.docSent;
|
|
554
|
+
if (!retrying) {
|
|
555
|
+
this.originalSetting = setting;
|
|
556
|
+
}
|
|
580
557
|
this.terminateSync();
|
|
581
|
-
|
|
582
|
-
|
|
558
|
+
const syncHandler = syncMode == "sync" ? localDB.sync(db, { ...syncOptionBase }) : syncMode == "pullOnly" ? localDB.replicate.from(db, {
|
|
559
|
+
...syncOptionBase,
|
|
560
|
+
...setting.readChunksOnline ? selectorOnDemandPull : {}
|
|
561
|
+
}) : syncMode == "pushOnly" ? localDB.replicate.to(db, { ...syncOptionBase }) : void 0;
|
|
562
|
+
const syncResult = await this.processSync(
|
|
563
|
+
syncHandler,
|
|
583
564
|
showResult,
|
|
584
|
-
|
|
565
|
+
docSentOnStart,
|
|
566
|
+
docArrivedOnStart,
|
|
585
567
|
syncMode,
|
|
586
|
-
|
|
568
|
+
retrying,
|
|
569
|
+
false
|
|
587
570
|
);
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
tempSetting.batches_limit = Math.ceil(tempSetting.batches_limit / 2) + 2;
|
|
593
|
-
if (tempSetting.batch_size <= 5 && tempSetting.batches_limit <= 5) {
|
|
594
|
-
Logger(
|
|
595
|
-
this.translate("liveSyncReplicator.cantReplicateLowerValue"),
|
|
596
|
-
showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
597
|
-
);
|
|
571
|
+
if (syncResult == "DONE") {
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
if (syncResult == "CANCELLED") {
|
|
598
575
|
return false;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
576
|
+
}
|
|
577
|
+
if (syncResult == "FAILED") {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
if (syncResult == "NEED_RESURRECT") {
|
|
581
|
+
this.terminateSync();
|
|
582
|
+
return async () => await this.openOneShotReplication(
|
|
583
|
+
this.originalSetting,
|
|
584
|
+
showResult,
|
|
585
|
+
false,
|
|
586
|
+
syncMode,
|
|
587
|
+
ignoreCleanLock
|
|
606
588
|
);
|
|
607
|
-
return async () => await this.openOneShotReplication(tempSetting, showResult, true, syncMode, ignoreCleanLock);
|
|
608
589
|
}
|
|
590
|
+
if (syncResult == "NEED_RETRY") {
|
|
591
|
+
const tempSetting = JSON.parse(JSON.stringify(setting));
|
|
592
|
+
tempSetting.batch_size = Math.ceil(tempSetting.batch_size / 2) + 2;
|
|
593
|
+
tempSetting.batches_limit = Math.ceil(tempSetting.batches_limit / 2) + 2;
|
|
594
|
+
if (tempSetting.batch_size <= 5 && tempSetting.batches_limit <= 5) {
|
|
595
|
+
Logger(
|
|
596
|
+
this.translate("liveSyncReplicator.cantReplicateLowerValue"),
|
|
597
|
+
showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
598
|
+
);
|
|
599
|
+
return false;
|
|
600
|
+
} else {
|
|
601
|
+
Logger(
|
|
602
|
+
this.translate("liveSyncReplicator.retryLowerBatchSize", {
|
|
603
|
+
batch_size: tempSetting.batch_size.toString(),
|
|
604
|
+
batches_limit: tempSetting.batches_limit.toString()
|
|
605
|
+
}),
|
|
606
|
+
showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
607
|
+
);
|
|
608
|
+
return async () => await this.openOneShotReplication(tempSetting, showResult, true, syncMode, ignoreCleanLock);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return false;
|
|
612
|
+
} finally {
|
|
613
|
+
await this.closeRemoteDatabase(db);
|
|
609
614
|
}
|
|
610
|
-
return false;
|
|
611
615
|
});
|
|
612
616
|
if (typeof next === "boolean") {
|
|
613
617
|
return next;
|
|
614
618
|
}
|
|
615
619
|
return await next();
|
|
616
620
|
}
|
|
621
|
+
// One-shot attempts own their temporary remote handle. Continuous replication
|
|
622
|
+
// keeps its handle until the replication controller terminates it.
|
|
623
|
+
async closeRemoteDatabase(db) {
|
|
624
|
+
try {
|
|
625
|
+
await db.close();
|
|
626
|
+
} catch (ex) {
|
|
627
|
+
Logger("Failed to close remote database.", LOG_LEVEL_INFO, "sync");
|
|
628
|
+
Logger(ex, LOG_LEVEL_VERBOSE, "sync");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
617
631
|
replicateAllToServer(setting, showingNotice) {
|
|
618
632
|
return this.openOneShotReplication(setting, showingNotice ?? false, false, "pushOnly");
|
|
619
633
|
}
|
|
@@ -638,72 +652,87 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
638
652
|
);
|
|
639
653
|
return false;
|
|
640
654
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
Logger(
|
|
668
|
-
"The remote database has been rebuilt or corrupted since we have synchronized last time. Fetch rebuilt DB, explicit unlocking or chunk clean-up is required.",
|
|
669
|
-
LOG_LEVEL_NOTICE
|
|
655
|
+
let ownershipTransferred = false;
|
|
656
|
+
try {
|
|
657
|
+
if (!skipCheck) {
|
|
658
|
+
if (!await checkRemoteVersion(dbRet.db, this.migrate.bind(this), VER)) {
|
|
659
|
+
Logger(this.translate("liveSyncReplicator.remoteDbCorrupted"), LOG_LEVEL_NOTICE);
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
this.remoteCleaned = false;
|
|
663
|
+
this.remoteLocked = false;
|
|
664
|
+
this.remoteLockedAndDeviceNotAccepted = false;
|
|
665
|
+
this.tweakSettingsMismatched = false;
|
|
666
|
+
this.preferredTweakValue = void 0;
|
|
667
|
+
const progress = `${dbRet.info.update_seq}`;
|
|
668
|
+
const info = {
|
|
669
|
+
app_version: this.env.services.API.getAppVersion(),
|
|
670
|
+
plugin_version: this.env.services.API.getPluginVersion(),
|
|
671
|
+
vault_name: this.env.services.vault.vaultName(),
|
|
672
|
+
device_name: this.env.services.vault.getVaultName(),
|
|
673
|
+
progress
|
|
674
|
+
};
|
|
675
|
+
const ensure = await ensureDatabaseIsCompatible(
|
|
676
|
+
dbRet.db,
|
|
677
|
+
setting,
|
|
678
|
+
this.nodeid,
|
|
679
|
+
currentVersionRange,
|
|
680
|
+
info
|
|
670
681
|
);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if (
|
|
678
|
-
this.remoteLocked = true;
|
|
679
|
-
} else {
|
|
682
|
+
if (ensure == "INCOMPATIBLE") {
|
|
683
|
+
Logger(
|
|
684
|
+
"The remote database has no compatibility with the running version. Please upgrade the plugin.",
|
|
685
|
+
LOG_LEVEL_NOTICE
|
|
686
|
+
);
|
|
687
|
+
return false;
|
|
688
|
+
} else if (ensure == "NODE_LOCKED") {
|
|
680
689
|
Logger(
|
|
681
|
-
"The remote database has been
|
|
690
|
+
"The remote database has been rebuilt or corrupted since we have synchronized last time. Fetch rebuilt DB, explicit unlocking or chunk clean-up is required.",
|
|
682
691
|
LOG_LEVEL_NOTICE
|
|
683
692
|
);
|
|
684
693
|
this.remoteLockedAndDeviceNotAccepted = true;
|
|
685
694
|
this.remoteLocked = true;
|
|
686
|
-
|
|
695
|
+
return false;
|
|
696
|
+
} else if (ensure == "LOCKED") {
|
|
697
|
+
this.remoteLocked = true;
|
|
698
|
+
} else if (ensure == "NODE_CLEANED") {
|
|
699
|
+
if (ignoreCleanLock) {
|
|
700
|
+
this.remoteLocked = true;
|
|
701
|
+
} else {
|
|
702
|
+
Logger(
|
|
703
|
+
"The remote database has been cleaned up. Fetch rebuilt DB, explicit unlocking or chunk clean-up is required.",
|
|
704
|
+
LOG_LEVEL_NOTICE
|
|
705
|
+
);
|
|
706
|
+
this.remoteLockedAndDeviceNotAccepted = true;
|
|
707
|
+
this.remoteLocked = true;
|
|
708
|
+
this.remoteCleaned = true;
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
} else if (ensure == "OK") {
|
|
712
|
+
} else if (ensure[0] == "MISMATCHED") {
|
|
713
|
+
Logger(this.translate("liveSyncReplicator.mismatchedTweakDetected"), LOG_LEVEL_NOTICE);
|
|
714
|
+
this.tweakSettingsMismatched = true;
|
|
715
|
+
this.preferredTweakValue = ensure[1];
|
|
687
716
|
return false;
|
|
688
717
|
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
718
|
+
}
|
|
719
|
+
const syncOptionBase = {
|
|
720
|
+
batches_limit: setting.batches_limit,
|
|
721
|
+
batch_size: setting.batch_size
|
|
722
|
+
};
|
|
723
|
+
syncOptionBase.push = {};
|
|
724
|
+
if (setting.readChunksOnline) {
|
|
725
|
+
syncOptionBase.pull = { ...selectorOnDemandPull };
|
|
726
|
+
}
|
|
727
|
+
const syncOption = keepAlive ? { live: true, retry: true, heartbeat: setting.useTimeouts ? false : 3e4, ...syncOptionBase } : { ...syncOptionBase };
|
|
728
|
+
const connection = { db: dbRet.db, info: dbRet.info, syncOptionBase, syncOption };
|
|
729
|
+
ownershipTransferred = true;
|
|
730
|
+
return connection;
|
|
731
|
+
} finally {
|
|
732
|
+
if (!ownershipTransferred) {
|
|
733
|
+
await this.closeRemoteDatabase(dbRet.db);
|
|
695
734
|
}
|
|
696
735
|
}
|
|
697
|
-
const syncOptionBase = {
|
|
698
|
-
batches_limit: setting.batches_limit,
|
|
699
|
-
batch_size: setting.batch_size
|
|
700
|
-
};
|
|
701
|
-
syncOptionBase.push = {};
|
|
702
|
-
if (setting.readChunksOnline) {
|
|
703
|
-
syncOptionBase.pull = { ...selectorOnDemandPull };
|
|
704
|
-
}
|
|
705
|
-
const syncOption = keepAlive ? { live: true, retry: true, heartbeat: setting.useTimeouts ? false : 3e4, ...syncOptionBase } : { ...syncOptionBase };
|
|
706
|
-
return { db: dbRet.db, info: dbRet.info, syncOptionBase, syncOption };
|
|
707
736
|
}
|
|
708
737
|
async openContinuousReplication(setting, showResult, retrying) {
|
|
709
738
|
const next = await shareRunningResult("continuousReplication", async () => {
|
|
@@ -1,14 +1,135 @@
|
|
|
1
|
-
import { type FilePathWithPrefix, type FilePathWithPrefixLC, type MetaEntry, type UXFileInfoStub, type ObsidianLiveSyncSettings, type LOG_LEVEL } from "../common/types.js";
|
|
1
|
+
import { type DocumentID, type FilePathWithPrefix, type FilePathWithPrefixLC, type MetaEntry, type UXFileInfoStub, type ObsidianLiveSyncSettings, type LOG_LEVEL } from "../common/types.js";
|
|
2
2
|
import { type LogFunction } from "../services/lib/logUtils.js";
|
|
3
3
|
import type { NecessaryServices } from "../interfaces/ServiceModule.js";
|
|
4
4
|
import { UnresolvedErrorManager } from "../services/base/UnresolvedErrorManager.js";
|
|
5
|
+
/**
|
|
6
|
+
* Outcome of processing one storage/database pair during an offline scan.
|
|
7
|
+
*
|
|
8
|
+
* A skipped pair is intentionally left unchanged by scanner policy. It is not
|
|
9
|
+
* a scan failure, but it must not receive success side effects which would
|
|
10
|
+
* claim that database content was reflected to storage.
|
|
11
|
+
*/
|
|
12
|
+
export declare const FilePairProcessResults: {
|
|
13
|
+
readonly COMPLETED: "completed";
|
|
14
|
+
readonly SKIPPED: "skipped";
|
|
15
|
+
readonly FAILED: "failed";
|
|
16
|
+
};
|
|
17
|
+
export type FilePairProcessResult = (typeof FilePairProcessResults)[keyof typeof FilePairProcessResults];
|
|
18
|
+
export declare const MetadataDocumentNamespaces: {
|
|
19
|
+
readonly NORMAL: "normal";
|
|
20
|
+
readonly INTERNAL: "internal";
|
|
21
|
+
readonly CUSTOMISATION: "customisation";
|
|
22
|
+
readonly PLUGIN_STORAGE: "plugin-storage";
|
|
23
|
+
};
|
|
24
|
+
export type MetadataDocumentNamespace = (typeof MetadataDocumentNamespaces)[keyof typeof MetadataDocumentNamespaces];
|
|
25
|
+
export declare const MetadataDocumentIdentityStatuses: {
|
|
26
|
+
readonly CONSISTENT: "consistent";
|
|
27
|
+
readonly EXCLUDED: "excluded";
|
|
28
|
+
readonly UNRESOLVED: "unresolved";
|
|
29
|
+
};
|
|
30
|
+
export declare const OfflineScanUnresolvedReasons: {
|
|
31
|
+
readonly DOCUMENT_ID_MISMATCH: "document-id-mismatch";
|
|
32
|
+
readonly NAMESPACE_MISMATCH: "namespace-mismatch";
|
|
33
|
+
};
|
|
34
|
+
export interface OfflineScanUnresolvedDiagnostic {
|
|
35
|
+
reason: (typeof OfflineScanUnresolvedReasons)[keyof typeof OfflineScanUnresolvedReasons];
|
|
36
|
+
actualDocumentId: DocumentID;
|
|
37
|
+
declaredPath: FilePathWithPrefix;
|
|
38
|
+
expectedDocumentId?: DocumentID;
|
|
39
|
+
actualNamespace: MetadataDocumentNamespace;
|
|
40
|
+
declaredPathNamespace: MetadataDocumentNamespace;
|
|
41
|
+
}
|
|
42
|
+
export type MetadataDocumentIdentityInspection = {
|
|
43
|
+
status: typeof MetadataDocumentIdentityStatuses.CONSISTENT;
|
|
44
|
+
actualDocumentId: DocumentID;
|
|
45
|
+
declaredPath: FilePathWithPrefix;
|
|
46
|
+
expectedDocumentId: DocumentID;
|
|
47
|
+
} | {
|
|
48
|
+
status: typeof MetadataDocumentIdentityStatuses.EXCLUDED;
|
|
49
|
+
actualDocumentId: DocumentID;
|
|
50
|
+
declaredPath: FilePathWithPrefix;
|
|
51
|
+
namespace: Exclude<MetadataDocumentNamespace, "normal">;
|
|
52
|
+
} | {
|
|
53
|
+
status: typeof MetadataDocumentIdentityStatuses.UNRESOLVED;
|
|
54
|
+
diagnostic: OfflineScanUnresolvedDiagnostic;
|
|
55
|
+
};
|
|
56
|
+
export interface MetadataDocumentIdentityIssue {
|
|
57
|
+
inspection: Extract<MetadataDocumentIdentityInspection, {
|
|
58
|
+
status: typeof MetadataDocumentIdentityStatuses.UNRESOLVED;
|
|
59
|
+
}>;
|
|
60
|
+
sourceRevision: string | null;
|
|
61
|
+
logicallyDeleted: boolean;
|
|
62
|
+
conflictRevisions: string[];
|
|
63
|
+
repairAvailable: boolean;
|
|
64
|
+
targetAlreadyPresent: boolean;
|
|
65
|
+
ordinaryPathAvailable: boolean;
|
|
66
|
+
}
|
|
67
|
+
export declare const MetadataDocumentRepairResults: {
|
|
68
|
+
readonly COMPLETED: "completed";
|
|
69
|
+
readonly STALE: "stale";
|
|
70
|
+
readonly BLOCKED: "blocked";
|
|
71
|
+
readonly FAILED: "failed";
|
|
72
|
+
};
|
|
73
|
+
export interface MetadataDocumentRepairRequest {
|
|
74
|
+
actualDocumentId: DocumentID;
|
|
75
|
+
expectedDocumentId: DocumentID;
|
|
76
|
+
sourceRevision: string;
|
|
77
|
+
}
|
|
78
|
+
export type MetadataDocumentRepairResult = {
|
|
79
|
+
status: (typeof MetadataDocumentRepairResults)[keyof typeof MetadataDocumentRepairResults];
|
|
80
|
+
actualDocumentId: DocumentID;
|
|
81
|
+
expectedDocumentId: DocumentID;
|
|
82
|
+
sourceRevision: string;
|
|
83
|
+
targetCreated: boolean;
|
|
84
|
+
message?: string;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Classify the logical Metadata namespace before platform path filtering.
|
|
88
|
+
*
|
|
89
|
+
* Internal, customisation, and plug-in storage entries are owned by their
|
|
90
|
+
* dedicated synchronisation paths. The ordinary file scanner handles only the
|
|
91
|
+
* normal namespace.
|
|
92
|
+
*/
|
|
93
|
+
export declare function getMetadataDocumentNamespace(value: string): MetadataDocumentNamespace;
|
|
94
|
+
/**
|
|
95
|
+
* Inspect whether a Metadata document identifier still represents its
|
|
96
|
+
* declared path under the active path settings.
|
|
97
|
+
*
|
|
98
|
+
* This check deliberately runs before any scanner action. A mismatch can be
|
|
99
|
+
* caused by an old path-obfuscation key, a case-sensitivity change, or a
|
|
100
|
+
* historical rename. Selecting a repair without additional evidence would be
|
|
101
|
+
* destructive, so callers must leave an unresolved document unchanged. A
|
|
102
|
+
* separate consistent document for the same logical path may still proceed
|
|
103
|
+
* through ordinary path-based handling.
|
|
104
|
+
*/
|
|
105
|
+
export declare function inspectMetadataDocumentIdentity(host: NecessaryServices<"path", never>, doc: MetaEntry): Promise<MetadataDocumentIdentityInspection>;
|
|
106
|
+
/**
|
|
107
|
+
* Enumerate Metadata from its actual local document IDs and report only the
|
|
108
|
+
* entries which ordinary path-based inspection cannot address.
|
|
109
|
+
*
|
|
110
|
+
* Path-addressed file inspection cannot discover a document whose stored path
|
|
111
|
+
* derives a different ID. The report therefore provides the evidence needed
|
|
112
|
+
* by an existing host-owned repair inspector without adding a batch-repair
|
|
113
|
+
* policy to the scanner.
|
|
114
|
+
*/
|
|
115
|
+
export declare function inspectMetadataDocumentIdentities(host: NecessaryServices<"path" | "database" | "setting" | "vault", never>): Promise<MetadataDocumentIdentityIssue[]>;
|
|
116
|
+
/**
|
|
117
|
+
* Repair one previously inspected normal-file Metadata identity.
|
|
118
|
+
*
|
|
119
|
+
* The request identifies the exact source revision which the operator
|
|
120
|
+
* approved. Every precondition is inspected again under the same ordered
|
|
121
|
+
* document locks as file-event handling. The target is created and verified
|
|
122
|
+
* before the source receives a hard tombstone. If interruption leaves that
|
|
123
|
+
* exact target in place, the same single-entry action can safely be repeated.
|
|
124
|
+
*/
|
|
125
|
+
export declare function repairMetadataDocumentIdentity(host: NecessaryServices<"path" | "database" | "setting" | "vault" | "keyValueDB", never>, request: MetadataDocumentRepairRequest): Promise<MetadataDocumentRepairResult>;
|
|
5
126
|
/**
|
|
6
127
|
* Collect deleted files that have expired according to retention policy.
|
|
7
128
|
* @param host Services container
|
|
8
129
|
* @param log Logging function
|
|
9
130
|
* @returns Array of expired deletion history
|
|
10
131
|
*/
|
|
11
|
-
export declare function collectDeletedFiles(host: NecessaryServices<"setting" | "database", never>, log: LogFunction): Promise<void>;
|
|
132
|
+
export declare function collectDeletedFiles(host: NecessaryServices<"setting" | "database" | "path", never>, log: LogFunction): Promise<void>;
|
|
12
133
|
/**
|
|
13
134
|
* Get the file path from a meta entry.
|
|
14
135
|
* This is a helper function to extract path from various document types.
|
|
@@ -23,7 +144,7 @@ export declare function getPathFromEntry(host: NecessaryServices<"path", never>,
|
|
|
23
144
|
* @param file Storage file information
|
|
24
145
|
* @param doc Database entry
|
|
25
146
|
*/
|
|
26
|
-
export declare function syncFileBetweenDBandStorage(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, doc: MetaEntry): Promise<
|
|
147
|
+
export declare function syncFileBetweenDBandStorage(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, doc: MetaEntry): Promise<FilePairProcessResult>;
|
|
27
148
|
export declare function canProceedScan(host: NecessaryServices<"keyValueDB" | "setting", never>, errorManager: UnresolvedErrorManager, log: LogFunction, showingNotice?: boolean, ignoreSuspending?: boolean): boolean;
|
|
28
149
|
/**
|
|
29
150
|
* Convert file path to lower case if the settings indicate that filename case should be handled insensitively.
|
|
@@ -40,15 +161,14 @@ export declare function collectFilesOnStorage(host: NecessaryServices<"vault", "
|
|
|
40
161
|
storageFileNameCI2CS: Record<FilePathWithPrefixLC, FilePathWithPrefix>;
|
|
41
162
|
}>;
|
|
42
163
|
export declare function collectDatabaseFiles(host: NecessaryServices<"database" | "vault" | "path", never>, settings: ObsidianLiveSyncSettings, log: LogFunction, showingNotice: boolean): Promise<{
|
|
43
|
-
databaseFileNameMap:
|
|
44
|
-
[k: string]: MetaEntry;
|
|
45
|
-
};
|
|
164
|
+
databaseFileNameMap: Record<FilePathWithPrefix, MetaEntry>;
|
|
46
165
|
databaseFileNames: FilePathWithPrefix[];
|
|
47
|
-
databaseFileNameCI2CS: Record<
|
|
166
|
+
databaseFileNameCI2CS: Record<FilePathWithPrefixLC, FilePathWithPrefix>;
|
|
167
|
+
quarantinedFileNamesLC: Set<FilePathWithPrefixLC>;
|
|
48
168
|
}>;
|
|
49
|
-
export declare function updateToDatabase(host: NecessaryServices<"vault", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, file: UXFileInfoStub): Promise<
|
|
50
|
-
export declare function updateToStorage(host: NecessaryServices<"vault" | "path", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, w: MetaEntry): Promise<
|
|
51
|
-
export declare function syncStorageAndDatabase(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, logLevel: LOG_LEVEL, doc: MetaEntry): Promise<
|
|
169
|
+
export declare function updateToDatabase(host: NecessaryServices<"vault", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, file: UXFileInfoStub): Promise<FilePairProcessResult>;
|
|
170
|
+
export declare function updateToStorage(host: NecessaryServices<"vault" | "path", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, w: MetaEntry): Promise<FilePairProcessResult>;
|
|
171
|
+
export declare function syncStorageAndDatabase(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, logLevel: LOG_LEVEL, doc: MetaEntry): Promise<FilePairProcessResult>;
|
|
52
172
|
export declare const FullScanModes: {
|
|
53
173
|
readonly DB_APPLY: "db-apply";
|
|
54
174
|
readonly NEWER_WINS: "newer-wins";
|
|
@@ -105,9 +225,18 @@ export declare function resolveFilePairAction(state: FilePairState, options: Ful
|
|
|
105
225
|
* @param log Logging function
|
|
106
226
|
* @param errorManager Error manager
|
|
107
227
|
* @param options Full scan options
|
|
228
|
+
* @returns True when every pair completed or was deliberately skipped; false when any pair failed
|
|
108
229
|
*/
|
|
109
230
|
export declare function synchroniseAllFilesBetweenDBandStorage(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, options: FullScanOptions): Promise<boolean>;
|
|
110
231
|
export declare function normaliseFullScanOptions(showingNoticeOrOptions: Partial<FullScanOptions> | boolean | undefined, ignoreSuspending?: boolean): FullScanOptions;
|
|
232
|
+
/**
|
|
233
|
+
* Remove one path from the Offline Scanner's durable last-seen state.
|
|
234
|
+
*
|
|
235
|
+
* Identity repair calls this before publishing a correctly addressed target.
|
|
236
|
+
* Otherwise an old record of local presence could make the next NEWER_WINS
|
|
237
|
+
* scan interpret that database-only target as a later local deletion.
|
|
238
|
+
*/
|
|
239
|
+
export declare function clearOfflineScannerLastSeen(host: NecessaryServices<"setting" | "keyValueDB", never>, path: FilePathWithPrefix): Promise<void>;
|
|
111
240
|
/**
|
|
112
241
|
* Perform a full scan and synchronisation between database and storage.
|
|
113
242
|
* @param host Services container
|
|
@@ -115,7 +244,7 @@ export declare function normaliseFullScanOptions(showingNoticeOrOptions: Partial
|
|
|
115
244
|
* @param errorManager Error manager
|
|
116
245
|
* @param showingNotice Whether to show notices during scanning
|
|
117
246
|
* @param ignoreSuspending Whether to ignore suspension settings
|
|
118
|
-
* @returns True
|
|
247
|
+
* @returns True when the scan was permitted and no selected pair failed
|
|
119
248
|
*/
|
|
120
249
|
export declare function performFullScan(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, options?: Partial<FullScanOptions>): Promise<boolean>;
|
|
121
250
|
export declare function performFullScan(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, showingNotice?: boolean, ignoreSuspending?: boolean): Promise<boolean>;
|
|
@@ -13,6 +13,296 @@ import { createInstanceLogFunction } from "../services/lib/logUtils.js";
|
|
|
13
13
|
import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "../common/models/shared.const.symbols.js";
|
|
14
14
|
import { UnresolvedErrorManager } from "../services/base/UnresolvedErrorManager.js";
|
|
15
15
|
import { compatGlobal } from "../common/coreEnvFunctions.js";
|
|
16
|
+
import { ICHeader, ICXHeader, PSCHeader } from "../common/models/fileaccess.const.js";
|
|
17
|
+
import { serialized } from "octagonal-wheels/concurrency/lock";
|
|
18
|
+
const FilePairProcessResults = {
|
|
19
|
+
COMPLETED: "completed",
|
|
20
|
+
SKIPPED: "skipped",
|
|
21
|
+
FAILED: "failed"
|
|
22
|
+
};
|
|
23
|
+
const MetadataDocumentNamespaces = {
|
|
24
|
+
NORMAL: "normal",
|
|
25
|
+
INTERNAL: "internal",
|
|
26
|
+
CUSTOMISATION: "customisation",
|
|
27
|
+
PLUGIN_STORAGE: "plugin-storage"
|
|
28
|
+
};
|
|
29
|
+
const MetadataDocumentIdentityStatuses = {
|
|
30
|
+
CONSISTENT: "consistent",
|
|
31
|
+
EXCLUDED: "excluded",
|
|
32
|
+
UNRESOLVED: "unresolved"
|
|
33
|
+
};
|
|
34
|
+
const OfflineScanUnresolvedReasons = {
|
|
35
|
+
DOCUMENT_ID_MISMATCH: "document-id-mismatch",
|
|
36
|
+
NAMESPACE_MISMATCH: "namespace-mismatch"
|
|
37
|
+
};
|
|
38
|
+
const MetadataDocumentRepairResults = {
|
|
39
|
+
COMPLETED: "completed",
|
|
40
|
+
STALE: "stale",
|
|
41
|
+
BLOCKED: "blocked",
|
|
42
|
+
FAILED: "failed"
|
|
43
|
+
};
|
|
44
|
+
function getMetadataDocumentNamespace(value) {
|
|
45
|
+
if (value.startsWith(ICXHeader)) return MetadataDocumentNamespaces.CUSTOMISATION;
|
|
46
|
+
if (value.startsWith(ICHeader)) return MetadataDocumentNamespaces.INTERNAL;
|
|
47
|
+
if (value.startsWith(PSCHeader)) return MetadataDocumentNamespaces.PLUGIN_STORAGE;
|
|
48
|
+
return MetadataDocumentNamespaces.NORMAL;
|
|
49
|
+
}
|
|
50
|
+
async function inspectMetadataDocumentIdentity(host, doc) {
|
|
51
|
+
const actualDocumentId = doc._id;
|
|
52
|
+
const declaredPath = getPathFromEntry(host, doc);
|
|
53
|
+
const actualNamespace = getMetadataDocumentNamespace(actualDocumentId);
|
|
54
|
+
const declaredPathNamespace = getMetadataDocumentNamespace(declaredPath);
|
|
55
|
+
if (actualNamespace !== MetadataDocumentNamespaces.NORMAL || declaredPathNamespace !== MetadataDocumentNamespaces.NORMAL) {
|
|
56
|
+
if (actualNamespace === declaredPathNamespace) {
|
|
57
|
+
return {
|
|
58
|
+
status: MetadataDocumentIdentityStatuses.EXCLUDED,
|
|
59
|
+
actualDocumentId,
|
|
60
|
+
declaredPath,
|
|
61
|
+
namespace: actualNamespace
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
status: MetadataDocumentIdentityStatuses.UNRESOLVED,
|
|
66
|
+
diagnostic: {
|
|
67
|
+
reason: OfflineScanUnresolvedReasons.NAMESPACE_MISMATCH,
|
|
68
|
+
actualDocumentId,
|
|
69
|
+
declaredPath,
|
|
70
|
+
actualNamespace,
|
|
71
|
+
declaredPathNamespace
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const expectedDocumentId = await host.services.path.path2id(declaredPath);
|
|
76
|
+
if (actualDocumentId !== expectedDocumentId) {
|
|
77
|
+
return {
|
|
78
|
+
status: MetadataDocumentIdentityStatuses.UNRESOLVED,
|
|
79
|
+
diagnostic: {
|
|
80
|
+
reason: OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH,
|
|
81
|
+
actualDocumentId,
|
|
82
|
+
declaredPath,
|
|
83
|
+
expectedDocumentId,
|
|
84
|
+
actualNamespace,
|
|
85
|
+
declaredPathNamespace
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
status: MetadataDocumentIdentityStatuses.CONSISTENT,
|
|
91
|
+
actualDocumentId,
|
|
92
|
+
declaredPath,
|
|
93
|
+
expectedDocumentId
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function stableValue(value) {
|
|
97
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
98
|
+
if (value === null || typeof value !== "object") return value;
|
|
99
|
+
return Object.fromEntries(
|
|
100
|
+
Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableValue(item)])
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
function logicalMetadataPayload(doc) {
|
|
104
|
+
return stableValue({
|
|
105
|
+
path: doc.path,
|
|
106
|
+
ctime: doc.ctime,
|
|
107
|
+
mtime: doc.mtime,
|
|
108
|
+
size: doc.size,
|
|
109
|
+
type: doc.type,
|
|
110
|
+
children: [...doc.children],
|
|
111
|
+
eden: doc.eden ?? {},
|
|
112
|
+
deleted: Boolean(doc.deleted || doc._deleted)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function isExactMetadataCopy(source, target) {
|
|
116
|
+
return JSON.stringify(logicalMetadataPayload(source)) === JSON.stringify(logicalMetadataPayload(target));
|
|
117
|
+
}
|
|
118
|
+
async function inspectMetadataDocumentIdentities(host) {
|
|
119
|
+
const observations = [];
|
|
120
|
+
for await (const doc of host.services.database.localDatabase.findAllNormalDocs({ conflicts: true }) || []) {
|
|
121
|
+
if (!isMetaEntry(doc)) continue;
|
|
122
|
+
const inspection = await inspectMetadataDocumentIdentity(host, doc);
|
|
123
|
+
observations.push({ doc, inspection });
|
|
124
|
+
}
|
|
125
|
+
const expectedTargetIds = unique(
|
|
126
|
+
observations.flatMap(({ inspection }) => {
|
|
127
|
+
if (inspection.status !== MetadataDocumentIdentityStatuses.UNRESOLVED) return [];
|
|
128
|
+
const { diagnostic } = inspection;
|
|
129
|
+
if (diagnostic.reason !== OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH || diagnostic.expectedDocumentId === void 0) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
return [diagnostic.expectedDocumentId];
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
const targetRows = /* @__PURE__ */ new Map();
|
|
136
|
+
if (expectedTargetIds.length > 0) {
|
|
137
|
+
const result = await host.services.database.localDatabase.allDocsRaw({
|
|
138
|
+
keys: expectedTargetIds,
|
|
139
|
+
include_docs: true,
|
|
140
|
+
conflicts: true
|
|
141
|
+
});
|
|
142
|
+
for (const row of result.rows) targetRows.set(row.key, row);
|
|
143
|
+
}
|
|
144
|
+
const settings = host.services.setting.currentSettings();
|
|
145
|
+
const observationsByDocumentId = new Map(observations.map((observation) => [observation.doc._id, observation]));
|
|
146
|
+
const pathClaims = /* @__PURE__ */ new Map();
|
|
147
|
+
const targetClaims = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const { doc, inspection } of observations) {
|
|
149
|
+
const declaredPath = inspection.status === MetadataDocumentIdentityStatuses.UNRESOLVED ? inspection.diagnostic.declaredPath : inspection.declaredPath;
|
|
150
|
+
const declaredNamespace = getMetadataDocumentNamespace(declaredPath);
|
|
151
|
+
if (declaredNamespace === MetadataDocumentNamespaces.NORMAL) {
|
|
152
|
+
const key = convertCase(settings, declaredPath);
|
|
153
|
+
const claims = pathClaims.get(key) ?? /* @__PURE__ */ new Set();
|
|
154
|
+
claims.add(doc._id);
|
|
155
|
+
pathClaims.set(key, claims);
|
|
156
|
+
}
|
|
157
|
+
if (inspection.status === MetadataDocumentIdentityStatuses.UNRESOLVED && inspection.diagnostic.reason === OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH && inspection.diagnostic.expectedDocumentId !== void 0) {
|
|
158
|
+
const claims = targetClaims.get(inspection.diagnostic.expectedDocumentId) ?? /* @__PURE__ */ new Set();
|
|
159
|
+
claims.add(doc._id);
|
|
160
|
+
targetClaims.set(inspection.diagnostic.expectedDocumentId, claims);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const entries = [];
|
|
164
|
+
for (const { doc, inspection } of observations) {
|
|
165
|
+
if (inspection.status !== MetadataDocumentIdentityStatuses.UNRESOLVED) continue;
|
|
166
|
+
const { diagnostic } = inspection;
|
|
167
|
+
let repairAvailable = false;
|
|
168
|
+
let targetAlreadyPresent = false;
|
|
169
|
+
let ordinaryPathAvailable = false;
|
|
170
|
+
if (diagnostic.reason === OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH && diagnostic.expectedDocumentId !== void 0) {
|
|
171
|
+
const expectedDocumentId = diagnostic.expectedDocumentId;
|
|
172
|
+
const normalisedPath = convertCase(settings, diagnostic.declaredPath);
|
|
173
|
+
const targetObservation = observationsByDocumentId.get(expectedDocumentId);
|
|
174
|
+
const targetPath = targetObservation?.inspection.status === MetadataDocumentIdentityStatuses.CONSISTENT ? targetObservation.inspection.declaredPath : void 0;
|
|
175
|
+
ordinaryPathAvailable = targetPath !== void 0 && convertCase(settings, targetPath) === normalisedPath && host.services.vault.isValidPath(targetPath) && !shouldBeIgnored(targetPath) && await host.services.vault.isTargetFile(targetPath);
|
|
176
|
+
const row = targetRows.get(expectedDocumentId);
|
|
177
|
+
const target = row?.doc;
|
|
178
|
+
const targetMetadata = target && isMetaEntry(target) ? target : void 0;
|
|
179
|
+
targetAlreadyPresent = Boolean(targetMetadata && isExactMetadataCopy(doc, targetMetadata));
|
|
180
|
+
const targetIsAvailable = !row || row.error === "not_found" || targetAlreadyPresent && !row?.value?.deleted && !targetMetadata?._deleted && !targetMetadata?.deleted && (targetMetadata?._conflicts?.length ?? 0) === 0;
|
|
181
|
+
const samePathClaims = pathClaims.get(normalisedPath) ?? /* @__PURE__ */ new Set();
|
|
182
|
+
const permittedExactPair = targetAlreadyPresent && samePathClaims.size === 2 && samePathClaims.has(doc._id) && samePathClaims.has(expectedDocumentId);
|
|
183
|
+
const pathIsUnambiguous = samePathClaims.size === 1 || permittedExactPair;
|
|
184
|
+
const targetIsUnambiguous = (targetClaims.get(expectedDocumentId)?.size ?? 0) === 1;
|
|
185
|
+
repairAvailable = Boolean(doc._rev) && !doc.deleted && !doc._deleted && (doc._conflicts?.length ?? 0) === 0 && host.services.vault.isValidPath(diagnostic.declaredPath) && await host.services.vault.isTargetFile(diagnostic.declaredPath) && pathIsUnambiguous && targetIsUnambiguous && targetIsAvailable;
|
|
186
|
+
}
|
|
187
|
+
entries.push({
|
|
188
|
+
inspection,
|
|
189
|
+
sourceRevision: doc._rev ?? null,
|
|
190
|
+
logicallyDeleted: Boolean(doc.deleted || doc._deleted),
|
|
191
|
+
conflictRevisions: [...doc._conflicts ?? []],
|
|
192
|
+
repairAvailable,
|
|
193
|
+
targetAlreadyPresent,
|
|
194
|
+
ordinaryPathAvailable
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return entries;
|
|
198
|
+
}
|
|
199
|
+
function cloneEden(eden) {
|
|
200
|
+
return Object.fromEntries(
|
|
201
|
+
Object.entries(eden ?? {}).map(([id, chunk]) => [id, { data: chunk.data, epoch: chunk.epoch }])
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function createReaddressedMetadata(source, expectedDocumentId) {
|
|
205
|
+
return {
|
|
206
|
+
_id: expectedDocumentId,
|
|
207
|
+
path: source.path,
|
|
208
|
+
ctime: source.ctime,
|
|
209
|
+
mtime: source.mtime,
|
|
210
|
+
size: source.size,
|
|
211
|
+
type: source.type,
|
|
212
|
+
children: [...source.children],
|
|
213
|
+
eden: cloneEden(source.eden),
|
|
214
|
+
...source.deleted === void 0 ? {} : { deleted: source.deleted }
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async function serializedByMetadataDocumentIds(documentIds, callback) {
|
|
218
|
+
const lockKeys = [...new Set(documentIds)].sort().map((documentId) => `processFileEvent-${documentId}`);
|
|
219
|
+
const acquire = async (index) => {
|
|
220
|
+
const key = lockKeys[index];
|
|
221
|
+
if (key === void 0) return await callback();
|
|
222
|
+
return await serialized(key, () => acquire(index + 1));
|
|
223
|
+
};
|
|
224
|
+
return await acquire(0);
|
|
225
|
+
}
|
|
226
|
+
function findRepairEntry(entries, actualDocumentId) {
|
|
227
|
+
return entries.find(
|
|
228
|
+
({ inspection }) => inspection.diagnostic.actualDocumentId === actualDocumentId
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
async function repairMetadataDocumentIdentity(host, request) {
|
|
232
|
+
const baseResult = {
|
|
233
|
+
actualDocumentId: request.actualDocumentId,
|
|
234
|
+
expectedDocumentId: request.expectedDocumentId,
|
|
235
|
+
sourceRevision: request.sourceRevision,
|
|
236
|
+
targetCreated: false
|
|
237
|
+
};
|
|
238
|
+
return await serializedByMetadataDocumentIds(
|
|
239
|
+
[request.actualDocumentId, request.expectedDocumentId],
|
|
240
|
+
async () => {
|
|
241
|
+
let targetCreated = false;
|
|
242
|
+
try {
|
|
243
|
+
const entries = await inspectMetadataDocumentIdentities(host);
|
|
244
|
+
const entry = findRepairEntry(entries, request.actualDocumentId);
|
|
245
|
+
if (!entry || entry.sourceRevision !== request.sourceRevision || entry.inspection.diagnostic.reason !== OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH || entry.inspection.diagnostic.expectedDocumentId !== request.expectedDocumentId) {
|
|
246
|
+
return {
|
|
247
|
+
...baseResult,
|
|
248
|
+
status: MetadataDocumentRepairResults.STALE,
|
|
249
|
+
message: "The inspected source revision or expected document ID has changed."
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (!entry.repairAvailable) {
|
|
253
|
+
return {
|
|
254
|
+
...baseResult,
|
|
255
|
+
status: MetadataDocumentRepairResults.BLOCKED,
|
|
256
|
+
message: "The source no longer satisfies the repair preconditions."
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const source = await host.services.database.localDatabase.getRaw(request.actualDocumentId, {
|
|
260
|
+
rev: request.sourceRevision,
|
|
261
|
+
conflicts: true
|
|
262
|
+
});
|
|
263
|
+
if (!isMetaEntry(source) || source._rev !== request.sourceRevision) {
|
|
264
|
+
return {
|
|
265
|
+
...baseResult,
|
|
266
|
+
status: MetadataDocumentRepairResults.STALE,
|
|
267
|
+
message: "The source revision changed during repair validation."
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
await clearOfflineScannerLastSeen(host, entry.inspection.diagnostic.declaredPath);
|
|
271
|
+
if (!entry.targetAlreadyPresent) {
|
|
272
|
+
await host.services.database.localDatabase.putRaw(
|
|
273
|
+
createReaddressedMetadata(source, request.expectedDocumentId)
|
|
274
|
+
);
|
|
275
|
+
targetCreated = true;
|
|
276
|
+
}
|
|
277
|
+
const target = await host.services.database.localDatabase.getRaw(
|
|
278
|
+
request.expectedDocumentId,
|
|
279
|
+
{ conflicts: true }
|
|
280
|
+
);
|
|
281
|
+
if (!isMetaEntry(target) || target.deleted || target._deleted || (target._conflicts?.length ?? 0) > 0 || !isExactMetadataCopy(source, target)) {
|
|
282
|
+
return {
|
|
283
|
+
...baseResult,
|
|
284
|
+
targetCreated,
|
|
285
|
+
status: MetadataDocumentRepairResults.FAILED,
|
|
286
|
+
message: "The repair target could not be verified as an exact local copy."
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
await host.services.database.localDatabase.removeRaw(request.actualDocumentId, request.sourceRevision);
|
|
290
|
+
return {
|
|
291
|
+
...baseResult,
|
|
292
|
+
targetCreated,
|
|
293
|
+
status: MetadataDocumentRepairResults.COMPLETED
|
|
294
|
+
};
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return {
|
|
297
|
+
...baseResult,
|
|
298
|
+
targetCreated,
|
|
299
|
+
status: MetadataDocumentRepairResults.FAILED,
|
|
300
|
+
message: error instanceof Error ? error.message : `${error}`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
}
|
|
16
306
|
async function collectDeletedFiles(host, log) {
|
|
17
307
|
const limitDays = host.services.setting.currentSettings().automaticallyDeleteMetadataOfDeletedFiles;
|
|
18
308
|
if (limitDays <= 0) return;
|
|
@@ -22,6 +312,16 @@ async function collectDeletedFiles(host, log) {
|
|
|
22
312
|
for await (const doc of host.services.database.localDatabase.findAllDocs({ conflicts: true })) {
|
|
23
313
|
if (isAnyNote(doc)) {
|
|
24
314
|
if (doc.deleted && doc.mtime - limit < 0) {
|
|
315
|
+
if (isMetaEntry(doc)) {
|
|
316
|
+
const identity = await inspectMetadataDocumentIdentity(host, doc);
|
|
317
|
+
if (identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED) {
|
|
318
|
+
log(
|
|
319
|
+
`Expired deletion history has an unresolved Metadata identity and will be left unchanged: ${identity.diagnostic.declaredPath} (${identity.diagnostic.reason})`,
|
|
320
|
+
LOG_LEVEL_INFO
|
|
321
|
+
);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
25
325
|
notes.push({
|
|
26
326
|
path: doc.path,
|
|
27
327
|
mtime: doc.mtime,
|
|
@@ -59,13 +359,14 @@ async function syncFileBetweenDBandStorage(host, log, file, doc) {
|
|
|
59
359
|
if (!host.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
|
60
360
|
log("STORAGE -> DB :" + file.path);
|
|
61
361
|
await host.serviceModules.fileHandler.storeFileToDB(file);
|
|
362
|
+
return FilePairProcessResults.COMPLETED;
|
|
62
363
|
} else {
|
|
63
364
|
log(
|
|
64
365
|
`STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
|
65
366
|
LOG_LEVEL_NOTICE
|
|
66
367
|
);
|
|
368
|
+
return FilePairProcessResults.SKIPPED;
|
|
67
369
|
}
|
|
68
|
-
break;
|
|
69
370
|
case TARGET_IS_NEW:
|
|
70
371
|
if (!host.services.vault.isFileSizeTooLarge(doc.size)) {
|
|
71
372
|
log("STORAGE <- DB :" + docPath);
|
|
@@ -74,21 +375,24 @@ async function syncFileBetweenDBandStorage(host, log, file, doc) {
|
|
|
74
375
|
file: file.path,
|
|
75
376
|
automated: true
|
|
76
377
|
});
|
|
378
|
+
return FilePairProcessResults.COMPLETED;
|
|
77
379
|
} else {
|
|
78
380
|
log(`STORAGE <- DB : Cloud not read ${file.path}, possibly deleted`, LOG_LEVEL_NOTICE);
|
|
381
|
+
return FilePairProcessResults.FAILED;
|
|
79
382
|
}
|
|
80
383
|
} else {
|
|
81
384
|
log(
|
|
82
385
|
`STORAGE <- DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
|
83
386
|
LOG_LEVEL_NOTICE
|
|
84
387
|
);
|
|
388
|
+
return FilePairProcessResults.SKIPPED;
|
|
85
389
|
}
|
|
86
|
-
break;
|
|
87
390
|
case EVEN:
|
|
88
391
|
log("STORAGE == DB :" + file.path, LOG_LEVEL_DEBUG);
|
|
89
|
-
|
|
392
|
+
return FilePairProcessResults.COMPLETED;
|
|
90
393
|
default:
|
|
91
394
|
log("STORAGE ?? DB :" + file.path + " Something got weird");
|
|
395
|
+
return FilePairProcessResults.FAILED;
|
|
92
396
|
}
|
|
93
397
|
}
|
|
94
398
|
function canProceedScan(host, errorManager, log, showingNotice = false, ignoreSuspending = false) {
|
|
@@ -138,6 +442,8 @@ async function collectFilesOnStorage(host, settings, log) {
|
|
|
138
442
|
async function collectDatabaseFiles(host, settings, log, showingNotice) {
|
|
139
443
|
log("Collecting local files on the DB", LOG_LEVEL_VERBOSE);
|
|
140
444
|
const _DBEntries = [];
|
|
445
|
+
const unresolvedFileNamesLC = /* @__PURE__ */ new Set();
|
|
446
|
+
const resolvableFileNamesLC = /* @__PURE__ */ new Set();
|
|
141
447
|
let count = 0;
|
|
142
448
|
for await (const doc of host.services.database.localDatabase.findAllNormalDocs({ conflicts: true }) || []) {
|
|
143
449
|
count++;
|
|
@@ -147,28 +453,54 @@ async function collectDatabaseFiles(host, settings, log, showingNotice) {
|
|
|
147
453
|
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
|
148
454
|
"syncAll"
|
|
149
455
|
);
|
|
150
|
-
|
|
456
|
+
if (!isMetaEntry(doc)) {
|
|
457
|
+
const documentId = doc._id ?? "unknown";
|
|
458
|
+
log(`Invalid Metadata entry: ${documentId}`, LOG_LEVEL_INFO);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const identity = await inspectMetadataDocumentIdentity(host, doc);
|
|
462
|
+
if (identity.status === MetadataDocumentIdentityStatuses.EXCLUDED) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED) {
|
|
466
|
+
const { diagnostic } = identity;
|
|
467
|
+
const unresolvedKey = convertCase(settings, stripAllPrefixes(diagnostic.declaredPath));
|
|
468
|
+
unresolvedFileNamesLC.add(unresolvedKey);
|
|
469
|
+
log(
|
|
470
|
+
`Metadata identity is unresolved and will be left unchanged: ${diagnostic.declaredPath} (${diagnostic.reason})`,
|
|
471
|
+
LOG_LEVEL_INFO
|
|
472
|
+
);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const path = identity.declaredPath;
|
|
151
476
|
if (host.services.vault.isValidPath(path) && !shouldBeIgnored(path) && await host.services.vault.isTargetFile(path)) {
|
|
152
|
-
if (!isMetaEntry(doc)) {
|
|
153
|
-
log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
477
|
_DBEntries.push(doc);
|
|
478
|
+
resolvableFileNamesLC.add(convertCase(settings, stripAllPrefixes(path)));
|
|
157
479
|
}
|
|
158
480
|
}
|
|
159
481
|
const databaseFileNameMap = Object.fromEntries(_DBEntries.map((e) => [getPathFromEntry(host, e), e]));
|
|
160
482
|
const databaseFileNames = Object.keys(databaseFileNameMap);
|
|
161
483
|
const databaseFileNameCapsPair = databaseFileNames.map((e) => [e, convertCase(settings, e)]);
|
|
162
484
|
const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map((e) => [e[1], e[0]]));
|
|
163
|
-
|
|
485
|
+
const quarantinedFileNamesLC = new Set(
|
|
486
|
+
[...unresolvedFileNamesLC].filter((path) => !resolvableFileNamesLC.has(path))
|
|
487
|
+
);
|
|
488
|
+
return {
|
|
489
|
+
databaseFileNameMap,
|
|
490
|
+
databaseFileNames,
|
|
491
|
+
databaseFileNameCI2CS,
|
|
492
|
+
quarantinedFileNamesLC
|
|
493
|
+
};
|
|
164
494
|
}
|
|
165
495
|
async function updateToDatabase(host, log, logLevel, file) {
|
|
166
496
|
if (!host.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
|
167
497
|
const path = file.path;
|
|
168
498
|
await host.serviceModules.fileHandler.storeFileToDB(file);
|
|
169
499
|
host.services.context.events.emitEvent("event-file-changed", { file: path, automated: true });
|
|
500
|
+
return FilePairProcessResults.COMPLETED;
|
|
170
501
|
} else {
|
|
171
502
|
log(`UPDATE DATABASE: ${file.path} has been skipped due to file size exceeding the limit`, logLevel);
|
|
503
|
+
return FilePairProcessResults.SKIPPED;
|
|
172
504
|
}
|
|
173
505
|
}
|
|
174
506
|
async function updateToStorage(host, log, logLevel, w) {
|
|
@@ -177,35 +509,41 @@ async function updateToStorage(host, log, logLevel, w) {
|
|
|
177
509
|
if (!host.services.vault.isFileSizeTooLarge(w.size)) {
|
|
178
510
|
if ((w._conflicts?.length ?? 0) > 0) {
|
|
179
511
|
log(`UPDATE STORAGE: ${path} has conflicts. skipped (x)`, LOG_LEVEL_INFO);
|
|
180
|
-
return;
|
|
512
|
+
return FilePairProcessResults.SKIPPED;
|
|
181
513
|
}
|
|
182
|
-
await host.serviceModules.fileHandler.dbToStorage(path, null, true);
|
|
514
|
+
const reflected = await host.serviceModules.fileHandler.dbToStorage(path, null, true);
|
|
515
|
+
if (!reflected) return FilePairProcessResults.FAILED;
|
|
183
516
|
host.services.context.events.emitEvent("event-file-changed", {
|
|
184
517
|
file: path,
|
|
185
518
|
automated: true
|
|
186
519
|
});
|
|
187
520
|
log(`Check or pull from db:${path} OK`);
|
|
521
|
+
return FilePairProcessResults.COMPLETED;
|
|
188
522
|
} else {
|
|
189
523
|
log(`UPDATE STORAGE: ${path} has been skipped due to file size exceeding the limit`, logLevel);
|
|
524
|
+
return FilePairProcessResults.SKIPPED;
|
|
190
525
|
}
|
|
191
526
|
} else if (w) {
|
|
192
527
|
log(`Deletion history skipped: ${path}`, LOG_LEVEL_VERBOSE);
|
|
528
|
+
return FilePairProcessResults.SKIPPED;
|
|
193
529
|
} else {
|
|
194
530
|
log(`entry not found: ${path}`);
|
|
531
|
+
return FilePairProcessResults.FAILED;
|
|
195
532
|
}
|
|
196
533
|
}
|
|
197
534
|
async function syncStorageAndDatabase(host, log, file, logLevel, doc) {
|
|
198
535
|
if ((doc._conflicts?.length ?? 0) > 0) {
|
|
199
536
|
log(`SYNC DATABASE AND STORAGE: ${file.path} has conflicts. skipped`, LOG_LEVEL_INFO);
|
|
200
|
-
return;
|
|
537
|
+
return FilePairProcessResults.SKIPPED;
|
|
201
538
|
}
|
|
202
539
|
if (!host.services.vault.isFileSizeTooLarge(file.stat.size) && !host.services.vault.isFileSizeTooLarge(doc.size)) {
|
|
203
|
-
await syncFileBetweenDBandStorage(host, log, file, doc);
|
|
540
|
+
return await syncFileBetweenDBandStorage(host, log, file, doc);
|
|
204
541
|
} else {
|
|
205
542
|
log(
|
|
206
543
|
`SYNC DATABASE AND STORAGE: ${getPathFromEntry(host, doc)} has been skipped due to file size exceeding the limit`,
|
|
207
544
|
logLevel
|
|
208
545
|
);
|
|
546
|
+
return FilePairProcessResults.SKIPPED;
|
|
209
547
|
}
|
|
210
548
|
}
|
|
211
549
|
const FullScanModes = {
|
|
@@ -308,7 +646,7 @@ async function processFilePair(host, log, pair, options) {
|
|
|
308
646
|
}
|
|
309
647
|
if (doc && (doc._conflicts?.length ?? 0) > 0) {
|
|
310
648
|
log(`SKIP ${options.mode}: ${path} has conflicts`, LOG_LEVEL_INFO);
|
|
311
|
-
return
|
|
649
|
+
return FilePairProcessResults.SKIPPED;
|
|
312
650
|
}
|
|
313
651
|
const state = getFilePairState(pair);
|
|
314
652
|
let action = resolveFilePairAction(state, options);
|
|
@@ -328,32 +666,35 @@ async function processFilePair(host, log, pair, options) {
|
|
|
328
666
|
if (!file) {
|
|
329
667
|
throw new Error(`Missing storage file for ${path}`);
|
|
330
668
|
}
|
|
331
|
-
await updateToDatabase(host, log, LOG_LEVEL_INFO, file);
|
|
332
|
-
return true;
|
|
669
|
+
return await updateToDatabase(host, log, LOG_LEVEL_INFO, file);
|
|
333
670
|
case "update-storage":
|
|
334
671
|
if (!doc) {
|
|
335
672
|
throw new Error(`Missing database entry for ${path}`);
|
|
336
673
|
}
|
|
337
|
-
await updateToStorage(host, log, LOG_LEVEL_INFO, doc);
|
|
338
|
-
|
|
339
|
-
|
|
674
|
+
const updateStorageResult = await updateToStorage(host, log, LOG_LEVEL_INFO, doc);
|
|
675
|
+
if (updateStorageResult === FilePairProcessResults.COMPLETED) {
|
|
676
|
+
updateFileMTimeInMap(host, fileMapKey, doc.mtime);
|
|
677
|
+
}
|
|
678
|
+
return updateStorageResult;
|
|
340
679
|
case "sync-newer":
|
|
341
680
|
if (!file || !doc) {
|
|
342
681
|
throw new Error(`Cannot compare freshness for ${path}`);
|
|
343
682
|
}
|
|
344
|
-
await syncStorageAndDatabase(host, log, file, LOG_LEVEL_INFO, doc);
|
|
345
|
-
|
|
346
|
-
|
|
683
|
+
const syncResult = await syncStorageAndDatabase(host, log, file, LOG_LEVEL_INFO, doc);
|
|
684
|
+
if (syncResult === FilePairProcessResults.COMPLETED) {
|
|
685
|
+
updateFileMTimeInMap(host, fileMapKey, Math.max(file.stat.mtime, doc.mtime));
|
|
686
|
+
}
|
|
687
|
+
return syncResult;
|
|
347
688
|
case "delete-local":
|
|
348
689
|
if (!file) {
|
|
349
690
|
log(`DELETE LOCAL: ${path} is already absent from storage`, LOG_LEVEL_VERBOSE);
|
|
350
|
-
return
|
|
691
|
+
return FilePairProcessResults.COMPLETED;
|
|
351
692
|
}
|
|
352
693
|
log(`DELETE LOCAL: ${file.path}`, LOG_LEVEL_INFO);
|
|
353
694
|
await host.serviceModules.storageAccess.delete(file.path, true);
|
|
354
695
|
fileMaps.delete(fileMapKey);
|
|
355
696
|
saveFileStatus(host);
|
|
356
|
-
return
|
|
697
|
+
return FilePairProcessResults.COMPLETED;
|
|
357
698
|
case "delete-db": {
|
|
358
699
|
if (!doc) {
|
|
359
700
|
throw new Error(`Missing database entry for ${path}`);
|
|
@@ -363,22 +704,23 @@ async function processFilePair(host, log, pair, options) {
|
|
|
363
704
|
if (dbDeleted) {
|
|
364
705
|
fileMaps.delete(fileMapKey);
|
|
365
706
|
saveFileStatus(host);
|
|
707
|
+
return FilePairProcessResults.COMPLETED;
|
|
366
708
|
} else {
|
|
367
709
|
log(
|
|
368
710
|
`DELETE DATABASE did not delete ${path}; keeping last-seen record to avoid resurrecting the file`,
|
|
369
711
|
LOG_LEVEL_NOTICE
|
|
370
712
|
);
|
|
713
|
+
return FilePairProcessResults.FAILED;
|
|
371
714
|
}
|
|
372
|
-
return true;
|
|
373
715
|
}
|
|
374
716
|
case "skip":
|
|
375
717
|
log(`SKIP ${options.mode}: ${path} (${state})`, LOG_LEVEL_VERBOSE);
|
|
376
|
-
return
|
|
718
|
+
return FilePairProcessResults.SKIPPED;
|
|
377
719
|
}
|
|
378
720
|
} catch (ex) {
|
|
379
721
|
log(`Error processing ${path} with action ${action}`, LOG_LEVEL_NOTICE);
|
|
380
722
|
log(ex, LOG_LEVEL_VERBOSE);
|
|
381
|
-
return
|
|
723
|
+
return FilePairProcessResults.FAILED;
|
|
382
724
|
}
|
|
383
725
|
}
|
|
384
726
|
async function synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, options) {
|
|
@@ -386,17 +728,15 @@ async function synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, o
|
|
|
386
728
|
const showingNotice = options.showingNotice ?? false;
|
|
387
729
|
await loadFileStatus(host);
|
|
388
730
|
const { storageFileNameMap, storageFileNameCI2CS } = await collectFilesOnStorage(host, settings, log);
|
|
389
|
-
const { databaseFileNameMap, databaseFileNameCI2CS } = await collectDatabaseFiles(
|
|
390
|
-
host,
|
|
391
|
-
settings,
|
|
392
|
-
log,
|
|
393
|
-
showingNotice
|
|
394
|
-
);
|
|
731
|
+
const { databaseFileNameMap, databaseFileNameCI2CS, quarantinedFileNamesLC } = await collectDatabaseFiles(host, settings, log, showingNotice);
|
|
395
732
|
const pairs = [];
|
|
396
733
|
for (const fileNameLC of unique([
|
|
397
734
|
...Object.keys(storageFileNameCI2CS),
|
|
398
735
|
...Object.keys(databaseFileNameCI2CS)
|
|
399
736
|
])) {
|
|
737
|
+
if (quarantinedFileNamesLC.has(fileNameLC)) {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
400
740
|
const fileName = fileNameLC in storageFileNameCI2CS ? storageFileNameCI2CS[fileNameLC] : void 0;
|
|
401
741
|
const file = fileName ? storageFileNameMap[fileName] : void 0;
|
|
402
742
|
const databaseName = fileNameLC in databaseFileNameCI2CS ? databaseFileNameCI2CS[fileNameLC] : void 0;
|
|
@@ -405,7 +745,9 @@ async function synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, o
|
|
|
405
745
|
pairs.push(pair);
|
|
406
746
|
}
|
|
407
747
|
log(`Total files to synchronise: ${pairs.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
|
408
|
-
let
|
|
748
|
+
let completedCount = 0;
|
|
749
|
+
let skippedCount = 0;
|
|
750
|
+
let failedCount = 0;
|
|
409
751
|
let processedCount = 0;
|
|
410
752
|
for await (const result of withConcurrency(
|
|
411
753
|
pairs,
|
|
@@ -415,14 +757,22 @@ async function synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, o
|
|
|
415
757
|
} catch (ex) {
|
|
416
758
|
log(`Error while synchronising files`, LOG_LEVEL_NOTICE);
|
|
417
759
|
log(ex, LOG_LEVEL_VERBOSE);
|
|
418
|
-
return
|
|
760
|
+
return FilePairProcessResults.FAILED;
|
|
419
761
|
}
|
|
420
762
|
},
|
|
421
763
|
10
|
|
422
764
|
)) {
|
|
423
765
|
processedCount++;
|
|
424
|
-
|
|
425
|
-
|
|
766
|
+
switch (result) {
|
|
767
|
+
case FilePairProcessResults.COMPLETED:
|
|
768
|
+
completedCount++;
|
|
769
|
+
break;
|
|
770
|
+
case FilePairProcessResults.SKIPPED:
|
|
771
|
+
skippedCount++;
|
|
772
|
+
break;
|
|
773
|
+
case FilePairProcessResults.FAILED:
|
|
774
|
+
failedCount++;
|
|
775
|
+
break;
|
|
426
776
|
}
|
|
427
777
|
if (processedCount % 25 === 0) {
|
|
428
778
|
log(
|
|
@@ -433,12 +783,12 @@ async function synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, o
|
|
|
433
783
|
}
|
|
434
784
|
}
|
|
435
785
|
log(
|
|
436
|
-
`Synchronisation completed: ${
|
|
786
|
+
`Synchronisation completed: ${processedCount} files processed (${completedCount} completed, ${skippedCount} skipped, ${failedCount} failed)`,
|
|
437
787
|
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
|
438
788
|
"syncAll"
|
|
439
789
|
);
|
|
440
790
|
saveFileStatus(host, true);
|
|
441
|
-
return
|
|
791
|
+
return failedCount === 0;
|
|
442
792
|
}
|
|
443
793
|
function normaliseFullScanOptions(showingNoticeOrOptions, ignoreSuspending = false) {
|
|
444
794
|
if (typeof showingNoticeOrOptions === "object") {
|
|
@@ -454,21 +804,43 @@ function normaliseFullScanOptions(showingNoticeOrOptions, ignoreSuspending = fal
|
|
|
454
804
|
};
|
|
455
805
|
}
|
|
456
806
|
let fileMaps = /* @__PURE__ */ new Map();
|
|
807
|
+
const FILE_STATUS_MAP_KEY = "fileStatusMap";
|
|
808
|
+
const FILE_STATUS_MAP_LOCK = "offlineScanner-fileStatusMap";
|
|
457
809
|
async function loadFileStatus(host) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
810
|
+
await serialized(FILE_STATUS_MAP_LOCK, async () => {
|
|
811
|
+
const kvDB = host.services.keyValueDB.kvDB;
|
|
812
|
+
if (!kvDB?.get) {
|
|
813
|
+
fileMaps = /* @__PURE__ */ new Map();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const mapItems = await kvDB.get(FILE_STATUS_MAP_KEY) || {};
|
|
817
|
+
fileMaps = new Map(Object.entries(mapItems));
|
|
818
|
+
});
|
|
465
819
|
}
|
|
466
820
|
async function _saveFileStatus(host) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
821
|
+
await serialized(FILE_STATUS_MAP_LOCK, async () => {
|
|
822
|
+
const kvDB = host.services.keyValueDB.kvDB;
|
|
823
|
+
if (!kvDB?.set) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
await kvDB.set(FILE_STATUS_MAP_KEY, Object.fromEntries(fileMaps));
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
async function clearOfflineScannerLastSeen(host, path) {
|
|
830
|
+
const key = convertCase(host.services.setting.currentSettings(), path);
|
|
831
|
+
if (saveFileStatusTimeout !== null) {
|
|
832
|
+
compatGlobal.clearTimeout(saveFileStatusTimeout);
|
|
833
|
+
saveFileStatusTimeout = null;
|
|
834
|
+
}
|
|
835
|
+
await serialized(FILE_STATUS_MAP_LOCK, async () => {
|
|
836
|
+
fileMaps.delete(key);
|
|
837
|
+
const kvDB = host.services.keyValueDB.kvDB;
|
|
838
|
+
if (!kvDB?.set) return;
|
|
839
|
+
const persisted = kvDB.get ? await kvDB.get(FILE_STATUS_MAP_KEY) || {} : Object.fromEntries(fileMaps);
|
|
840
|
+
const next = { ...persisted, ...Object.fromEntries(fileMaps) };
|
|
841
|
+
delete next[key];
|
|
842
|
+
await kvDB.set(FILE_STATUS_MAP_KEY, next);
|
|
843
|
+
});
|
|
472
844
|
}
|
|
473
845
|
let saveFileStatusTimeout = null;
|
|
474
846
|
function saveFileStatus(host, immediate = false) {
|
|
@@ -513,7 +885,7 @@ async function performFullScan(host, log, errorManager, showingNoticeOrOptions =
|
|
|
513
885
|
log("Initialize and checking database files");
|
|
514
886
|
log("Checking deleted files");
|
|
515
887
|
await collectDeletedFiles(host, log);
|
|
516
|
-
await synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, options);
|
|
888
|
+
const scanResult = await synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, options);
|
|
517
889
|
log("Initialized, NOW TRACKING!");
|
|
518
890
|
if (!isInitialized) {
|
|
519
891
|
await host.services.keyValueDB.kvDB.set("initialized", true);
|
|
@@ -521,7 +893,7 @@ async function performFullScan(host, log, errorManager, showingNoticeOrOptions =
|
|
|
521
893
|
if (showingNotice) {
|
|
522
894
|
log("Initialize done!", LOG_LEVEL_NOTICE, "syncAll");
|
|
523
895
|
}
|
|
524
|
-
return
|
|
896
|
+
return scanResult;
|
|
525
897
|
}
|
|
526
898
|
function useOfflineScanner(host) {
|
|
527
899
|
const log = createInstanceLogFunction("SF:OfflineScanner", host.services.API);
|
|
@@ -534,16 +906,26 @@ function useOfflineScanner(host) {
|
|
|
534
906
|
export {
|
|
535
907
|
ExtraOnLocal,
|
|
536
908
|
ExtraOnRemote,
|
|
909
|
+
FilePairProcessResults,
|
|
537
910
|
FullScanModes,
|
|
911
|
+
MetadataDocumentIdentityStatuses,
|
|
912
|
+
MetadataDocumentNamespaces,
|
|
913
|
+
MetadataDocumentRepairResults,
|
|
914
|
+
OfflineScanUnresolvedReasons,
|
|
538
915
|
canProceedScan,
|
|
916
|
+
clearOfflineScannerLastSeen,
|
|
539
917
|
collectDatabaseFiles,
|
|
540
918
|
collectDeletedFiles,
|
|
541
919
|
collectFilesOnStorage,
|
|
542
920
|
convertCase,
|
|
543
921
|
getFilePairState,
|
|
922
|
+
getMetadataDocumentNamespace,
|
|
544
923
|
getPathFromEntry,
|
|
924
|
+
inspectMetadataDocumentIdentities,
|
|
925
|
+
inspectMetadataDocumentIdentity,
|
|
545
926
|
normaliseFullScanOptions,
|
|
546
927
|
performFullScan,
|
|
928
|
+
repairMetadataDocumentIdentity,
|
|
547
929
|
resolveFilePairAction,
|
|
548
930
|
syncFileBetweenDBandStorage,
|
|
549
931
|
syncStorageAndDatabase,
|
package/docs/development.md
CHANGED
|
@@ -57,7 +57,7 @@ The settings lifecycle has an additional focused unit table in `src/common/model
|
|
|
57
57
|
|
|
58
58
|
The multiple-remote profile contract has focused unit coverage in `src/serviceFeatures/remoteConfig.unit.spec.ts`. Extend it whenever profile identity, generated display names, activation, P2P selection, URI serialisation, or legacy migration changes. New hosts should import the reviewed primitives through `/remote-configurations`; `compat/serviceFeatures/remoteConfig` remains the wider migration surface used by existing clients.
|
|
59
59
|
|
|
60
|
-
The full offline scanner applies both the host's target-file policy and Commonlib's built-in `shouldBeIgnored` policy before constructing storage/database pairs. This keeps legacy LiveSync log files and flag files outside ordinary create, restore, and delete decisions even when a host accepts their file extension. Lower-level write guards remain a defence for direct calls; they are not the normal full-scan filter.
|
|
60
|
+
The full offline scanner applies both the host's target-file policy and Commonlib's built-in `shouldBeIgnored` policy before constructing storage/database pairs. This keeps legacy LiveSync log files and flag files outside ordinary create, restore, and delete decisions even when a host accepts their file extension. Lower-level write guards remain a defence for direct calls; they are not the normal full-scan filter. The complete pair-state decision matrix and database-to-storage reflection result contract are documented in [Full offline scan](offline-scanner.md). Extend that document, the Cartesian action test, and both collection-side tests whenever the policy changes.
|
|
61
61
|
|
|
62
62
|
The revision-tree safety rules are documented in [Conflict resolution and file provenance](conflict-resolution.md). `ConflictManager.unit.spec.ts` owns common-ancestor and conservative merge behaviour, deterministic pair selection for multiple live leaves, and reconstruction from the committed tree between stages. `ServiceDatabaseFileAccessBase.unit.spec.ts` owns all-branch content discovery, short-circuiting history existence checks after an exact match, and exact-revision logical deletion. `ServiceFileHandlerBase.unit.spec.ts` owns the injected provenance policy for edit, deletion, and rename operations, together with user-selected reflection, child creation, strict matching, matching-provenance cleanup after exact branch deletion, and stale-revision refusal. `LiveSyncLocalDB.unit.spec.ts` owns chunk reachability across current winners, shared chunks, live conflicts, available merge ancestry, conflict resolution, and PouchDB replication of collection and recreation. Extend the relevant sides when changing conflict resolution, compaction handling, provenance, chunk collection, or database-to-storage overwrite protection.
|
|
63
63
|
|
package/docs/proven-in-use.md
CHANGED
|
@@ -22,6 +22,8 @@ Commonlib's injected CouchDB tests verify that a mixed remote chunk batch preser
|
|
|
22
22
|
|
|
23
23
|
Commonlib's remote-preference result contract distinguishes available synchronisation settings, a remote which has not stored those settings yet, a failed read, and a remote type which does not support central preferences. Focused CouchDB and Object Storage tests cover absent milestone data separately from transport failures, while Self-hosted LiveSync owns the initialisation choices, cancellation policy, and user-facing explanation.
|
|
24
24
|
|
|
25
|
+
Commonlib's full offline scanner distinguishes completed, deliberately skipped, and failed storage/database pairs. Before pair construction, it withholds Metadata whose actual document ID disagrees with its recorded path, without reflection, deletion, or last-seen side effects. A consistently addressed entry for the same logical path continues through the established path-based flow; only a path without such an entry also withholds storage processing. The read-only actual-ID Inspector and separately invoked one-entry repair retain ambiguous states for review, revalidate an exact source revision, verify a target-first copy, and preserve the source on failure. Self-hosted LiveSync keeps the existing Fast Setup aggregate policy and owns confirmation and presentation in its file/database repair interface.
|
|
26
|
+
|
|
25
27
|
Most replication and storage services used by the plug-in still enter through explicit `compat/*` paths. Those imports prove that the published compatibility boundary supports the current migration; they are not examples of a finished high-level client API.
|
|
26
28
|
|
|
27
29
|
The plug-in's manual onboarding and Remote Databases pane use the focused `/remote-configurations` entry to create opaque profile IDs, retain user-visible names, and select main and P2P remotes. Commonlib owns the in-memory profile result contract; Self-hosted LiveSync adds dialogue, Setup URI classification, Fetch or Rebuild scheduling, persistence, restart ordering, and real-Obsidian presentation checks.
|
|
@@ -38,6 +40,8 @@ The CLI filesystem composition receives a configured base directory and construc
|
|
|
38
40
|
|
|
39
41
|
The maintained CLI checks include unit tests, Deno-driven CLI E2E, Compose-based compatibility runs, and the CLI-to-real-Obsidian synchronisation scenario in `test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts`. The latter is the strongest current evidence that the CLI and plug-in compose compatible Commonlib behaviour across two hosts.
|
|
40
42
|
|
|
43
|
+
The CLI mirror command returns Commonlib's full-scan aggregate, and daemon start-up stops when that aggregate reports an actual pair failure. Deliberately skipped pairs remain a successful scan result. Commonlib owns the pair classification and aggregate; the CLI owns its command and process policy.
|
|
44
|
+
|
|
41
45
|
## WebApp
|
|
42
46
|
|
|
43
47
|
WebApp receives an already authorised `FileSystemDirectoryHandle` from its host and constructs `FileSystemAccessStorageAdapter` inside `src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts`. The adapter does not call `showDirectoryPicker()`, choose a directory, or persist the handle. This is the maintained application example for the browser entry's root-ownership rule.
|