@vrtmrz/livesync-commonlib 0.1.42 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/common/pathIdentity.d.ts +10 -1
- package/dist/common/pathIdentity.js +1 -0
- package/dist/diagnostics/couchdb.d.ts +15 -0
- package/dist/diagnostics/couchdb.js +129 -0
- package/dist/diagnostics/index.d.ts +10 -0
- package/dist/diagnostics/index.js +26 -0
- package/dist/diagnostics/integrity.d.ts +5 -0
- package/dist/diagnostics/integrity.js +674 -0
- package/dist/diagnostics/local.d.ts +4 -0
- package/dist/diagnostics/local.js +25 -0
- package/dist/diagnostics/reports.d.ts +108 -0
- package/dist/diagnostics/reports.js +353 -0
- package/dist/diagnostics/types.d.ts +130 -0
- package/dist/diagnostics/types.js +13 -0
- package/dist/diagnostics/usage.d.ts +51 -0
- package/dist/diagnostics/usage.js +241 -0
- package/dist/pouchdb/encryption.d.ts +3 -1
- package/dist/pouchdb/encryption.js +38 -30
- package/dist/serviceFeatures/offlineScanner.d.ts +11 -2
- package/dist/serviceModules/Rebuilder.js +1 -1
- package/docs/database-diagnostics.md +24 -0
- package/docs/development.md +1 -0
- package/docs/proven-in-use.md +5 -1
- package/docs/unicode-path-identity.md +98 -0
- package/package.json +9 -1
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DOCID_SYNC_PARAMETERS,
|
|
3
|
+
EntryTypes,
|
|
4
|
+
MILESTONE_DOCID,
|
|
5
|
+
NODEINFO_DOCID,
|
|
6
|
+
SYNCINFO_ID,
|
|
7
|
+
VERSIONING_DOCID
|
|
8
|
+
} from "../common/types.js";
|
|
9
|
+
import { isChunk } from "../common/typeUtils.js";
|
|
10
|
+
import { readAsBlob } from "../common/utils.js";
|
|
11
|
+
import {
|
|
12
|
+
inspectMetadataDocumentIdentity,
|
|
13
|
+
MetadataDocumentIdentityStatuses
|
|
14
|
+
} from "../serviceFeatures/offlineScanner.js";
|
|
15
|
+
import { DiagnosticReadError } from "./types.js";
|
|
16
|
+
const EXCLUDED_IDS = /* @__PURE__ */ new Set([
|
|
17
|
+
DOCID_SYNC_PARAMETERS,
|
|
18
|
+
MILESTONE_DOCID,
|
|
19
|
+
NODEINFO_DOCID,
|
|
20
|
+
SYNCINFO_ID,
|
|
21
|
+
VERSIONING_DOCID
|
|
22
|
+
]);
|
|
23
|
+
const INITIAL_COVERAGE = {
|
|
24
|
+
status: "not-checked",
|
|
25
|
+
documents: 0,
|
|
26
|
+
metadata: 0,
|
|
27
|
+
excluded: 0,
|
|
28
|
+
conflictBranches: 0,
|
|
29
|
+
contentReads: 0,
|
|
30
|
+
changedDuringRun: false
|
|
31
|
+
};
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return value !== null && typeof value === "object";
|
|
34
|
+
}
|
|
35
|
+
function isCurrentMetadata(value) {
|
|
36
|
+
return isRecord(value) && (value.type === EntryTypes.NOTE_BINARY || value.type === EntryTypes.NOTE_PLAIN);
|
|
37
|
+
}
|
|
38
|
+
function isValidCurrentMetadata(value) {
|
|
39
|
+
return typeof value._id === "string" && typeof value._rev === "string" && typeof value.path === "string" && Array.isArray(value.children) && value.children.every((child) => typeof child === "string");
|
|
40
|
+
}
|
|
41
|
+
function isMissingRead(error) {
|
|
42
|
+
if (error instanceof DiagnosticReadError) return error.missing;
|
|
43
|
+
if (!isRecord(error)) return false;
|
|
44
|
+
return error.status === 404 || error.name === "not_found" || error.error === "not_found";
|
|
45
|
+
}
|
|
46
|
+
function reasonFromReadError(error, fallback) {
|
|
47
|
+
return error instanceof DiagnosticReadError ? error.reason : fallback;
|
|
48
|
+
}
|
|
49
|
+
function hasUpdateSequence(value) {
|
|
50
|
+
return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
51
|
+
}
|
|
52
|
+
function appendError(errors, source, reason, details = {}) {
|
|
53
|
+
errors.push({ source, reason, ...details });
|
|
54
|
+
}
|
|
55
|
+
function progress(options, phase, processed) {
|
|
56
|
+
try {
|
|
57
|
+
options.onProgress?.({ phase, processed });
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function cancelled(options) {
|
|
62
|
+
return options.signal?.aborted === true;
|
|
63
|
+
}
|
|
64
|
+
function isConfigurationCurrent(options) {
|
|
65
|
+
try {
|
|
66
|
+
return options.isConfigurationCurrent();
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isSkippedDocumentId(id) {
|
|
72
|
+
return isChunk(id) || id.startsWith("_local/") || id.startsWith("_design/") || EXCLUDED_IDS.has(id);
|
|
73
|
+
}
|
|
74
|
+
function makeSourceRun(name, source) {
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
source,
|
|
78
|
+
coverage: { ...INITIAL_COVERAGE, status: "partial" },
|
|
79
|
+
errors: [],
|
|
80
|
+
observations: [],
|
|
81
|
+
metadataScanComplete: false,
|
|
82
|
+
enumerationComplete: false,
|
|
83
|
+
endInfoAvailable: true
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function classifyObservation(options, run, document, branch, conflictCount, reference) {
|
|
87
|
+
if (!isValidCurrentMetadata(document)) {
|
|
88
|
+
appendError(run.errors, run.name, "invalid-metadata", {
|
|
89
|
+
id: typeof document._id === "string" ? document._id : void 0,
|
|
90
|
+
revision: typeof document._rev === "string" ? document._rev : void 0
|
|
91
|
+
});
|
|
92
|
+
return void 0;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const identity = await inspectMetadataDocumentIdentity({ services: { path: options.path } }, document);
|
|
96
|
+
if (identity.status === MetadataDocumentIdentityStatuses.EXCLUDED) {
|
|
97
|
+
run.coverage.excluded++;
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
const rawPath = document.path;
|
|
101
|
+
const mismatch = identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED ? identity.diagnostic.reason : void 0;
|
|
102
|
+
const report = {
|
|
103
|
+
reference,
|
|
104
|
+
source: run.name,
|
|
105
|
+
id: document._id,
|
|
106
|
+
...typeof document._rev === "string" ? { revision: document._rev } : {},
|
|
107
|
+
path: rawPath,
|
|
108
|
+
...identity.status === MetadataDocumentIdentityStatuses.CONSISTENT ? { expectedId: identity.expectedDocumentId } : identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED && identity.diagnostic.expectedDocumentId ? { expectedId: identity.diagnostic.expectedDocumentId } : {},
|
|
109
|
+
identity: identity.status === MetadataDocumentIdentityStatuses.CONSISTENT ? "consistent" : mismatch === "namespace-mismatch" ? "namespace-mismatch" : "document-id-mismatch",
|
|
110
|
+
logicallyDeleted: Boolean(document.deleted || document._deleted),
|
|
111
|
+
branch,
|
|
112
|
+
conflictCount,
|
|
113
|
+
pathIsNFC: rawPath === rawPath.normalize("NFC"),
|
|
114
|
+
caseEquivalentPaths: false,
|
|
115
|
+
nfcEquivalentPaths: false,
|
|
116
|
+
content: {
|
|
117
|
+
status: "not-checked",
|
|
118
|
+
chunksRead: 0,
|
|
119
|
+
unavailableChunks: []
|
|
120
|
+
},
|
|
121
|
+
otherSourceGroup: "not-checked",
|
|
122
|
+
vault: { reference: `vault:${reference}`, status: "not-checked" },
|
|
123
|
+
comparisons: []
|
|
124
|
+
};
|
|
125
|
+
const observation = { report, source: run.source, document };
|
|
126
|
+
run.observations.push(observation);
|
|
127
|
+
if (branch === "winner") run.coverage.metadata++;
|
|
128
|
+
else run.coverage.conflictBranches++;
|
|
129
|
+
return observation;
|
|
130
|
+
} catch {
|
|
131
|
+
appendError(run.errors, run.name, "invalid-metadata", {
|
|
132
|
+
id: document._id,
|
|
133
|
+
revision: document._rev,
|
|
134
|
+
path: document.path
|
|
135
|
+
});
|
|
136
|
+
return void 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function scanSource(options, run, nextReference, metadataProgress) {
|
|
140
|
+
if (cancelled(options)) return;
|
|
141
|
+
try {
|
|
142
|
+
const info = await run.source.info();
|
|
143
|
+
run.startSeq = info.update_seq;
|
|
144
|
+
if (!hasUpdateSequence(run.startSeq)) appendError(run.errors, run.name, "source-unavailable");
|
|
145
|
+
} catch {
|
|
146
|
+
appendError(run.errors, run.name, "source-unavailable");
|
|
147
|
+
}
|
|
148
|
+
if (cancelled(options)) return;
|
|
149
|
+
let enumerationComplete = false;
|
|
150
|
+
try {
|
|
151
|
+
for await (const id of run.source.enumerateIds()) {
|
|
152
|
+
if (cancelled(options)) return;
|
|
153
|
+
if (typeof id !== "string") {
|
|
154
|
+
appendError(run.errors, run.name, "source-unavailable");
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
run.coverage.documents++;
|
|
158
|
+
metadataProgress.processed++;
|
|
159
|
+
progress(options, "metadata", metadataProgress.processed);
|
|
160
|
+
if (isSkippedDocumentId(id)) {
|
|
161
|
+
run.coverage.excluded++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (cancelled(options)) return;
|
|
165
|
+
let winnerValue;
|
|
166
|
+
try {
|
|
167
|
+
winnerValue = await run.source.get(id, { conflicts: true });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (cancelled(options)) return;
|
|
170
|
+
appendError(run.errors, run.name, reasonFromReadError(error, "document-unreadable"), { id });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (cancelled(options)) return;
|
|
174
|
+
if (!isRecord(winnerValue) || winnerValue._id !== id) {
|
|
175
|
+
appendError(run.errors, run.name, "invalid-metadata", { id });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!isCurrentMetadata(winnerValue)) {
|
|
179
|
+
run.coverage.excluded++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const winner = winnerValue;
|
|
183
|
+
if (!isValidCurrentMetadata(winner)) {
|
|
184
|
+
appendError(run.errors, run.name, "invalid-metadata", {
|
|
185
|
+
id,
|
|
186
|
+
revision: typeof winner._rev === "string" ? winner._rev : void 0
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const conflictRevisions = Array.isArray(winner._conflicts) ? winner._conflicts.filter((revision) => typeof revision === "string") : [];
|
|
191
|
+
await classifyObservation(
|
|
192
|
+
options,
|
|
193
|
+
run,
|
|
194
|
+
winner,
|
|
195
|
+
"winner",
|
|
196
|
+
conflictRevisions.length,
|
|
197
|
+
nextReference(run.name)
|
|
198
|
+
);
|
|
199
|
+
if (cancelled(options)) return;
|
|
200
|
+
for (const revision of conflictRevisions) {
|
|
201
|
+
if (cancelled(options)) return;
|
|
202
|
+
let branchValue;
|
|
203
|
+
try {
|
|
204
|
+
branchValue = await run.source.get(id, { rev: revision, conflicts: true });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (cancelled(options)) return;
|
|
207
|
+
appendError(run.errors, run.name, reasonFromReadError(error, "revision-unavailable"), {
|
|
208
|
+
id,
|
|
209
|
+
revision
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (cancelled(options)) return;
|
|
214
|
+
if (!isRecord(branchValue) || branchValue._id !== id || branchValue._rev !== revision) {
|
|
215
|
+
appendError(run.errors, run.name, "revision-unavailable", { id, revision });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!isCurrentMetadata(branchValue)) {
|
|
219
|
+
run.coverage.excluded++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
await classifyObservation(
|
|
223
|
+
options,
|
|
224
|
+
run,
|
|
225
|
+
branchValue,
|
|
226
|
+
"conflict",
|
|
227
|
+
conflictRevisions.length,
|
|
228
|
+
nextReference(run.name)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
enumerationComplete = true;
|
|
233
|
+
} catch {
|
|
234
|
+
if (!cancelled(options)) appendError(run.errors, run.name, "source-unavailable");
|
|
235
|
+
}
|
|
236
|
+
if (cancelled(options)) return;
|
|
237
|
+
run.metadataScanComplete = enumerationComplete && run.errors.length === 0;
|
|
238
|
+
run.enumerationComplete = enumerationComplete;
|
|
239
|
+
}
|
|
240
|
+
function groupObservations(runs) {
|
|
241
|
+
const observations = runs.flatMap((run) => run.observations);
|
|
242
|
+
const parents = observations.map((_, index) => index);
|
|
243
|
+
const find = (index) => {
|
|
244
|
+
if (parents[index] !== index) parents[index] = find(parents[index]);
|
|
245
|
+
return parents[index];
|
|
246
|
+
};
|
|
247
|
+
const union = (left, right) => {
|
|
248
|
+
const leftRoot = find(left);
|
|
249
|
+
const rightRoot = find(right);
|
|
250
|
+
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
|
|
251
|
+
};
|
|
252
|
+
const byExpectedId = /* @__PURE__ */ new Map();
|
|
253
|
+
const byRawPath = /* @__PURE__ */ new Map();
|
|
254
|
+
observations.forEach((observation, index) => {
|
|
255
|
+
const { expectedId, path } = observation.report;
|
|
256
|
+
if (expectedId !== void 0) {
|
|
257
|
+
const previous = byExpectedId.get(expectedId);
|
|
258
|
+
if (previous === void 0) byExpectedId.set(expectedId, index);
|
|
259
|
+
else union(index, previous);
|
|
260
|
+
}
|
|
261
|
+
const previousPath = byRawPath.get(path);
|
|
262
|
+
if (previousPath === void 0) byRawPath.set(path, index);
|
|
263
|
+
else union(index, previousPath);
|
|
264
|
+
});
|
|
265
|
+
const groupsByRoot = /* @__PURE__ */ new Map();
|
|
266
|
+
observations.forEach((observation, index) => {
|
|
267
|
+
const root = find(index);
|
|
268
|
+
const group = groupsByRoot.get(root) ?? { key: `group:${root}`, observations: [], suspect: false };
|
|
269
|
+
group.observations.push(observation);
|
|
270
|
+
groupsByRoot.set(root, group);
|
|
271
|
+
});
|
|
272
|
+
return [...groupsByRoot.values()];
|
|
273
|
+
}
|
|
274
|
+
function markPathEquivalences(observations) {
|
|
275
|
+
const pathsByCaseFold = /* @__PURE__ */ new Map();
|
|
276
|
+
const pathsByNfc = /* @__PURE__ */ new Map();
|
|
277
|
+
for (const observation of observations) {
|
|
278
|
+
const path = observation.report.path;
|
|
279
|
+
const caseFold = path.toLowerCase();
|
|
280
|
+
const casePaths = pathsByCaseFold.get(caseFold) ?? /* @__PURE__ */ new Set();
|
|
281
|
+
casePaths.add(path);
|
|
282
|
+
pathsByCaseFold.set(caseFold, casePaths);
|
|
283
|
+
const nfc = path.normalize("NFC");
|
|
284
|
+
const nfcPaths = pathsByNfc.get(nfc) ?? /* @__PURE__ */ new Set();
|
|
285
|
+
nfcPaths.add(path);
|
|
286
|
+
pathsByNfc.set(nfc, nfcPaths);
|
|
287
|
+
}
|
|
288
|
+
for (const observation of observations) {
|
|
289
|
+
const path = observation.report.path;
|
|
290
|
+
observation.report.caseEquivalentPaths = (pathsByCaseFold.get(path.toLowerCase())?.size ?? 0) > 1;
|
|
291
|
+
observation.report.nfcEquivalentPaths = (pathsByNfc.get(path.normalize("NFC"))?.size ?? 0) > 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function markSuspectGroups(groups, runs) {
|
|
295
|
+
const crossSourceComparisonAvailable = runs.some((run) => run.name === "local" && run.metadataScanComplete) && runs.some((run) => run.name === "remote" && run.metadataScanComplete);
|
|
296
|
+
for (const group of groups) {
|
|
297
|
+
const observations = group.observations;
|
|
298
|
+
group.suspect = observations.some((observation) => observation.report.identity !== "consistent") || observations.some((observation) => observation.report.conflictCount > 0);
|
|
299
|
+
const claimsBySourceAndPath = /* @__PURE__ */ new Map();
|
|
300
|
+
for (const observation of observations) {
|
|
301
|
+
const key = `${observation.report.source}\0${observation.report.path}`;
|
|
302
|
+
const ids = claimsBySourceAndPath.get(key) ?? /* @__PURE__ */ new Set();
|
|
303
|
+
ids.add(observation.report.id);
|
|
304
|
+
claimsBySourceAndPath.set(key, ids);
|
|
305
|
+
}
|
|
306
|
+
if ([...claimsBySourceAndPath.values()].some((ids) => ids.size > 1)) group.suspect = true;
|
|
307
|
+
for (const observation of observations) {
|
|
308
|
+
const otherSource = observation.report.source === "local" ? "remote" : "local";
|
|
309
|
+
const otherRun = runs.find((run) => run.name === otherSource);
|
|
310
|
+
observation.report.otherSourceGroup = observations.some((other) => other.report.source === otherSource) ? "present" : otherRun?.metadataScanComplete ? "missing" : "not-checked";
|
|
311
|
+
}
|
|
312
|
+
if (!crossSourceComparisonAvailable) continue;
|
|
313
|
+
const localWinners = observations.filter(
|
|
314
|
+
(observation) => observation.report.source === "local" && observation.report.branch === "winner"
|
|
315
|
+
);
|
|
316
|
+
const remoteWinners = observations.filter(
|
|
317
|
+
(observation) => observation.report.source === "remote" && observation.report.branch === "winner"
|
|
318
|
+
);
|
|
319
|
+
if (localWinners.length === 0 || remoteWinners.length === 0) {
|
|
320
|
+
group.suspect = true;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const state = (observation) => `${observation.report.revision ?? ""}\0${observation.report.logicallyDeleted}`;
|
|
324
|
+
if (localWinners.some((local) => !remoteWinners.some((remote) => state(local) === state(remote))) || remoteWinners.some((remote) => !localWinners.some((local) => state(remote) === state(local)))) {
|
|
325
|
+
group.suspect = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function isValidChunk(value, id) {
|
|
330
|
+
return isRecord(value) && value._id === id && value.type === EntryTypes.CHUNK && typeof value.data === "string";
|
|
331
|
+
}
|
|
332
|
+
function getEdenChunk(document, id) {
|
|
333
|
+
const eden = document.eden;
|
|
334
|
+
if (!isRecord(eden)) return void 0;
|
|
335
|
+
const chunk = eden[id];
|
|
336
|
+
if (!isRecord(chunk) || typeof chunk.data !== "string") return void 0;
|
|
337
|
+
return chunk.data;
|
|
338
|
+
}
|
|
339
|
+
async function readObservationContent(options, observation, run) {
|
|
340
|
+
run.coverage.contentReads++;
|
|
341
|
+
const unavailableChunks = [];
|
|
342
|
+
const data = [];
|
|
343
|
+
let chunksRead = 0;
|
|
344
|
+
if (observation.document.type !== EntryTypes.NOTE_BINARY && observation.document.type !== EntryTypes.NOTE_PLAIN) {
|
|
345
|
+
appendError(run.errors, run.name, "unsupported-content", {
|
|
346
|
+
id: observation.report.id,
|
|
347
|
+
revision: observation.report.revision,
|
|
348
|
+
path: observation.report.path
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
content: { status: "unreadable", reason: "unsupported-content" },
|
|
352
|
+
chunksRead,
|
|
353
|
+
unavailableChunks
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
for (const child of observation.document.children) {
|
|
357
|
+
if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
|
|
358
|
+
const eden = getEdenChunk(observation.document, child);
|
|
359
|
+
if (eden !== void 0) {
|
|
360
|
+
data.push(eden);
|
|
361
|
+
chunksRead++;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const chunk = await observation.source.get(child);
|
|
366
|
+
if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
|
|
367
|
+
if (!isValidChunk(chunk, child) || chunk._deleted === true) {
|
|
368
|
+
unavailableChunks.push({ id: child, status: "unreadable" });
|
|
369
|
+
appendError(run.errors, run.name, "chunk-unreadable", {
|
|
370
|
+
id: observation.report.id,
|
|
371
|
+
revision: observation.report.revision,
|
|
372
|
+
path: observation.report.path,
|
|
373
|
+
chunkId: child
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
data.push(chunk.data);
|
|
378
|
+
chunksRead++;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
|
|
381
|
+
if (isMissingRead(error)) {
|
|
382
|
+
const eden2 = getEdenChunk(observation.document, child);
|
|
383
|
+
if (eden2 !== void 0) {
|
|
384
|
+
data.push(eden2);
|
|
385
|
+
chunksRead++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
unavailableChunks.push({ id: child, status: "missing" });
|
|
389
|
+
appendError(run.errors, run.name, reasonFromReadError(error, "chunk-missing"), {
|
|
390
|
+
id: observation.report.id,
|
|
391
|
+
revision: observation.report.revision,
|
|
392
|
+
path: observation.report.path,
|
|
393
|
+
chunkId: child
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
unavailableChunks.push({ id: child, status: "unreadable" });
|
|
398
|
+
appendError(run.errors, run.name, reasonFromReadError(error, "chunk-unreadable"), {
|
|
399
|
+
id: observation.report.id,
|
|
400
|
+
revision: observation.report.revision,
|
|
401
|
+
path: observation.report.path,
|
|
402
|
+
chunkId: child
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (unavailableChunks.length > 0) {
|
|
407
|
+
const hasUnreadable = unavailableChunks.some((chunk) => chunk.status === "unreadable");
|
|
408
|
+
return {
|
|
409
|
+
content: {
|
|
410
|
+
status: hasUnreadable ? "unreadable" : "missing",
|
|
411
|
+
reason: hasUnreadable ? "chunk-unreadable" : "chunk-missing"
|
|
412
|
+
},
|
|
413
|
+
chunksRead,
|
|
414
|
+
unavailableChunks
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const loaded = {
|
|
419
|
+
...observation.document,
|
|
420
|
+
data,
|
|
421
|
+
datatype: observation.document.type
|
|
422
|
+
};
|
|
423
|
+
const blob = readAsBlob(loaded);
|
|
424
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
425
|
+
if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
|
|
426
|
+
return {
|
|
427
|
+
content: { status: "available", bytes },
|
|
428
|
+
chunksRead,
|
|
429
|
+
unavailableChunks
|
|
430
|
+
};
|
|
431
|
+
} catch {
|
|
432
|
+
if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
|
|
433
|
+
appendError(run.errors, run.name, "unsupported-content", {
|
|
434
|
+
id: observation.report.id,
|
|
435
|
+
revision: observation.report.revision,
|
|
436
|
+
path: observation.report.path
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
content: { status: "unreadable", reason: "unsupported-content" },
|
|
440
|
+
chunksRead,
|
|
441
|
+
unavailableChunks
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function relation(left, right) {
|
|
446
|
+
if (left.status === "not-checked" || right.status === "not-checked") return "not-checked";
|
|
447
|
+
if (left.status !== "available" || right.status !== "available") return "unavailable";
|
|
448
|
+
if (left.bytes.length !== right.bytes.length) return "different";
|
|
449
|
+
for (let index = 0; index < left.bytes.length; index++) {
|
|
450
|
+
if (left.bytes[index] !== right.bytes[index]) return "different";
|
|
451
|
+
}
|
|
452
|
+
return "equal";
|
|
453
|
+
}
|
|
454
|
+
async function readVaultContent(options, path, errors) {
|
|
455
|
+
if (cancelled(options)) return { status: "not-checked" };
|
|
456
|
+
try {
|
|
457
|
+
const content = await options.readStorage(path);
|
|
458
|
+
if (cancelled(options)) return { status: "not-checked" };
|
|
459
|
+
if (content.status === "unreadable" || content.reason) {
|
|
460
|
+
appendError(errors, "storage", content.reason ?? "content-unreadable", { path });
|
|
461
|
+
}
|
|
462
|
+
return content;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (cancelled(options)) return { status: "not-checked" };
|
|
465
|
+
const reason = error instanceof DiagnosticReadError ? error.reason : "content-unreadable";
|
|
466
|
+
appendError(errors, "storage", reason, { path });
|
|
467
|
+
return { status: "unreadable", reason };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function inspectGroupContent(options, group, runsByName, errors, contentProgress) {
|
|
471
|
+
if (!group.suspect) return;
|
|
472
|
+
const byRawPath = /* @__PURE__ */ new Map();
|
|
473
|
+
for (const observation of group.observations) {
|
|
474
|
+
if (!byRawPath.has(observation.report.path)) byRawPath.set(observation.report.path, observation);
|
|
475
|
+
}
|
|
476
|
+
const vaultReferences = /* @__PURE__ */ new Map();
|
|
477
|
+
for (const [path, representative] of byRawPath) {
|
|
478
|
+
vaultReferences.set(path, `vault:${representative.report.reference}`);
|
|
479
|
+
}
|
|
480
|
+
for (const observation of group.observations) {
|
|
481
|
+
observation.report.comparisons = [
|
|
482
|
+
...group.observations.filter((other) => other !== observation).map((other) => ({ against: other.report.reference, relation: "not-checked" })),
|
|
483
|
+
...[...vaultReferences.values()].map((against) => ({ against, relation: "not-checked" }))
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
const contentByObservation = /* @__PURE__ */ new Map();
|
|
487
|
+
const contentByReference = /* @__PURE__ */ new Map();
|
|
488
|
+
for (const observation of group.observations) {
|
|
489
|
+
if (cancelled(options)) return;
|
|
490
|
+
const run = runsByName.get(observation.report.source);
|
|
491
|
+
if (!run) continue;
|
|
492
|
+
const outcome = await readObservationContent(options, observation, run);
|
|
493
|
+
observation.report.content = {
|
|
494
|
+
status: outcome.content.status,
|
|
495
|
+
...outcome.content.reason ? { reason: outcome.content.reason } : {},
|
|
496
|
+
chunksRead: outcome.chunksRead,
|
|
497
|
+
unavailableChunks: outcome.unavailableChunks
|
|
498
|
+
};
|
|
499
|
+
contentByObservation.set(observation, outcome.content);
|
|
500
|
+
contentByReference.set(observation.report.reference, outcome.content);
|
|
501
|
+
contentProgress.processed++;
|
|
502
|
+
progress(options, "content", contentProgress.processed);
|
|
503
|
+
if (cancelled(options)) return;
|
|
504
|
+
}
|
|
505
|
+
const vaultByPath = /* @__PURE__ */ new Map();
|
|
506
|
+
for (const [path] of byRawPath) {
|
|
507
|
+
if (cancelled(options)) return;
|
|
508
|
+
const vaultContent = await readVaultContent(options, path, errors);
|
|
509
|
+
vaultByPath.set(path, vaultContent);
|
|
510
|
+
const vaultReference = vaultReferences.get(path);
|
|
511
|
+
if (vaultReference) contentByReference.set(vaultReference, vaultContent);
|
|
512
|
+
for (const observation of group.observations) {
|
|
513
|
+
if (observation.report.path !== path) continue;
|
|
514
|
+
observation.report.vault = {
|
|
515
|
+
reference: vaultReference ?? `vault:${observation.report.reference}`,
|
|
516
|
+
status: vaultContent.status,
|
|
517
|
+
...vaultContent.reason ? { reason: vaultContent.reason } : {}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (cancelled(options)) return;
|
|
521
|
+
}
|
|
522
|
+
for (const observation of group.observations) {
|
|
523
|
+
const ownContent = contentByObservation.get(observation) ?? { status: "not-checked" };
|
|
524
|
+
for (const comparison of observation.report.comparisons) {
|
|
525
|
+
const comparedContent = contentByReference.get(comparison.against) ?? { status: "not-checked" };
|
|
526
|
+
comparison.relation = relation(ownContent, comparedContent);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function determineStatus(wasCancelled, errors, runs, configurationChanged) {
|
|
531
|
+
if (wasCancelled) return "cancelled";
|
|
532
|
+
if (configurationChanged || errors.length > 0) {
|
|
533
|
+
const anyCompletedSource = runs.some((run) => run.metadataScanComplete);
|
|
534
|
+
const allUnavailable = runs.length > 0 && runs.every((run) => run.coverage.status === "failed");
|
|
535
|
+
return allUnavailable && !anyCompletedSource ? "failed" : "partial";
|
|
536
|
+
}
|
|
537
|
+
if (runs.length === 0 || runs.some((run) => run.coverage.status !== "complete")) return "partial";
|
|
538
|
+
return "complete";
|
|
539
|
+
}
|
|
540
|
+
async function analyseDatabaseIntegrity(options) {
|
|
541
|
+
const errors = [];
|
|
542
|
+
const runs = [];
|
|
543
|
+
const metadataProgress = { processed: 0 };
|
|
544
|
+
const contentProgress = { processed: 0 };
|
|
545
|
+
const referenceNumbers = { local: 0, remote: 0 };
|
|
546
|
+
const nextReference = (source) => `${source}:${++referenceNumbers[source]}`;
|
|
547
|
+
let wasCancelled = cancelled(options);
|
|
548
|
+
let configurationChanged = false;
|
|
549
|
+
let ownedRemote;
|
|
550
|
+
if (!wasCancelled && !isConfigurationCurrent(options)) {
|
|
551
|
+
configurationChanged = true;
|
|
552
|
+
appendError(errors, "configuration", "configuration-changed");
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
if (!wasCancelled && !configurationChanged) {
|
|
556
|
+
const localRun = makeSourceRun("local", options.local);
|
|
557
|
+
runs.push(localRun);
|
|
558
|
+
await scanSource(options, localRun, nextReference, metadataProgress);
|
|
559
|
+
if (!cancelled(options)) {
|
|
560
|
+
if (!options.remote) {
|
|
561
|
+
const remoteRun = { ...makeSourceRun("remote", options.local), source: options.local };
|
|
562
|
+
remoteRun.coverage.status = "failed";
|
|
563
|
+
remoteRun.endInfoAvailable = false;
|
|
564
|
+
appendError(errors, "remote", options.remoteUnavailableReason ?? "source-unavailable");
|
|
565
|
+
runs.push(remoteRun);
|
|
566
|
+
} else {
|
|
567
|
+
try {
|
|
568
|
+
ownedRemote = await options.remote();
|
|
569
|
+
if (cancelled(options)) {
|
|
570
|
+
wasCancelled = true;
|
|
571
|
+
} else {
|
|
572
|
+
const remoteRun = makeSourceRun("remote", ownedRemote);
|
|
573
|
+
runs.push(remoteRun);
|
|
574
|
+
await scanSource(options, remoteRun, nextReference, metadataProgress);
|
|
575
|
+
}
|
|
576
|
+
} catch {
|
|
577
|
+
if (cancelled(options)) {
|
|
578
|
+
wasCancelled = true;
|
|
579
|
+
} else {
|
|
580
|
+
const remoteRun = { ...makeSourceRun("remote", options.local), source: options.local };
|
|
581
|
+
remoteRun.coverage.status = "failed";
|
|
582
|
+
remoteRun.endInfoAvailable = false;
|
|
583
|
+
appendError(errors, "remote", options.remoteUnavailableReason ?? "source-unavailable");
|
|
584
|
+
runs.push(remoteRun);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (cancelled(options)) wasCancelled = true;
|
|
590
|
+
if (!wasCancelled) {
|
|
591
|
+
const groups = groupObservations(runs);
|
|
592
|
+
markPathEquivalences(runs.flatMap((run) => run.observations));
|
|
593
|
+
markSuspectGroups(groups, runs);
|
|
594
|
+
const runsByName = new Map(runs.map((run) => [run.name, run]));
|
|
595
|
+
for (const group of groups) {
|
|
596
|
+
if (cancelled(options)) {
|
|
597
|
+
wasCancelled = true;
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
await inspectGroupContent(options, group, runsByName, errors, contentProgress);
|
|
601
|
+
if (cancelled(options)) {
|
|
602
|
+
wasCancelled = true;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (!wasCancelled && !cancelled(options)) {
|
|
609
|
+
for (const run of runs) {
|
|
610
|
+
if (!run.endInfoAvailable) continue;
|
|
611
|
+
if (cancelled(options)) {
|
|
612
|
+
wasCancelled = true;
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const info = await run.source.info();
|
|
617
|
+
run.endSeq = info.update_seq;
|
|
618
|
+
if (!hasUpdateSequence(run.endSeq)) {
|
|
619
|
+
appendError(run.errors, run.name, "source-unavailable");
|
|
620
|
+
} else if (hasUpdateSequence(run.startSeq) && run.startSeq !== run.endSeq) {
|
|
621
|
+
run.coverage.changedDuringRun = true;
|
|
622
|
+
appendError(run.errors, run.name, "source-changed");
|
|
623
|
+
}
|
|
624
|
+
} catch {
|
|
625
|
+
appendError(run.errors, run.name, "source-unavailable");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} finally {
|
|
630
|
+
if (ownedRemote) {
|
|
631
|
+
try {
|
|
632
|
+
await ownedRemote.close();
|
|
633
|
+
} catch {
|
|
634
|
+
appendError(errors, "remote", "source-unavailable");
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (cancelled(options)) wasCancelled = true;
|
|
639
|
+
if (!wasCancelled && !configurationChanged && !isConfigurationCurrent(options)) {
|
|
640
|
+
configurationChanged = true;
|
|
641
|
+
appendError(errors, "configuration", "configuration-changed");
|
|
642
|
+
}
|
|
643
|
+
for (const run of runs) {
|
|
644
|
+
if (run.coverage.status === "failed") continue;
|
|
645
|
+
const runHasErrors = run.errors.length > 0 || errors.some((error) => error.source === run.name);
|
|
646
|
+
run.coverage.status = wasCancelled ? run.coverage.documents > 0 || run.coverage.metadata > 0 ? "partial" : "not-checked" : runHasErrors ? run.coverage.metadata > 0 || run.enumerationComplete ? "partial" : "failed" : run.metadataScanComplete ? "complete" : "partial";
|
|
647
|
+
errors.push(...run.errors);
|
|
648
|
+
}
|
|
649
|
+
const coverage = {
|
|
650
|
+
local: { ...INITIAL_COVERAGE },
|
|
651
|
+
remote: { ...INITIAL_COVERAGE }
|
|
652
|
+
};
|
|
653
|
+
for (const run of runs) coverage[run.name] = { ...run.coverage };
|
|
654
|
+
const allObservations = runs.flatMap((run) => run.observations.map((observation) => observation.report));
|
|
655
|
+
const status = determineStatus(wasCancelled, errors, runs, configurationChanged);
|
|
656
|
+
return {
|
|
657
|
+
schemaVersion: 1,
|
|
658
|
+
kind: "integrity",
|
|
659
|
+
status,
|
|
660
|
+
settings: { ...options.settings },
|
|
661
|
+
coverage,
|
|
662
|
+
observations: allObservations,
|
|
663
|
+
errors,
|
|
664
|
+
scope: {
|
|
665
|
+
metadata: "normal-files-and-current-conflicts",
|
|
666
|
+
content: "suspect-groups-only",
|
|
667
|
+
history: "not-checked",
|
|
668
|
+
atomicSnapshot: false
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
export {
|
|
673
|
+
analyseDatabaseIntegrity
|
|
674
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { EntryDoc } from "../common/types.js";
|
|
2
|
+
import type { DiagnosticDatabaseSource } from "./types.js";
|
|
3
|
+
/** Wrap an existing local handle without taking ownership or using chunk caches. */
|
|
4
|
+
export declare function createLocalDiagnosticSource(database: Pick<PouchDB.Database<EntryDoc>, "info" | "allDocs" | "get">): DiagnosticDatabaseSource;
|