@vrtmrz/livesync-commonlib 0.1.43 → 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.
@@ -32,6 +32,12 @@ const INITIAL_COVERAGE = {
32
32
  function isRecord(value) {
33
33
  return value !== null && typeof value === "object";
34
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
+ }
35
41
  function isCurrentMetadata(value) {
36
42
  return isRecord(value) && (value.type === EntryTypes.NOTE_BINARY || value.type === EntryTypes.NOTE_PLAIN);
37
43
  }
@@ -52,10 +58,140 @@ function hasUpdateSequence(value) {
52
58
  function appendError(errors, source, reason, details = {}) {
53
59
  errors.push({ source, reason, ...details });
54
60
  }
55
- function progress(options, phase, processed) {
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
+ };
56
140
  try {
57
- options.onProgress?.({ phase, processed });
58
- } catch {
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];
59
195
  }
60
196
  }
61
197
  function cancelled(options) {
@@ -98,6 +234,7 @@ async function classifyObservation(options, run, document, branch, conflictCount
98
234
  return void 0;
99
235
  }
100
236
  const rawPath = document.path;
237
+ const recordedSize = isRecordedSize(document.size) ? document.size : void 0;
101
238
  const mismatch = identity.status === MetadataDocumentIdentityStatuses.UNRESOLVED ? identity.diagnostic.reason : void 0;
102
239
  const report = {
103
240
  reference,
@@ -113,6 +250,16 @@ async function classifyObservation(options, run, document, branch, conflictCount
113
250
  pathIsNFC: rawPath === rawPath.normalize("NFC"),
114
251
  caseEquivalentPaths: false,
115
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
+ },
116
263
  content: {
117
264
  status: "not-checked",
118
265
  chunksRead: 0,
@@ -136,106 +283,121 @@ async function classifyObservation(options, run, document, branch, conflictCount
136
283
  return void 0;
137
284
  }
138
285
  }
139
- async function scanSource(options, run, nextReference, metadataProgress) {
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) {
140
347
  if (cancelled(options)) return;
348
+ const phase = new InspectionPhase(options, "metadata", run.name);
141
349
  try {
142
350
  const info = await run.source.info();
143
351
  run.startSeq = info.update_seq;
352
+ if (Number.isSafeInteger(info.doc_count) && info.doc_count >= 0) phase.total = info.doc_count;
144
353
  if (!hasUpdateSequence(run.startSeq)) appendError(run.errors, run.name, "source-unavailable");
145
354
  } catch {
146
355
  appendError(run.errors, run.name, "source-unavailable");
147
356
  }
148
- if (cancelled(options)) return;
357
+ phase.update();
149
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
+ };
150
376
  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;
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);
221
385
  }
222
- await classifyObservation(
223
- options,
224
- run,
225
- branchValue,
226
- "conflict",
227
- conflictRevisions.length,
228
- nextReference(run.name)
229
- );
386
+ enumerationComplete = !cancelled(options);
387
+ } else {
388
+ enumerationComplete = await inspectBatches(run.source.enumerateIds(), options, inspect, merge);
230
389
  }
231
390
  }
232
- enumerationComplete = true;
233
391
  } catch {
234
392
  if (!cancelled(options)) appendError(run.errors, run.name, "source-unavailable");
235
393
  }
236
- if (cancelled(options)) return;
237
- run.metadataScanComplete = enumerationComplete && run.errors.length === 0;
238
- run.enumerationComplete = enumerationComplete;
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");
239
401
  }
240
402
  function groupObservations(runs) {
241
403
  const observations = runs.flatMap((run) => run.observations);
@@ -277,19 +439,42 @@ function markPathEquivalences(observations) {
277
439
  for (const observation of observations) {
278
440
  const path = observation.report.path;
279
441
  const caseFold = path.toLowerCase();
280
- const casePaths = pathsByCaseFold.get(caseFold) ?? /* @__PURE__ */ new Set();
281
- casePaths.add(path);
442
+ const casePaths = pathsByCaseFold.get(caseFold) ?? [];
443
+ casePaths.push(observation);
282
444
  pathsByCaseFold.set(caseFold, casePaths);
283
445
  const nfc = path.normalize("NFC");
284
- const nfcPaths = pathsByNfc.get(nfc) ?? /* @__PURE__ */ new Set();
285
- nfcPaths.add(path);
446
+ const nfcPaths = pathsByNfc.get(nfc) ?? [];
447
+ nfcPaths.push(observation);
286
448
  pathsByNfc.set(nfc, nfcPaths);
287
449
  }
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;
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
+ }
292
476
  }
