@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.
@@ -128,15 +128,19 @@ function generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint)
128
128
  return {
129
129
  async write(doc, sequence) {
130
130
  let decryptedDoc;
131
- try {
132
- decryptedDoc = await decryptFunction(doc);
133
- } catch (error) {
134
- throw new StreamingFetchFailure(
135
- "decryption",
136
- `Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
137
- false,
138
- { cause: error }
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
- this.syncStatus = "STARTED";
550
- this.updateInfo();
551
- const docArrivedOnStart = this.docArrived;
552
- const docSentOnStart = this.docSent;
553
- if (!retrying) {
554
- this.originalSetting = setting;
555
- }
556
- this.terminateSync();
557
- const syncHandler = syncMode == "sync" ? localDB.sync(db, { ...syncOptionBase }) : syncMode == "pullOnly" ? localDB.replicate.from(db, {
558
- ...syncOptionBase,
559
- ...setting.readChunksOnline ? selectorOnDemandPull : {}
560
- }) : syncMode == "pushOnly" ? localDB.replicate.to(db, { ...syncOptionBase }) : void 0;
561
- const syncResult = await this.processSync(
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
- return async () => await this.openOneShotReplication(
582
- this.originalSetting,
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
- false,
565
+ docSentOnStart,
566
+ docArrivedOnStart,
585
567
  syncMode,
586
- ignoreCleanLock
568
+ retrying,
569
+ false
587
570
  );
588
- }
589
- if (syncResult == "NEED_RETRY") {
590
- const tempSetting = JSON.parse(JSON.stringify(setting));
591
- tempSetting.batch_size = Math.ceil(tempSetting.batch_size / 2) + 2;
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
- } else {
600
- Logger(
601
- this.translate("liveSyncReplicator.retryLowerBatchSize", {
602
- batch_size: tempSetting.batch_size.toString(),
603
- batches_limit: tempSetting.batches_limit.toString()
604
- }),
605
- showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
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
- if (!skipCheck) {
642
- if (!await checkRemoteVersion(dbRet.db, this.migrate.bind(this), VER)) {
643
- Logger(this.translate("liveSyncReplicator.remoteDbCorrupted"), LOG_LEVEL_NOTICE);
644
- return false;
645
- }
646
- this.remoteCleaned = false;
647
- this.remoteLocked = false;
648
- this.remoteLockedAndDeviceNotAccepted = false;
649
- this.tweakSettingsMismatched = false;
650
- this.preferredTweakValue = void 0;
651
- const progress = `${dbRet.info.update_seq}`;
652
- const info = {
653
- app_version: this.env.services.API.getAppVersion(),
654
- plugin_version: this.env.services.API.getPluginVersion(),
655
- vault_name: this.env.services.vault.vaultName(),
656
- device_name: this.env.services.vault.getVaultName(),
657
- progress
658
- };
659
- const ensure = await ensureDatabaseIsCompatible(dbRet.db, setting, this.nodeid, currentVersionRange, info);
660
- if (ensure == "INCOMPATIBLE") {
661
- Logger(
662
- "The remote database has no compatibility with the running version. Please upgrade the plugin.",
663
- LOG_LEVEL_NOTICE
664
- );
665
- return false;
666
- } else if (ensure == "NODE_LOCKED") {
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
- this.remoteLockedAndDeviceNotAccepted = true;
672
- this.remoteLocked = true;
673
- return false;
674
- } else if (ensure == "LOCKED") {
675
- this.remoteLocked = true;
676
- } else if (ensure == "NODE_CLEANED") {
677
- if (ignoreCleanLock) {
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 cleaned up. Fetch rebuilt DB, explicit unlocking or chunk clean-up is required.",
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
- this.remoteCleaned = true;
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
- } else if (ensure == "OK") {
690
- } else if (ensure[0] == "MISMATCHED") {
691
- Logger(this.translate("liveSyncReplicator.mismatchedTweakDetected"), LOG_LEVEL_NOTICE);
692
- this.tweakSettingsMismatched = true;
693
- this.preferredTweakValue = ensure[1];
694
- return false;
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<void>;
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<FilePathWithPrefix, FilePathWithPrefixLC>;
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<void>;
50
- export declare function updateToStorage(host: NecessaryServices<"vault" | "path", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, w: MetaEntry): Promise<void>;
51
- export declare function syncStorageAndDatabase(host: NecessaryServices<"setting" | "vault" | "path", "storageAccess" | "fileHandler">, log: LogFunction, file: UXFileInfoStub, logLevel: LOG_LEVEL, doc: MetaEntry): Promise<void>;
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 if scan completed successfully
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
- break;
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
- const path = getPathFromEntry(host, doc);
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
- return { databaseFileNameMap, databaseFileNames, databaseFileNameCI2CS };
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 true;
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
- updateFileMTimeInMap(host, fileMapKey, doc.mtime);
339
- return true;
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
- updateFileMTimeInMap(host, fileMapKey, Math.max(file.stat.mtime, doc.mtime));
346
- return true;
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 true;
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 true;
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 true;
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 false;
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 successCount = 0;
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 false;
760
+ return FilePairProcessResults.FAILED;
419
761
  }
420
762
  },
421
763
  10
422
764
  )) {
423
765
  processedCount++;
424
- if (result) {
425
- successCount++;
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: ${successCount}/${processedCount} files processed successfully`,
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 successCount === processedCount;
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
- const kvDB = host.services.keyValueDB.kvDB;
459
- if (!kvDB?.get) {
460
- fileMaps = /* @__PURE__ */ new Map();
461
- return;
462
- }
463
- const mapItems = await kvDB.get("fileStatusMap") || {};
464
- fileMaps = new Map(Object.entries(mapItems));
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
- const kvDB = host.services.keyValueDB.kvDB;
468
- if (!kvDB?.set) {
469
- return;
470
- }
471
- await kvDB.set("fileStatusMap", Object.fromEntries(fileMaps));
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 true;
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,
@@ -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. Extend both storage-collection and database-collection tests whenever the built-in exclusions change.
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
 
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",