@vrtmrz/livesync-commonlib 0.1.42 → 0.1.44

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.
@@ -0,0 +1,901 @@
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 isFiniteNumber(value) {
36
+ return typeof value === "number" && Number.isFinite(value);
37
+ }
38
+ function isRecordedSize(value) {
39
+ return isFiniteNumber(value) && value >= 0;
40
+ }
41
+ function isCurrentMetadata(value) {
42
+ return isRecord(value) && (value.type === EntryTypes.NOTE_BINARY || value.type === EntryTypes.NOTE_PLAIN);
43
+ }
44
+ function isValidCurrentMetadata(value) {
45
+ return typeof value._id === "string" && typeof value._rev === "string" && typeof value.path === "string" && Array.isArray(value.children) && value.children.every((child) => typeof child === "string");
46
+ }
47
+ function isMissingRead(error) {
48
+ if (error instanceof DiagnosticReadError) return error.missing;
49
+ if (!isRecord(error)) return false;
50
+ return error.status === 404 || error.name === "not_found" || error.error === "not_found";
51
+ }
52
+ function reasonFromReadError(error, fallback) {
53
+ return error instanceof DiagnosticReadError ? error.reason : fallback;
54
+ }
55
+ function hasUpdateSequence(value) {
56
+ return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
57
+ }
58
+ function appendError(errors, source, reason, details = {}) {
59
+ errors.push({ source, reason, ...details });
60
+ }
61
+ function concurrencyLimit(options) {
62
+ return Number.isFinite(options.concurrency) ? Math.max(1, Math.min(16, Math.floor(options.concurrency))) : 4;
63
+ }
64
+ class InspectionPhase {
65
+ constructor(options, phase, source) {
66
+ this.options = options;
67
+ this.phase = phase;
68
+ this.source = source;
69
+ this.log("started");
70
+ }
71
+ options;
72
+ phase;
73
+ source;
74
+ processed = 0;
75
+ total;
76
+ started = performance.now();
77
+ lastLog = this.started;
78
+ activeReads = 0;
79
+ peakActiveReads = 0;
80
+ readOperations = 0;
81
+ readDurationMs = 0;
82
+ async read(read, documents = 1) {
83
+ const started = performance.now();
84
+ this.readOperations += documents;
85
+ this.peakActiveReads = Math.max(this.peakActiveReads, ++this.activeReads);
86
+ try {
87
+ return await read();
88
+ } finally {
89
+ this.activeReads--;
90
+ this.readDurationMs += performance.now() - started;
91
+ }
92
+ }
93
+ update() {
94
+ if (this.total !== void 0) this.total = Math.max(this.total, this.processed);
95
+ if (this.phase !== "grouping") {
96
+ try {
97
+ this.options.onProgress?.({
98
+ phase: this.phase,
99
+ source: this.source,
100
+ processed: this.processed,
101
+ total: this.total,
102
+ elapsedMs: Math.round(performance.now() - this.started)
103
+ });
104
+ } catch {
105
+ }
106
+ }
107
+ if (performance.now() - this.lastLog >= 5e3) this.log("progress");
108
+ }
109
+ log(event) {
110
+ this.lastLog = performance.now();
111
+ try {
112
+ this.options.onLog?.({
113
+ phase: this.phase,
114
+ source: this.source,
115
+ event,
116
+ processed: this.processed,
117
+ total: this.total,
118
+ elapsedMs: Math.round(this.lastLog - this.started),
119
+ concurrency: concurrencyLimit(this.options),
120
+ activeReads: this.activeReads,
121
+ peakActiveReads: this.peakActiveReads,
122
+ readOperations: this.readOperations,
123
+ readDurationMs: Math.round(this.readDurationMs)
124
+ });
125
+ } catch {
126
+ }
127
+ }
128
+ }
129
+ async function inspectBatches(items, options, inspect, merge) {
130
+ const pending = [];
131
+ const drain = async () => {
132
+ const results = await Promise.allSettled(pending.splice(0));
133
+ let failure;
134
+ for (const result of results) {
135
+ if (result.status === "fulfilled") merge(result.value);
136
+ else failure ??= result;
137
+ }
138
+ if (failure) throw failure.reason;
139
+ };
140
+ try {
141
+ for await (const item of items) {
142
+ if (cancelled(options)) return false;
143
+ const task = inspect(item);
144
+ void task.catch(() => {
145
+ });
146
+ pending.push(task);
147
+ if (pending.length >= concurrencyLimit(options)) await drain();
148
+ if (cancelled(options)) return false;
149
+ }
150
+ return !cancelled(options);
151
+ } finally {
152
+ await drain();
153
+ }
154
+ }
155
+ const DOCUMENT_BATCH_SIZE = 64;
156
+ async function* documentBatches(ids, options) {
157
+ let pending = [];
158
+ let failure;
159
+ try {
160
+ for await (const id of ids) {
161
+ if (cancelled(options)) return;
162
+ pending.push(id);
163
+ if (pending.length === DOCUMENT_BATCH_SIZE) {
164
+ yield pending;
165
+ pending = [];
166
+ }
167
+ }
168
+ } catch (error) {
169
+ failure = { error };
170
+ }
171
+ if (pending.length && !cancelled(options)) yield pending;
172
+ if (failure) throw failure.error;
173
+ }
174
+ async function readMany(source, ids, phase, options) {
175
+ if (!ids.length) return /* @__PURE__ */ new Map();
176
+ try {
177
+ const results = await phase.read(() => source.getMany(ids, options), ids.length);
178
+ const byId = new Map(results.map((result) => [result.id, result]));
179
+ return new Map(
180
+ ids.map((id) => [id, byId.get(id) ?? { id, error: new DiagnosticReadError("document-unreadable") }])
181
+ );
182
+ } catch (error) {
183
+ return new Map(ids.map((id) => [id, { id, error }]));
184
+ }
185
+ }
186
+ function readResult(result) {
187
+ if ("error" in result) throw result.error;
188
+ return result.document;
189
+ }
190
+ function mergeSourceWork(target, work) {
191
+ target.errors.push(...work.errors);
192
+ target.observations.push(...work.observations);
193
+ for (const key of ["documents", "metadata", "excluded", "conflictBranches", "contentReads"]) {
194
+ target.coverage[key] += work.coverage[key];
195
+ }
196
+ }
197
+ function cancelled(options) {
198
+ return options.signal?.aborted === true;
199
+ }
200
+ function isConfigurationCurrent(options) {
201
+ try {
202
+ return options.isConfigurationCurrent();
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+ function isSkippedDocumentId(id) {
208
+ return isChunk(id) || id.startsWith("_local/") || id.startsWith("_design/") || EXCLUDED_IDS.has(id);
209
+ }
210
+ function makeSourceRun(name, source) {
211
+ return {
212
+ name,
213
+ source,
214
+ coverage: { ...INITIAL_COVERAGE, status: "partial" },
215
+ errors: [],
216
+ observations: [],
217
+ metadataScanComplete: false,
218
+ enumerationComplete: false,
219
+ endInfoAvailable: true
220
+ };
221
+ }
222
+ async function classifyObservation(options, run, document, branch, conflictCount, reference) {
223
+ if (!isValidCurrentMetadata(document)) {
224
+ appendError(run.errors, run.name, "invalid-metadata", {
225
+ id: typeof document._id === "string" ? document._id : void 0,
226
+ revision: typeof document._rev === "string" ? document._rev : void 0
227
+ });
228
+ return void 0;
229
+ }
230
+ try {
231
+ const identity = await inspectMetadataDocumentIdentity({ services: { path: options.path } }, document);
232
+ if (identity.status === MetadataDocumentIdentityStatuses.EXCLUDED) {
233
+ run.coverage.excluded++;
234
+ return void 0;
235
+ }
236
+ const rawPath = document.path;
237
+ const recordedSize = isRecordedSize(document.size) ? document.size : void 0;
238
+ const mismatch = identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED ? identity.diagnostic.reason : void 0;
239
+ const report = {
240
+ reference,
241
+ source: run.name,
242
+ id: document._id,
243
+ ...typeof document._rev === "string" ? { revision: document._rev } : {},
244
+ path: rawPath,
245
+ ...identity.status === MetadataDocumentIdentityStatuses.CONSISTENT ? { expectedId: identity.expectedDocumentId } : identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED && identity.diagnostic.expectedDocumentId ? { expectedId: identity.diagnostic.expectedDocumentId } : {},
246
+ identity: identity.status === MetadataDocumentIdentityStatuses.CONSISTENT ? "consistent" : mismatch === "namespace-mismatch" ? "namespace-mismatch" : "document-id-mismatch",
247
+ logicallyDeleted: Boolean(document.deleted || document._deleted),
248
+ branch,
249
+ conflictCount,
250
+ pathIsNFC: rawPath === rawPath.normalize("NFC"),
251
+ caseEquivalentPaths: false,
252
+ nfcEquivalentPaths: false,
253
+ metadata: {
254
+ type: document.type,
255
+ ...recordedSize !== void 0 ? { recordedSize } : {},
256
+ chunkReferences: document.children.length,
257
+ uniqueChunkReferences: new Set(document.children).size,
258
+ embeddedChunkReferences: document.children.reduce(
259
+ (count, child) => count + (getEdenChunk(document, child) !== void 0 ? 1 : 0),
260
+ 0
261
+ )
262
+ },
263
+ content: {
264
+ status: "not-checked",
265
+ chunksRead: 0,
266
+ unavailableChunks: []
267
+ },
268
+ otherSourceGroup: "not-checked",
269
+ vault: { reference: `vault:${reference}`, status: "not-checked" },
270
+ comparisons: []
271
+ };
272
+ const observation = { report, source: run.source, document };
273
+ run.observations.push(observation);
274
+ if (branch === "winner") run.coverage.metadata++;
275
+ else run.coverage.conflictBranches++;
276
+ return observation;
277
+ } catch {
278
+ appendError(run.errors, run.name, "invalid-metadata", {
279
+ id: document._id,
280
+ revision: document._rev,
281
+ path: document.path
282
+ });
283
+ return void 0;
284
+ }
285
+ }
286
+ async function inspectMetadataDocument(options, run, id, phase, prefetched) {
287
+ if (cancelled(options)) return;
288
+ run.coverage.documents++;
289
+ if (isSkippedDocumentId(id)) {
290
+ run.coverage.excluded++;
291
+ return;
292
+ }
293
+ let winnerValue;
294
+ try {
295
+ winnerValue = prefetched === void 0 ? await phase.read(() => run.source.get(id, { conflicts: true })) : readResult(prefetched);
296
+ } catch (error) {
297
+ if (cancelled(options)) return;
298
+ appendError(run.errors, run.name, reasonFromReadError(error, "document-unreadable"), { id });
299
+ return;
300
+ }
301
+ if (cancelled(options)) return;
302
+ if (!isRecord(winnerValue) || winnerValue._id !== id) {
303
+ appendError(run.errors, run.name, "invalid-metadata", { id });
304
+ return;
305
+ }
306
+ if (!isCurrentMetadata(winnerValue)) {
307
+ run.coverage.excluded++;
308
+ return;
309
+ }
310
+ const winner = winnerValue;
311
+ if (!isValidCurrentMetadata(winner)) {
312
+ appendError(run.errors, run.name, "invalid-metadata", {
313
+ id,
314
+ revision: typeof winner._rev === "string" ? winner._rev : void 0
315
+ });
316
+ return;
317
+ }
318
+ const conflictRevisions = Array.isArray(winner._conflicts) ? winner._conflicts.filter((revision) => typeof revision === "string") : [];
319
+ await classifyObservation(options, run, winner, "winner", conflictRevisions.length, "pending");
320
+ if (cancelled(options)) return;
321
+ for (const revision of conflictRevisions) {
322
+ if (cancelled(options)) return;
323
+ let branchValue;
324
+ try {
325
+ branchValue = await phase.read(() => run.source.get(id, { rev: revision, conflicts: true }));
326
+ } catch (error) {
327
+ if (cancelled(options)) return;
328
+ appendError(run.errors, run.name, reasonFromReadError(error, "revision-unavailable"), {
329
+ id,
330
+ revision
331
+ });
332
+ continue;
333
+ }
334
+ if (cancelled(options)) return;
335
+ if (!isRecord(branchValue) || branchValue._id !== id || branchValue._rev !== revision) {
336
+ appendError(run.errors, run.name, "revision-unavailable", { id, revision });
337
+ continue;
338
+ }
339
+ if (!isCurrentMetadata(branchValue)) {
340
+ run.coverage.excluded++;
341
+ continue;
342
+ }
343
+ await classifyObservation(options, run, branchValue, "conflict", conflictRevisions.length, "pending");
344
+ }
345
+ }
346
+ async function scanSource(options, run, nextReference) {
347
+ if (cancelled(options)) return;
348
+ const phase = new InspectionPhase(options, "metadata", run.name);
349
+ try {
350
+ const info = await run.source.info();
351
+ run.startSeq = info.update_seq;
352
+ if (Number.isSafeInteger(info.doc_count) && info.doc_count >= 0) phase.total = info.doc_count;
353
+ if (!hasUpdateSequence(run.startSeq)) appendError(run.errors, run.name, "source-unavailable");
354
+ } catch {
355
+ appendError(run.errors, run.name, "source-unavailable");
356
+ }
357
+ phase.update();
358
+ let enumerationComplete = false;
359
+ const merge = (work) => {
360
+ for (const observation of work.observations) {
361
+ observation.report.reference = nextReference(run.name);
362
+ observation.report.vault.reference = `vault:${observation.report.reference}`;
363
+ }
364
+ mergeSourceWork(run, work);
365
+ };
366
+ const inspect = async (id, prefetched) => {
367
+ const work = makeSourceRun(run.name, run.source);
368
+ if (typeof id !== "string") appendError(work.errors, run.name, "source-unavailable");
369
+ else await inspectMetadataDocument(options, work, id, phase, prefetched);
370
+ if (!cancelled(options)) {
371
+ phase.processed++;
372
+ phase.update();
373
+ }
374
+ return work;
375
+ };
376
+ try {
377
+ if (!cancelled(options)) {
378
+ if (run.source.getMany) {
379
+ for await (const ids of documentBatches(run.source.enumerateIds(), options)) {
380
+ if (cancelled(options)) break;
381
+ const targets = ids.filter((id) => typeof id === "string" && !isSkippedDocumentId(id));
382
+ const prefetched = await readMany(run.source, targets, phase, { conflicts: true });
383
+ if (cancelled(options)) break;
384
+ await inspectBatches(ids, options, (id) => inspect(id, prefetched.get(id)), merge);
385
+ }
386
+ enumerationComplete = !cancelled(options);
387
+ } else {
388
+ enumerationComplete = await inspectBatches(run.source.enumerateIds(), options, inspect, merge);
389
+ }
390
+ }
391
+ } catch {
392
+ if (!cancelled(options)) appendError(run.errors, run.name, "source-unavailable");
393
+ }
394
+ if (!cancelled(options)) {
395
+ run.metadataScanComplete = enumerationComplete && run.errors.length === 0;
396
+ run.enumerationComplete = enumerationComplete;
397
+ if (enumerationComplete) phase.total = phase.processed;
398
+ }
399
+ phase.update();
400
+ phase.log(cancelled(options) ? "cancelled" : run.metadataScanComplete ? "completed" : "partial");
401
+ }
402
+ function groupObservations(runs) {
403
+ const observations = runs.flatMap((run) => run.observations);
404
+ const parents = observations.map((_, index) => index);
405
+ const find = (index) => {
406
+ if (parents[index] !== index) parents[index] = find(parents[index]);
407
+ return parents[index];
408
+ };
409
+ const union = (left, right) => {
410
+ const leftRoot = find(left);
411
+ const rightRoot = find(right);
412
+ if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
413
+ };
414
+ const byExpectedId = /* @__PURE__ */ new Map();
415
+ const byRawPath = /* @__PURE__ */ new Map();
416
+ observations.forEach((observation, index) => {
417
+ const { expectedId, path } = observation.report;
418
+ if (expectedId !== void 0) {
419
+ const previous = byExpectedId.get(expectedId);
420
+ if (previous === void 0) byExpectedId.set(expectedId, index);
421
+ else union(index, previous);
422
+ }
423
+ const previousPath = byRawPath.get(path);
424
+ if (previousPath === void 0) byRawPath.set(path, index);
425
+ else union(index, previousPath);
426
+ });
427
+ const groupsByRoot = /* @__PURE__ */ new Map();
428
+ observations.forEach((observation, index) => {
429
+ const root = find(index);
430
+ const group = groupsByRoot.get(root) ?? { key: `group:${root}`, observations: [], suspect: false };
431
+ group.observations.push(observation);
432
+ groupsByRoot.set(root, group);
433
+ });
434
+ return [...groupsByRoot.values()];
435
+ }
436
+ function markPathEquivalences(observations) {
437
+ const pathsByCaseFold = /* @__PURE__ */ new Map();
438
+ const pathsByNfc = /* @__PURE__ */ new Map();
439
+ for (const observation of observations) {
440
+ const path = observation.report.path;
441
+ const caseFold = path.toLowerCase();
442
+ const casePaths = pathsByCaseFold.get(caseFold) ?? [];
443
+ casePaths.push(observation);
444
+ pathsByCaseFold.set(caseFold, casePaths);
445
+ const nfc = path.normalize("NFC");
446
+ const nfcPaths = pathsByNfc.get(nfc) ?? [];
447
+ nfcPaths.push(observation);
448
+ pathsByNfc.set(nfc, nfcPaths);
449
+ }
450
+ const equivalenceGroups = [];
451
+ for (const [kind, groups] of [
452
+ ["case", pathsByCaseFold],
453
+ ["nfc", pathsByNfc]
454
+ ]) {
455
+ let groupNumber = 0;
456
+ for (const members of groups.values()) {
457
+ const hasDifferentSpellings = new Set(members.map((observation) => observation.report.path)).size > 1;
458
+ if (kind === "case") {
459
+ for (const observation of members) observation.report.caseEquivalentPaths = hasDifferentSpellings;
460
+ } else {
461
+ for (const observation of members) observation.report.nfcEquivalentPaths = hasDifferentSpellings;
462
+ }
463
+ if (!hasDifferentSpellings) continue;
464
+ const reference = `${kind}:${++groupNumber}`;
465
+ if (kind === "case") {
466
+ for (const observation of members) observation.report.caseEquivalentGroup = reference;
467
+ } else {
468
+ for (const observation of members) observation.report.nfcEquivalentGroup = reference;
469
+ }
470
+ equivalenceGroups.push({
471
+ reference,
472
+ kind,
473
+ members: members.map((observation) => observation.report.reference)
474
+ });
475
+ }
476
+ }
477
+ return equivalenceGroups;
478
+ }
479
+ function markSuspectGroups(groups, runs) {
480
+ const crossSourceComparisonAvailable = runs.some((run) => run.name === "local" && run.metadataScanComplete) && runs.some((run) => run.name === "remote" && run.metadataScanComplete);
481
+ for (const group of groups) {
482
+ const observations = group.observations;
483
+ group.suspect = observations.some((observation) => observation.report.identity !== "consistent") || observations.some((observation) => observation.report.conflictCount > 0);
484
+ const claimsBySourceAndPath = /* @__PURE__ */ new Map();
485
+ for (const observation of observations) {
486
+ const key = `${observation.report.source}\0${observation.report.path}`;
487
+ const ids = claimsBySourceAndPath.get(key) ?? /* @__PURE__ */ new Set();
488
+ ids.add(observation.report.id);
489
+ claimsBySourceAndPath.set(key, ids);
490
+ }
491
+ if ([...claimsBySourceAndPath.values()].some((ids) => ids.size > 1)) group.suspect = true;
492
+ for (const observation of observations) {
493
+ const otherSource = observation.report.source === "local" ? "remote" : "local";
494
+ const otherRun = runs.find((run) => run.name === otherSource);
495
+ observation.report.otherSourceGroup = observations.some((other) => other.report.source === otherSource) ? "present" : otherRun?.metadataScanComplete ? "missing" : "not-checked";
496
+ }
497
+ if (!crossSourceComparisonAvailable) continue;
498
+ const localWinners = observations.filter(
499
+ (observation) => observation.report.source === "local" && observation.report.branch === "winner"
500
+ );
501
+ const remoteWinners = observations.filter(
502
+ (observation) => observation.report.source === "remote" && observation.report.branch === "winner"
503
+ );
504
+ if (localWinners.length === 0 || remoteWinners.length === 0) {
505
+ group.suspect = true;
506
+ continue;
507
+ }
508
+ const state = (observation) => `${observation.report.revision ?? ""}\0${observation.report.logicallyDeleted}`;
509
+ if (localWinners.some((local) => !remoteWinners.some((remote) => state(local) === state(remote))) || remoteWinners.some((remote) => !localWinners.some((local) => state(remote) === state(local)))) {
510
+ group.suspect = true;
511
+ }
512
+ }
513
+ }
514
+ function isValidChunk(value, id) {
515
+ return isRecord(value) && value._id === id && value.type === EntryTypes.CHUNK && typeof value.data === "string";
516
+ }
517
+ function getEdenChunk(document, id) {
518
+ const eden = document.eden;
519
+ if (!isRecord(eden)) return void 0;
520
+ const chunk = eden[id];
521
+ if (!isRecord(chunk) || typeof chunk.data !== "string") return void 0;
522
+ return chunk.data;
523
+ }
524
+ async function readObservationContent(options, observation, run, phase) {
525
+ run.coverage.contentReads++;
526
+ const unavailableChunks = [];
527
+ const data = [];
528
+ let chunksRead = 0;
529
+ if (observation.document.type !== EntryTypes.NOTE_BINARY && observation.document.type !== EntryTypes.NOTE_PLAIN) {
530
+ appendError(run.errors, run.name, "unsupported-content", {
531
+ id: observation.report.id,
532
+ revision: observation.report.revision,
533
+ path: observation.report.path
534
+ });
535
+ return {
536
+ content: { status: "unreadable", reason: "unsupported-content" },
537
+ chunksRead,
538
+ unavailableChunks
539
+ };
540
+ }
541
+ for (let offset = 0; offset < observation.document.children.length; offset += DOCUMENT_BATCH_SIZE) {
542
+ if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
543
+ const children = observation.document.children.slice(offset, offset + DOCUMENT_BATCH_SIZE);
544
+ const prefetched = observation.source.getMany ? await readMany(
545
+ observation.source,
546
+ [...new Set(children.filter((child) => getEdenChunk(observation.document, child) === void 0))],
547
+ phase
548
+ ) : void 0;
549
+ for (const child of children) {
550
+ if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
551
+ const eden = getEdenChunk(observation.document, child);
552
+ if (eden !== void 0) {
553
+ data.push(eden);
554
+ chunksRead++;
555
+ continue;
556
+ }
557
+ try {
558
+ const chunk = prefetched ? readResult(prefetched.get(child)) : await phase.read(() => observation.source.get(child));
559
+ if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
560
+ if (!isValidChunk(chunk, child) || chunk._deleted === true) {
561
+ unavailableChunks.push({ id: child, status: "unreadable" });
562
+ appendError(run.errors, run.name, "chunk-unreadable", {
563
+ id: observation.report.id,
564
+ revision: observation.report.revision,
565
+ path: observation.report.path,
566
+ chunkId: child
567
+ });
568
+ continue;
569
+ }
570
+ data.push(chunk.data);
571
+ chunksRead++;
572
+ } catch (error) {
573
+ if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
574
+ if (isMissingRead(error)) {
575
+ const eden2 = getEdenChunk(observation.document, child);
576
+ if (eden2 !== void 0) {
577
+ data.push(eden2);
578
+ chunksRead++;
579
+ continue;
580
+ }
581
+ unavailableChunks.push({ id: child, status: "missing" });
582
+ appendError(run.errors, run.name, reasonFromReadError(error, "chunk-missing"), {
583
+ id: observation.report.id,
584
+ revision: observation.report.revision,
585
+ path: observation.report.path,
586
+ chunkId: child
587
+ });
588
+ continue;
589
+ }
590
+ unavailableChunks.push({ id: child, status: "unreadable" });
591
+ appendError(run.errors, run.name, reasonFromReadError(error, "chunk-unreadable"), {
592
+ id: observation.report.id,
593
+ revision: observation.report.revision,
594
+ path: observation.report.path,
595
+ chunkId: child
596
+ });
597
+ }
598
+ }
599
+ }
600
+ if (unavailableChunks.length > 0) {
601
+ const hasUnreadable = unavailableChunks.some((chunk) => chunk.status === "unreadable");
602
+ return {
603
+ content: {
604
+ status: hasUnreadable ? "unreadable" : "missing",
605
+ reason: hasUnreadable ? "chunk-unreadable" : "chunk-missing"
606
+ },
607
+ chunksRead,
608
+ unavailableChunks
609
+ };
610
+ }
611
+ try {
612
+ const loaded = {
613
+ ...observation.document,
614
+ data,
615
+ datatype: observation.document.type
616
+ };
617
+ const blob = readAsBlob(loaded);
618
+ const bytes = new Uint8Array(await blob.arrayBuffer());
619
+ if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
620
+ return {
621
+ content: { status: "available", bytes },
622
+ chunksRead,
623
+ unavailableChunks
624
+ };
625
+ } catch {
626
+ if (cancelled(options)) return { content: { status: "not-checked" }, chunksRead, unavailableChunks };
627
+ appendError(run.errors, run.name, "unsupported-content", {
628
+ id: observation.report.id,
629
+ revision: observation.report.revision,
630
+ path: observation.report.path
631
+ });
632
+ return {
633
+ content: { status: "unreadable", reason: "unsupported-content" },
634
+ chunksRead,
635
+ unavailableChunks
636
+ };
637
+ }
638
+ }
639
+ function relation(left, right) {
640
+ if (left.status === "not-checked" || right.status === "not-checked") return "not-checked";
641
+ if (left.status !== "available" || right.status !== "available") return "unavailable";
642
+ if (left.bytes.length !== right.bytes.length) return "different";
643
+ for (let index = 0; index < left.bytes.length; index++) {
644
+ if (left.bytes[index] !== right.bytes[index]) return "different";
645
+ }
646
+ return "equal";
647
+ }
648
+ async function readVaultContent(options, path, errors, phase) {
649
+ if (cancelled(options)) return { status: "not-checked" };
650
+ try {
651
+ const content = await phase.read(() => options.readStorage(path));
652
+ if (cancelled(options)) return { status: "not-checked" };
653
+ if (content.status === "unreadable" || content.reason) {
654
+ appendError(errors, "storage", content.reason ?? "content-unreadable", { path });
655
+ }
656
+ return content;
657
+ } catch (error) {
658
+ if (cancelled(options)) return { status: "not-checked" };
659
+ const reason = error instanceof DiagnosticReadError ? error.reason : "content-unreadable";
660
+ appendError(errors, "storage", reason, { path });
661
+ return { status: "unreadable", reason };
662
+ }
663
+ }
664
+ async function inspectGroupContent(options, group, runsByName, errors, phase) {
665
+ if (!group.suspect) return;
666
+ const byRawPath = /* @__PURE__ */ new Map();
667
+ for (const observation of group.observations) {
668
+ if (!byRawPath.has(observation.report.path)) byRawPath.set(observation.report.path, observation);
669
+ }
670
+ const vaultReferences = /* @__PURE__ */ new Map();
671
+ for (const [path, representative] of byRawPath) {
672
+ vaultReferences.set(path, `vault:${representative.report.reference}`);
673
+ }
674
+ for (const observation of group.observations) {
675
+ observation.report.comparisons = [
676
+ ...group.observations.filter((other) => other !== observation).map((other) => ({ against: other.report.reference, relation: "not-checked" })),
677
+ ...[...vaultReferences.values()].map((against) => ({ against, relation: "not-checked" }))
678
+ ];
679
+ }
680
+ const contentByObservation = /* @__PURE__ */ new Map();
681
+ const contentByReference = /* @__PURE__ */ new Map();
682
+ for (const observation of group.observations) {
683
+ if (cancelled(options)) return;
684
+ const run = runsByName.get(observation.report.source);
685
+ if (!run) continue;
686
+ const outcome = await readObservationContent(options, observation, run, phase);
687
+ const decodedSize = outcome.content.status === "available" ? outcome.content.bytes.length : void 0;
688
+ const recordedSize = observation.report.metadata?.recordedSize;
689
+ observation.report.content = {
690
+ status: outcome.content.status,
691
+ ...outcome.content.reason ? { reason: outcome.content.reason } : {},
692
+ chunksRead: outcome.chunksRead,
693
+ unavailableChunks: outcome.unavailableChunks,
694
+ ...decodedSize !== void 0 ? {
695
+ size: decodedSize,
696
+ ...recordedSize !== void 0 ? { recordedSizeDifference: decodedSize - recordedSize } : {}
697
+ } : {}
698
+ };
699
+ contentByObservation.set(observation, outcome.content);
700
+ contentByReference.set(observation.report.reference, outcome.content);
701
+ if (cancelled(options)) return;
702
+ }
703
+ const vaultByPath = /* @__PURE__ */ new Map();
704
+ for (const [path] of byRawPath) {
705
+ if (cancelled(options)) return;
706
+ const vaultContent = await readVaultContent(options, path, errors, phase);
707
+ vaultByPath.set(path, vaultContent);
708
+ const vaultReference = vaultReferences.get(path);
709
+ if (vaultReference) contentByReference.set(vaultReference, vaultContent);
710
+ for (const observation of group.observations) {
711
+ if (observation.report.path !== path) continue;
712
+ const observationContent = contentByObservation.get(observation);
713
+ observation.report.vault = {
714
+ reference: vaultReference ?? `vault:${observation.report.reference}`,
715
+ status: vaultContent.status,
716
+ ...vaultContent.reason ? { reason: vaultContent.reason } : {},
717
+ ...vaultContent.status === "available" ? {
718
+ size: vaultContent.bytes.length,
719
+ ...observationContent?.status === "available" ? { contentSizeDifference: vaultContent.bytes.length - observationContent.bytes.length } : {},
720
+ ...isFiniteNumber(vaultContent.mtime) && isFiniteNumber(observation.document.mtime) ? { mtimeDifferenceMs: vaultContent.mtime - observation.document.mtime } : {}
721
+ } : {}
722
+ };
723
+ }
724
+ if (cancelled(options)) return;
725
+ }
726
+ for (const observation of group.observations) {
727
+ const ownContent = contentByObservation.get(observation) ?? { status: "not-checked" };
728
+ for (const comparison of observation.report.comparisons) {
729
+ const comparedContent = contentByReference.get(comparison.against) ?? { status: "not-checked" };
730
+ comparison.relation = relation(ownContent, comparedContent);
731
+ }
732
+ }
733
+ }
734
+ function determineStatus(wasCancelled, errors, runs, configurationChanged) {
735
+ if (wasCancelled) return "cancelled";
736
+ if (configurationChanged || errors.length > 0) {
737
+ const anyCompletedSource = runs.some((run) => run.metadataScanComplete);
738
+ const allUnavailable = runs.length > 0 && runs.every((run) => run.coverage.status === "failed");
739
+ return allUnavailable && !anyCompletedSource ? "failed" : "partial";
740
+ }
741
+ if (runs.length === 0 || runs.some((run) => run.coverage.status !== "complete")) return "partial";
742
+ return "complete";
743
+ }
744
+ async function analyseDatabaseIntegrity(options) {
745
+ const errors = [];
746
+ const runs = [];
747
+ const referenceNumbers = { local: 0, remote: 0 };
748
+ const nextReference = (source) => `${source}:${++referenceNumbers[source]}`;
749
+ let wasCancelled = cancelled(options);
750
+ let configurationChanged = false;
751
+ let pathEquivalenceGroups = [];
752
+ let ownedRemote;
753
+ if (!wasCancelled && !isConfigurationCurrent(options)) {
754
+ configurationChanged = true;
755
+ appendError(errors, "configuration", "configuration-changed");
756
+ }
757
+ try {
758
+ if (!wasCancelled && !configurationChanged) {
759
+ const localRun = makeSourceRun("local", options.local);
760
+ runs.push(localRun);
761
+ await scanSource(options, localRun, nextReference);
762
+ if (!cancelled(options) && options.includeRemote !== false) {
763
+ if (!options.remote) {
764
+ const remoteRun = { ...makeSourceRun("remote", options.local), source: options.local };
765
+ remoteRun.coverage.status = "failed";
766
+ remoteRun.endInfoAvailable = false;
767
+ appendError(errors, "remote", options.remoteUnavailableReason ?? "source-unavailable");
768
+ runs.push(remoteRun);
769
+ } else {
770
+ try {
771
+ ownedRemote = await options.remote();
772
+ if (cancelled(options)) {
773
+ wasCancelled = true;
774
+ } else {
775
+ const remoteRun = makeSourceRun("remote", ownedRemote);
776
+ runs.push(remoteRun);
777
+ await scanSource(options, remoteRun, nextReference);
778
+ }
779
+ } catch {
780
+ if (cancelled(options)) {
781
+ wasCancelled = true;
782
+ } else {
783
+ const remoteRun = { ...makeSourceRun("remote", options.local), source: options.local };
784
+ remoteRun.coverage.status = "failed";
785
+ remoteRun.endInfoAvailable = false;
786
+ appendError(errors, "remote", options.remoteUnavailableReason ?? "source-unavailable");
787
+ runs.push(remoteRun);
788
+ }
789
+ }
790
+ }
791
+ }
792
+ if (cancelled(options)) wasCancelled = true;
793
+ if (!wasCancelled) {
794
+ const grouping = new InspectionPhase(options, "grouping");
795
+ const groups = groupObservations(runs);
796
+ pathEquivalenceGroups = markPathEquivalences(runs.flatMap((run) => run.observations));
797
+ markSuspectGroups(groups, runs);
798
+ grouping.processed = groups.length;
799
+ grouping.total = groups.length;
800
+ grouping.log("completed");
801
+ const suspects = groups.filter((group) => group.suspect);
802
+ const content = new InspectionPhase(options, "content");
803
+ content.total = suspects.length;
804
+ content.update();
805
+ await inspectBatches(
806
+ suspects,
807
+ options,
808
+ async (group) => {
809
+ const work = runs.map((run) => makeSourceRun(run.name, run.source));
810
+ const groupErrors = [];
811
+ await inspectGroupContent(
812
+ options,
813
+ group,
814
+ new Map(work.map((run) => [run.name, run])),
815
+ groupErrors,
816
+ content
817
+ );
818
+ if (!cancelled(options)) {
819
+ content.processed++;
820
+ content.update();
821
+ }
822
+ return { work, groupErrors };
823
+ },
824
+ ({ work, groupErrors }) => {
825
+ for (let index = 0; index < runs.length; index++) mergeSourceWork(runs[index], work[index]);
826
+ errors.push(...groupErrors);
827
+ }
828
+ );
829
+ if (cancelled(options)) wasCancelled = true;
830
+ content.log(wasCancelled ? "cancelled" : "completed");
831
+ }
832
+ }
833
+ if (!wasCancelled && !cancelled(options)) {
834
+ for (const run of runs) {
835
+ if (!run.endInfoAvailable) continue;
836
+ if (cancelled(options)) {
837
+ wasCancelled = true;
838
+ break;
839
+ }
840
+ try {
841
+ const info = await run.source.info();
842
+ run.endSeq = info.update_seq;
843
+ if (!hasUpdateSequence(run.endSeq)) {
844
+ appendError(run.errors, run.name, "source-unavailable");
845
+ } else if (hasUpdateSequence(run.startSeq) && run.startSeq !== run.endSeq) {
846
+ run.coverage.changedDuringRun = true;
847
+ appendError(run.errors, run.name, "source-changed");
848
+ }
849
+ } catch {
850
+ appendError(run.errors, run.name, "source-unavailable");
851
+ }
852
+ }
853
+ }
854
+ } finally {
855
+ if (ownedRemote) {
856
+ try {
857
+ await ownedRemote.close();
858
+ } catch {
859
+ appendError(errors, "remote", "source-unavailable");
860
+ }
861
+ }
862
+ }
863
+ if (cancelled(options)) wasCancelled = true;
864
+ if (!wasCancelled && !configurationChanged && !isConfigurationCurrent(options)) {
865
+ configurationChanged = true;
866
+ appendError(errors, "configuration", "configuration-changed");
867
+ }
868
+ for (const run of runs) {
869
+ if (run.coverage.status === "failed") continue;
870
+ const runHasErrors = run.errors.length > 0 || errors.some((error) => error.source === run.name);
871
+ 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";
872
+ errors.push(...run.errors);
873
+ }
874
+ const coverage = {
875
+ local: { ...INITIAL_COVERAGE },
876
+ remote: { ...INITIAL_COVERAGE }
877
+ };
878
+ for (const run of runs) coverage[run.name] = { ...run.coverage };
879
+ const allObservations = runs.flatMap((run) => run.observations.map((observation) => observation.report));
880
+ const status = determineStatus(wasCancelled, errors, runs, configurationChanged);
881
+ return {
882
+ schemaVersion: 1,
883
+ kind: "integrity",
884
+ status,
885
+ settings: { ...options.settings },
886
+ coverage,
887
+ observations: allObservations,
888
+ ...pathEquivalenceGroups.length > 0 ? { pathEquivalenceGroups } : {},
889
+ errors,
890
+ scope: {
891
+ remote: options.includeRemote === false ? "excluded" : "included",
892
+ metadata: "normal-files-and-current-conflicts",
893
+ content: "suspect-groups-only",
894
+ history: "not-checked",
895
+ atomicSnapshot: false
896
+ }
897
+ };
898
+ }
899
+ export {
900
+ analyseDatabaseIntegrity
901
+ };