477
+ return equivalenceGroups;
293
478
  }
294
479
  function markSuspectGroups(groups, runs) {
295
480
  const crossSourceComparisonAvailable = runs.some((run) => run.name === "local" && run.metadataScanComplete) && runs.some((run) => run.name === "remote" && run.metadataScanComplete);
@@ -336,7 +521,7 @@ function getEdenChunk(document, id) {
336
521
  if (!isRecord(chunk) || typeof chunk.data !== "string") return void 0;
337
522
  return chunk.data;
338
523
  }
339
- async function readObservationContent(options, observation, run) {
524
+ async function readObservationContent(options, observation, run, phase) {
340
525
  run.coverage.contentReads++;
341
526
  const unavailableChunks = [];
342
527
  const data = [];
@@ -353,54 +538,63 @@ async function readObservationContent(options, observation, run) {
353
538
  unavailableChunks
354
539
  };
355
540
  }
356
- for (const child of observation.document.children) {
541
+ for (let offset = 0; offset < observation.document.children.length; offset += DOCUMENT_BATCH_SIZE) {
357
542
  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);
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) {
366
550
  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
- });
551
+ const eden = getEdenChunk(observation.document, child);
552
+ if (eden !== void 0) {
553
+ data.push(eden);
554
+ chunksRead++;
375
555
  continue;
376
556
  }
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++;
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
+ });
386
588
  continue;
387
589
  }
388
- unavailableChunks.push({ id: child, status: "missing" });
389
- appendError(run.errors, run.name, reasonFromReadError(error, "chunk-missing"), {
590
+ unavailableChunks.push({ id: child, status: "unreadable" });
591
+ appendError(run.errors, run.name, reasonFromReadError(error, "chunk-unreadable"), {
390
592
  id: observation.report.id,
391
593
  revision: observation.report.revision,
392
594
  path: observation.report.path,
393
595
  chunkId: child
394
596
  });
395
- continue;
396
597
  }
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
598
  }
405
599
  }
406
600
  if (unavailableChunks.length > 0) {
@@ -451,10 +645,10 @@ function relation(left, right) {
451
645
  }
452
646
  return "equal";
453
647
  }
