@vrtmrz/livesync-commonlib 0.1.11 → 0.1.13

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 {
@@ -1,4 +1,4 @@
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";
@@ -15,13 +15,121 @@ export declare const FilePairProcessResults: {
15
15
  readonly FAILED: "failed";
16
16
  };
17
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>;
18
126
  /**
19
127
  * Collect deleted files that have expired according to retention policy.
20
128
  * @param host Services container
21
129
  * @param log Logging function
22
130
  * @returns Array of expired deletion history
23
131
  */
24
- 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>;
25
133
  /**
26
134
  * Get the file path from a meta entry.
27
135
  * This is a helper function to extract path from various document types.
@@ -53,11 +161,10 @@ export declare function collectFilesOnStorage(host: NecessaryServices<"vault", "
53
161
  storageFileNameCI2CS: Record<FilePathWithPrefixLC, FilePathWithPrefix>;
54
162
  }>;
55
163
  export declare function collectDatabaseFiles(host: NecessaryServices<"database" | "vault" | "path", never>, settings: ObsidianLiveSyncSettings, log: LogFunction, showingNotice: boolean): Promise<{
56
- databaseFileNameMap: {
57
- [k: string]: MetaEntry;
58
- };
164
+ databaseFileNameMap: Record<FilePathWithPrefix, MetaEntry>;
59
165
  databaseFileNames: FilePathWithPrefix[];
60
- databaseFileNameCI2CS: Record<FilePathWithPrefix, FilePathWithPrefixLC>;
166
+ databaseFileNameCI2CS: Record<FilePathWithPrefixLC, FilePathWithPrefix>;
167
+ quarantinedFileNamesLC: Set<FilePathWithPrefixLC>;
61
168
  }>;
62
169
  export declare function updateToDatabase(host: NecessaryServices<"vault", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, file: UXFileInfoStub): Promise<FilePairProcessResult>;
63
170
  export declare function updateToStorage(host: NecessaryServices<"vault" | "path", "fileHandler">, log: LogFunction, logLevel: LOG_LEVEL, w: MetaEntry): Promise<FilePairProcessResult>;
@@ -122,6 +229,14 @@ export declare function resolveFilePairAction(state: FilePairState, options: Ful
122
229
  */
123
230
  export declare function synchroniseAllFilesBetweenDBandStorage(host: NecessaryServices<"setting" | "vault" | "path" | "fileProcessing" | "database" | "keyValueDB", "storageAccess" | "fileHandler">, log: LogFunction, errorManager: UnresolvedErrorManager, options: FullScanOptions): Promise<boolean>;
124
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>;
125
240
  /**
126
241
  * Perform a full scan and synchronisation between database and storage.
127
242
  * @param host Services container
@@ -13,11 +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";
16
18
  const FilePairProcessResults = {
17
19
  COMPLETED: "completed",
18
20
  SKIPPED: "skipped",
19
21
  FAILED: "failed"
20
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
+ }
21
306
  async function collectDeletedFiles(host, log) {
22
307
  const limitDays = host.services.setting.currentSettings().automaticallyDeleteMetadataOfDeletedFiles;
23
308
  if (limitDays <= 0) return;
@@ -27,6 +312,16 @@ async function collectDeletedFiles(host, log) {
27
312
  for await (const doc of host.services.database.localDatabase.findAllDocs({ conflicts: true })) {
28
313
  if (isAnyNote(doc)) {
29
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
+ }
30
325
  notes.push({
31
326
  path: doc.path,
32
327
  mtime: doc.mtime,
@@ -147,6 +442,8 @@ async function collectFilesOnStorage(host, settings, log) {
147
442
  async function collectDatabaseFiles(host, settings, log, showingNotice) {
148
443
  log("Collecting local files on the DB", LOG_LEVEL_VERBOSE);
149
444
  const _DBEntries = [];
445
+ const unresolvedFileNamesLC = /* @__PURE__ */ new Set();
446
+ const resolvableFileNamesLC = /* @__PURE__ */ new Set();
150
447
  let count = 0;
151
448
  for await (const doc of host.services.database.localDatabase.findAllNormalDocs({ conflicts: true }) || []) {
152
449
  count++;
@@ -156,20 +453,44 @@ async function collectDatabaseFiles(host, settings, log, showingNotice) {
156
453
  showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
157
454
  "syncAll"
158
455
  );
159
- 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;
160
476
  if (host.services.vault.isValidPath(path) && !shouldBeIgnored(path) && await host.services.vault.isTargetFile(path)) {
161
- if (!isMetaEntry(doc)) {
162
- log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
163
- continue;
164
- }
165
477
  _DBEntries.push(doc);
478
+ resolvableFileNamesLC.add(convertCase(settings, stripAllPrefixes(path)));
166
479
  }
167
480
  }
168
481
  const databaseFileNameMap = Object.fromEntries(_DBEntries.map((e) => [getPathFromEntry(host, e), e]));
169
482
  const databaseFileNames = Object.keys(databaseFileNameMap);
170
483
  const databaseFileNameCapsPair = databaseFileNames.map((e) => [e, convertCase(settings, e)]);
171
484
  const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map((e) => [e[1], e[0]]));
