@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
|
-
|
|
132
|
-
decryptedDoc =
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
131
|
+
if (doc._deleted) {
|
|
132
|
+
decryptedDoc = doc;
|
|
133
|
+
} else {
|
|
134
|
+
try {
|
|
135
|
+
decryptedDoc = await decryptFunction(doc);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new StreamingFetchFailure(
|
|
138
|
+
"decryption",
|
|
139
|
+
`Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
|
|
140
|
+
false,
|
|
141
|
+
{ cause: error }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
140
144
|
}
|
|
141
145
|
let serialisedDoc;
|
|
142
146
|
try {
|
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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,
|