454
- async function readVaultContent(options, path, errors) {
648
+ async function readVaultContent(options, path, errors, phase) {
455
649
  if (cancelled(options)) return { status: "not-checked" };
456
650
  try {
457
- const content = await options.readStorage(path);
651
+ const content = await phase.read(() => options.readStorage(path));
458
652
  if (cancelled(options)) return { status: "not-checked" };
459
653
  if (content.status === "unreadable" || content.reason) {
460
654
  appendError(errors, "storage", content.reason ?? "content-unreadable", { path });
@@ -467,7 +661,7 @@ async function readVaultContent(options, path, errors) {
467
661
  return { status: "unreadable", reason };
468
662
  }
469
663
  }
470
- async function inspectGroupContent(options, group, runsByName, errors, contentProgress) {
664
+ async function inspectGroupContent(options, group, runsByName, errors, phase) {
471
665
  if (!group.suspect) return;
472
666
  const byRawPath = /* @__PURE__ */ new Map();
473
667
  for (const observation of group.observations) {
@@ -489,32 +683,42 @@ async function inspectGroupContent(options, group, runsByName, errors, contentPr
489
683
  if (cancelled(options)) return;
490
684
  const run = runsByName.get(observation.report.source);
491
685
  if (!run) continue;
492
- const outcome = await readObservationContent(options, observation, run);
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;
493
689
  observation.report.content = {
494
690
  status: outcome.content.status,
495
691
  ...outcome.content.reason ? { reason: outcome.content.reason } : {},
496
692
  chunksRead: outcome.chunksRead,
497
- unavailableChunks: outcome.unavailableChunks
693
+ unavailableChunks: outcome.unavailableChunks,
694
+ ...decodedSize !== void 0 ? {
695
+ size: decodedSize,
696
+ ...recordedSize !== void 0 ? { recordedSizeDifference: decodedSize - recordedSize } : {}
697
+ } : {}
498
698
  };
499
699
  contentByObservation.set(observation, outcome.content);
500
700
  contentByReference.set(observation.report.reference, outcome.content);
501
- contentProgress.processed++;
502
- progress(options, "content", contentProgress.processed);
503
701
  if (cancelled(options)) return;
504
702
  }
505
703
  const vaultByPath = /* @__PURE__ */ new Map();
506
704
  for (const [path] of byRawPath) {
507
705
  if (cancelled(options)) return;
508
- const vaultContent = await readVaultContent(options, path, errors);
706
+ const vaultContent = await readVaultContent(options, path, errors, phase);
509
707
  vaultByPath.set(path, vaultContent);
510
708
  const vaultReference = vaultReferences.get(path);
511
709
  if (vaultReference) contentByReference.set(vaultReference, vaultContent);
512
710
  for (const observation of group.observations) {
513
711
  if (observation.report.path !== path) continue;
712
+ const observationContent = contentByObservation.get(observation);
514
713
  observation.report.vault = {
515
714
  reference: vaultReference ?? `vault:${observation.report.reference}`,
516
715
  status: vaultContent.status,
517
- ...vaultContent.reason ? { reason: vaultContent.reason } : {}
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
+ } : {}
518
722
  };
519
723
  }
520
724
  if (cancelled(options)) return;
@@ -540,12 +744,11 @@ function determineStatus(wasCancelled, errors, runs, configurationChanged) {
540
744
  async function analyseDatabaseIntegrity(options) {
541
745
  const errors = [];
542
746
  const runs = [];
543
- const metadataProgress = { processed: 0 };
544
- const contentProgress = { processed: 0 };
545
747
  const referenceNumbers = { local: 0, remote: 0 };
546
748
  const nextReference = (source) => `${source}:${++referenceNumbers[source]}`;
547
749
  let wasCancelled = cancelled(options);
548
750
  let configurationChanged = false;
751
+ let pathEquivalenceGroups = [];
549
752
  let ownedRemote;
550
753
  if (!wasCancelled && !isConfigurationCurrent(options)) {
551
754
  configurationChanged = true;
@@ -555,8 +758,8 @@ async function analyseDatabaseIntegrity(options) {
555
758
  if (!wasCancelled && !configurationChanged) {
556
759
  const localRun = makeSourceRun("local", options.local);
557
760
  runs.push(localRun);
558
- await scanSource(options, localRun, nextReference, metadataProgress);
559
- if (!cancelled(options)) {
761
+ await scanSource(options, localRun, nextReference);
762
+ if (!cancelled(options) && options.includeRemote !== false) {
560
763
  if (!options.remote) {
561
764
  const remoteRun = { ...makeSourceRun("remote", options.local), source: options.local };
562
765
  remoteRun.coverage.status = "failed";
@@ -571,7 +774,7 @@ async function analyseDatabaseIntegrity(options) {
571
774
  } else {
572
775
  const remoteRun = makeSourceRun("remote", ownedRemote);
573
776
  runs.push(remoteRun);
574
- await scanSource(options, remoteRun, nextReference, metadataProgress);
777
+ await scanSource(options, remoteRun, nextReference);
575
778
  }
576
779
  } catch {
577
780
  if (cancelled(options)) {
@@ -588,21 +791,43 @@ async function analyseDatabaseIntegrity(options) {
588
791
  }
589
792
  if (cancelled(options)) wasCancelled = true;
590
793
  if (!wasCancelled) {
794
+ const grouping = new InspectionPhase(options, "grouping");
591
795
  const groups = groupObservations(runs);
592
- markPathEquivalences(runs.flatMap((run) => run.observations));
796
+ pathEquivalenceGroups = markPathEquivalences(runs.flatMap((run) => run.observations));
593
797
  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;
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);
604
827
  }
605
- }
828
+ );
829
+ if (cancelled(options)) wasCancelled = true;
830
+ content.log(wasCancelled ? "cancelled" : "completed");
606
831
  }
607
832
  }
608
833
  if (!wasCancelled && !cancelled(options)) {
@@ -660,8 +885,10 @@ async function analyseDatabaseIntegrity(options) {
660
885
  settings: { ...options.settings },
661
886
  coverage,
662
887
  observations: allObservations,
888
+ ...pathEquivalenceGroups.length > 0 ? { pathEquivalenceGroups } : {},
663
889
  errors,
664
890
  scope: {
891
+ remote: options.includeRemote === false ? "excluded" : "included",
665
892
  metadata: "normal-files-and-current-conflicts",
666
893
  content: "suspect-groups-only",
667
894
  history: "not-checked",