172
- 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
+ };
173
494
  }
174
495
  async function updateToDatabase(host, log, logLevel, file) {
175
496
  if (!host.services.vault.isFileSizeTooLarge(file.stat.size)) {
@@ -407,17 +728,15 @@ async function synchroniseAllFilesBetweenDBandStorage(host, log, errorManager, o
407
728
  const showingNotice = options.showingNotice ?? false;
408
729
  await loadFileStatus(host);
409
730
  const { storageFileNameMap, storageFileNameCI2CS } = await collectFilesOnStorage(host, settings, log);
410
- const { databaseFileNameMap, databaseFileNameCI2CS } = await collectDatabaseFiles(
411
- host,
412
- settings,
413
- log,
414
- showingNotice
415
- );
731
+ const { databaseFileNameMap, databaseFileNameCI2CS, quarantinedFileNamesLC } = await collectDatabaseFiles(host, settings, log, showingNotice);
416
732
  const pairs = [];
417
733
  for (const fileNameLC of unique([
418
734
  ...Object.keys(storageFileNameCI2CS),
419
735
  ...Object.keys(databaseFileNameCI2CS)
420
736
  ])) {
737
+ if (quarantinedFileNamesLC.has(fileNameLC)) {
738
+ continue;
739
+ }
421
740
  const fileName = fileNameLC in storageFileNameCI2CS ? storageFileNameCI2CS[fileNameLC] : void 0;
422
741
  const file = fileName ? storageFileNameMap[fileName] : void 0;
423
742
  const databaseName = fileNameLC in databaseFileNameCI2CS ? databaseFileNameCI2CS[fileNameLC] : void 0;
@@ -485,21 +804,43 @@ function normaliseFullScanOptions(showingNoticeOrOptions, ignoreSuspending = fal
485
804
  };
486
805
  }
487
806
  let fileMaps = /* @__PURE__ */ new Map();
807
+ const FILE_STATUS_MAP_KEY = "fileStatusMap";
808
+ const FILE_STATUS_MAP_LOCK = "offlineScanner-fileStatusMap";
488
809
  async function loadFileStatus(host) {
489
- const kvDB = host.services.keyValueDB.kvDB;
490
- if (!kvDB?.get) {
491
- fileMaps = /* @__PURE__ */ new Map();
492
- return;
493
- }
494
- const mapItems = await kvDB.get("fileStatusMap") || {};
495
- 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
+ });
496
819
  }
497
820
  async function _saveFileStatus(host) {
498
- const kvDB = host.services.keyValueDB.kvDB;
499
- if (!kvDB?.set) {
500
- return;
501
- }
502
- 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
+ });
503
844
  }
504
845
  let saveFileStatusTimeout = null;
505
846
  function saveFileStatus(host, immediate = false) {
@@ -567,15 +908,24 @@ export {
567
908
  ExtraOnRemote,
568
909
  FilePairProcessResults,
569
910
  FullScanModes,
911
+ MetadataDocumentIdentityStatuses,
912
+ MetadataDocumentNamespaces,
913
+ MetadataDocumentRepairResults,
914
+ OfflineScanUnresolvedReasons,
570
915
  canProceedScan,
916
+ clearOfflineScannerLastSeen,
571
917
  collectDatabaseFiles,
572
918
  collectDeletedFiles,
573
919
  collectFilesOnStorage,
574
920
  convertCase,
575
921
  getFilePairState,
922
+ getMetadataDocumentNamespace,
576
923
  getPathFromEntry,
924
+ inspectMetadataDocumentIdentities,
925
+ inspectMetadataDocumentIdentity,
577
926
  normaliseFullScanOptions,
578
927
  performFullScan,
928
+ repairMetadataDocumentIdentity,
579
929
  resolveFilePairAction,
580
930
  syncFileBetweenDBandStorage,
581
931
  syncStorageAndDatabase,