@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.
@@ -0,0 +1,16 @@
1
+ import type { DiagnosticNameMode } from "./types.js";
2
+ export interface DiagnosticNameMapper {
3
+ readonly mode: DiagnosticNameMode;
4
+ readonly salt?: string;
5
+ path(value: string): string;
6
+ id(value: string): string;
7
+ revision(value: string): string;
8
+ chunk(value: string): string;
9
+ }
10
+ /** Reads only a complete version-one path token, never a document or revision alias. */
11
+ export declare function parseDiagnosticPathToken(value: string): {
12
+ salt: string;
13
+ hash: string;
14
+ } | undefined;
15
+ /** Reuses public report salts without storing paths or a correspondence table outside this mapper. */
16
+ export declare function createDiagnosticNameMapper(mode: DiagnosticNameMode, suppliedSalt?: string): DiagnosticNameMapper;
@@ -0,0 +1,50 @@
1
+ import { Sha256 } from "@aws-crypto/sha256-js";
2
+ const SALT_PATTERN = /^[A-Za-z0-9_-]{8}$/;
3
+ function parseDiagnosticPathToken(value) {
4
+ const match = /^([A-Za-z0-9_-]{8})\$([0-9a-f]{32})$/.exec(value);
5
+ return match ? { salt: match[1], hash: match[2] } : void 0;
6
+ }
7
+ function randomSalt() {
8
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(6));
9
+ return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_");
10
+ }
11
+ function nameHash(salt, domain, value) {
12
+ const hash = new Sha256();
13
+ hash.update(JSON.stringify(["livesync-diagnostic-v1", salt, domain, value]));
14
+ return Array.from(hash.digestSync().subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("");
15
+ }
16
+ function createDiagnosticNameMapper(mode, suppliedSalt) {
17
+ if (mode === "original") {
18
+ const original = (value) => value;
19
+ return { mode, path: original, id: original, revision: original, chunk: original };
20
+ }
21
+ const salt = suppliedSalt ?? randomSalt();
22
+ if (!SALT_PATTERN.test(salt)) throw new Error("Invalid diagnostic salt");
23
+ const domainMapper = (domain, format) => {
24
+ const aliases = /* @__PURE__ */ new Map();
25
+ return (value) => {
26
+ const existing = aliases.get(value);
27
+ if (existing !== void 0) return existing;
28
+ const alias = format(nameHash(salt, domain, value), value);
29
+ if (aliases.size >= 4096) aliases.clear();
30
+ aliases.set(value, alias);
31
+ return alias;
32
+ };
33
+ };
34
+ return {
35
+ mode,
36
+ salt,
37
+ path: domainMapper("path", (hash) => `${salt}$${hash}`),
38
+ id: domainMapper("document", (hash) => `document-${hash}`),
39
+ revision: domainMapper("revision", (hash, value) => {
40
+ const generation = /^([1-9][0-9]*)-.+$/.exec(value)?.[1];
41
+ const prefix = generation !== void 0 && Number.isSafeInteger(Number(generation)) ? `${generation}-` : "";
42
+ return `${prefix}psu${hash}`;
43
+ }),
44
+ chunk: domainMapper("chunk", (hash) => `chunk-${hash}`)
45
+ };
46
+ }
47
+ export {
48
+ createDiagnosticNameMapper,
49
+ parseDiagnosticPathToken
50
+ };
@@ -1,19 +1,13 @@
1
- import type { DatabaseIntegrityResult, DiagnosticCoverage, DiagnosticError, DiagnosticIdentitySettings, DiagnosticNameMode, DiagnosticObservation, DiagnosticSourceName, DiagnosticStatus } from "./types.js";
1
+ import type { DatabaseIntegrityResult, DiagnosticCoverage, DiagnosticError, DiagnosticIdentitySettings, DiagnosticNameMode, DiagnosticObservation, DiagnosticPathEquivalenceGroup, DiagnosticSourceName, DiagnosticStatus } from "./types.js";
2
2
  import type { DatabaseUsageError, DatabaseUsageResult, DatabaseUsageStatus } from "./usage.js";
3
- export interface DiagnosticNameMapper {
4
- readonly mode: DiagnosticNameMode;
5
- path(value: string): string;
6
- id(value: string): string;
7
- revision(value: string): string;
8
- chunk(value: string): string;
9
- }
3
+ import type { DiagnosticNameMapper } from "./names.js";
4
+ export { createDiagnosticNameMapper, parseDiagnosticPathToken } from "./names.js";
5
+ export type { DiagnosticNameMapper } from "./names.js";
10
6
  export interface DiagnosticReportVersions {
11
7
  commonlib?: string;
12
8
  plugin?: string;
13
9
  application?: string;
14
10
  }
15
- /** Creates run-local name aliases. Each name domain has an independent map. */
16
- export declare function createDiagnosticNameMapper(mode: DiagnosticNameMode): DiagnosticNameMapper;
17
11
  export interface ProjectedDiagnosticError {
18
12
  source: DiagnosticError["source"];
19
13
  reason: DiagnosticError["reason"];
@@ -36,11 +30,17 @@ export interface ProjectedDiagnosticObservation {
36
30
  pathIsNFC: boolean;
37
31
  caseEquivalentPaths: boolean;
38
32
  nfcEquivalentPaths: boolean;
33
+ caseEquivalentGroup?: string;
34
+ nfcEquivalentGroup?: string;
35
+ metadata?: DiagnosticObservation["metadata"];
39
36
  otherSourceGroup: DiagnosticObservation["otherSourceGroup"];
40
37
  vault: {
41
38
  reference: string;
42
39
  status: DiagnosticObservation["vault"]["status"];
43
40
  reason?: DiagnosticObservation["vault"]["reason"];
41
+ size?: number;
42
+ contentSizeDifference?: number;
43
+ mtimeDifferenceMs?: number;
44
44
  };
45
45
  content: {
46
46
  status: DiagnosticObservation["content"]["status"];
@@ -50,6 +50,8 @@ export interface ProjectedDiagnosticObservation {
50
50
  id: string;
51
51
  status: "missing" | "unreadable";
52
52
  }[];
53
+ size?: number;
54
+ recordedSizeDifference?: number;
53
55
  };
54
56
  comparisons: {
55
57
  against: string;
@@ -65,6 +67,7 @@ export interface ProjectedDatabaseIntegrityReport {
65
67
  settings: DiagnosticIdentitySettings;
66
68
  coverage: Record<DiagnosticSourceName, DiagnosticCoverage>;
67
69
  observations: ProjectedDiagnosticObservation[];
70
+ pathEquivalenceGroups?: DiagnosticPathEquivalenceGroup[];
68
71
  errors: ProjectedDiagnosticError[];
69
72
  scope: DatabaseIntegrityResult["scope"];
70
73
  }
@@ -1,26 +1,4 @@
1
- function createDomainMapper(prefix) {
2
- const aliases = /* @__PURE__ */ new Map();
3
- return (value) => {
4
- const existing = aliases.get(value);
5
- if (existing !== void 0) return existing;
6
- const alias = `${prefix}-${String(aliases.size + 1).padStart(3, "0")}`;
7
- aliases.set(value, alias);
8
- return alias;
9
- };
10
- }
11
- function createDiagnosticNameMapper(mode) {
12
- if (mode === "original") {
13
- const original = (value) => value;
14
- return { mode, path: original, id: original, revision: original, chunk: original };
15
- }
16
- return {
17
- mode,
18
- path: createDomainMapper("file"),
19
- id: createDomainMapper("document"),
20
- revision: createDomainMapper("revision"),
21
- chunk: createDomainMapper("chunk")
22
- };
23
- }
1
+ import { createDiagnosticNameMapper, parseDiagnosticPathToken } from "./names.js";
24
2
  function copyVersions(versions) {
25
3
  return {
26
4
  ...versions.commonlib === void 0 ? {} : { commonlib: versions.commonlib },
@@ -39,6 +17,9 @@ function copyCoverage(coverage) {
39
17
  changedDuringRun: coverage.changedDuringRun
40
18
  };
41
19
  }
20
+ function finiteField(key, value, nonnegative = false) {
21
+ return typeof value === "number" && Number.isFinite(value) && (!nonnegative || value >= 0) ? { [key]: value } : {};
22
+ }
42
23
  function projectIntegrityObservation(observation, mapper, projectReference) {
43
24
  return {
44
25
  reference: projectReference(observation.reference),
@@ -54,11 +35,29 @@ function projectIntegrityObservation(observation, mapper, projectReference) {
54
35
  pathIsNFC: observation.pathIsNFC,
55
36
  caseEquivalentPaths: observation.caseEquivalentPaths,
56
37
  nfcEquivalentPaths: observation.nfcEquivalentPaths,
38
+ ...observation.caseEquivalentGroup === void 0 ? {} : {
39
+ caseEquivalentGroup: projectReference(observation.caseEquivalentGroup)
40
+ },
41
+ ...observation.nfcEquivalentGroup === void 0 ? {} : {
42
+ nfcEquivalentGroup: projectReference(observation.nfcEquivalentGroup)
43
+ },
44
+ ...observation.metadata === void 0 ? {} : {
45
+ metadata: {
46
+ type: ["plain", "newnote", "notes"].includes(observation.metadata.type) ? observation.metadata.type : "unknown",
47
+ ...finiteField("recordedSize", observation.metadata.recordedSize, true),
48
+ chunkReferences: observation.metadata.chunkReferences,
49
+ uniqueChunkReferences: observation.metadata.uniqueChunkReferences,
50
+ embeddedChunkReferences: observation.metadata.embeddedChunkReferences
51
+ }
52
+ },
57
53
  otherSourceGroup: observation.otherSourceGroup,
58
54
  vault: {
59
55
  reference: projectReference(observation.vault.reference),
60
56
  status: observation.vault.status,
61
- ...observation.vault.reason === void 0 ? {} : { reason: observation.vault.reason }
57
+ ...observation.vault.reason === void 0 ? {} : { reason: observation.vault.reason },
58
+ ...finiteField("size", observation.vault.size, true),
59
+ ...finiteField("contentSizeDifference", observation.vault.contentSizeDifference),
60
+ ...finiteField("mtimeDifferenceMs", observation.vault.mtimeDifferenceMs)
62
61
  },
63
62
  content: {
64
63
  status: observation.content.status,
@@ -67,7 +66,9 @@ function projectIntegrityObservation(observation, mapper, projectReference) {
67
66
  unavailableChunks: observation.content.unavailableChunks.map((chunk) => ({
68
67
  id: mapper.chunk(chunk.id),
69
68
  status: chunk.status
70
- }))
69
+ })),
70
+ ...finiteField("size", observation.content.size, true),
71
+ ...finiteField("recordedSizeDifference", observation.content.recordedSizeDifference)
71
72
  },
72
73
  comparisons: observation.comparisons.map((comparison) => ({
73
74
  against: projectReference(comparison.against),
@@ -86,7 +87,7 @@ function projectIntegrityError(error, mapper) {
86
87
  };
87
88
  }
88
89
  function projectIntegrityReport(result, mapper, versions = {}) {
89
- const safeReference = /^((local|remote):[0-9]+|vault:(local|remote):[0-9]+)$/;
90
+ const safeReference = /^((local|remote):[0-9]+|vault:(local|remote):[0-9]+|(case|nfc):[0-9]+)$/;
90
91
  const referenceAliases = /* @__PURE__ */ new Map();
91
92
  const projectReference = (value) => {
92
93
  if (safeReference.test(value)) return value;
@@ -116,8 +117,16 @@ function projectIntegrityReport(result, mapper, versions = {}) {
116
117
  observations: result.observations.map(
117
118
  (observation) => projectIntegrityObservation(observation, mapper, projectReference)
118
119
  ),
120
+ ...result.pathEquivalenceGroups === void 0 ? {} : {
121
+ pathEquivalenceGroups: result.pathEquivalenceGroups.map((group) => ({
122
+ reference: projectReference(group.reference),
123
+ kind: group.kind === "case" ? "case" : "nfc",
124
+ members: group.members.map(projectReference)
125
+ }))
126
+ },
119
127
  errors: result.errors.map((error) => projectIntegrityError(error, mapper)),
120
128
  scope: {
129
+ remote: result.scope.remote === "excluded" ? "excluded" : "included",
121
130
  metadata: "normal-files-and-current-conflicts",
122
131
  content: "suspect-groups-only",
123
132
  history: "not-checked",
@@ -153,8 +162,8 @@ function projectUsageRow(row, mapper) {
153
162
  id: mapper.id(row.id),
154
163
  path,
155
164
  revision,
156
- rev: row.rev,
157
- revHash: mapper.mode === "pseudonym" ? revision : row.revHash,
165
+ rev: mapper.mode === "pseudonym" ? /^([1-9][0-9]*)-/.exec(revision)?.[1] ?? "" : row.rev,
166
+ revHash: mapper.mode === "pseudonym" ? revision.replace(/^[1-9][0-9]*-/, "") : row.revHash,
158
167
  ...statistics
159
168
  };
160
169
  }
@@ -213,37 +222,65 @@ function formatUsageTSV(projected) {
213
222
  function formatDiagnosticJSON(projected) {
214
223
  return JSON.stringify(projected, null, 2);
215
224
  }
225
+ function longestBacktickRun(value) {
226
+ let longest = 0;
227
+ for (const match of value.matchAll(/`+/g)) longest = Math.max(longest, match[0].length);
228
+ return longest;
229
+ }
216
230
  function markdownCode(value) {
217
231
  const text = value.replace(/\r\n?|\n|\t/g, " ").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
218
- const backtickRuns = text.match(/`+/g) ?? [];
219
- const fence = "`".repeat(Math.max(0, ...backtickRuns.map((run) => run.length)) + 1);
232
+ const fence = "`".repeat(longestBacktickRun(text) + 1);
220
233
  const padding = /^\s|\s$/.test(text) ? " " : "";
221
234
  return `${fence}${padding}${text}${padding}${fence}`;
222
235
  }
223
236
  function renderMaybe(value) {
224
- return value === void 0 ? "not recorded" : markdownCode(value);
237
+ return value === void 0 ? "?" : markdownCode(value);
238
+ }
239
+ const IDENTITY_LABELS = {
240
+ consistent: "consistent",
241
+ "document-id-mismatch": "documentIdMismatch",
242
+ "namespace-mismatch": "namespaceMismatch"
243
+ };
244
+ function renderState(observation) {
245
+ return [
246
+ IDENTITY_LABELS[observation.identity],
247
+ observation.branch,
248
+ observation.logicallyDeleted ? "logicallyDeleted" : "logicallyLive",
249
+ observation.pathIsNFC ? "NFC" : "nonNFC",
250
+ observation.metadata?.type ?? "notRecorded"
251
+ ].join("-");
252
+ }
253
+ function renderPathGroup(present, group) {
254
+ return `${present ? "yes" : "none"}${group === void 0 ? "" : `@${markdownCode(group)}`}`;
255
+ }
256
+ function renderContentStatus(value) {
257
+ return `${value.status}${value.reason === void 0 ? "" : `(${value.reason})`}`;
258
+ }
259
+ function renderDifference(value) {
260
+ return `${value > 0 ? "+" : ""}${value}`;
225
261
  }
226
262
  function renderObservation(observation) {
263
+ const metadata = observation.metadata;
264
+ const expectedId = observation.expectedId === observation.id ? "self" : renderMaybe(observation.expectedId);
227
265
  const lines = [
228
- `### ${markdownCode(observation.reference)}`,
229
- `- Source: ${observation.source}`,
230
- `- Path: ${markdownCode(observation.path)}`,
231
- `- Document ID: ${markdownCode(observation.id)}`,
232
- `- Revision: ${renderMaybe(observation.revision)}`,
233
- `- Expected ID: ${renderMaybe(observation.expectedId)}`,
234
- `- Identity: ${observation.identity}`,
235
- `- Branch: ${observation.branch}`,
236
- `- Logically deleted: ${observation.logicallyDeleted}`,
237
- `- Conflict count: ${observation.conflictCount}`,
238
- `- Path is NFC: ${observation.pathIsNFC}`,
239
- `- Case-equivalent paths: ${observation.caseEquivalentPaths}`,
240
- `- NFC-equivalent paths: ${observation.nfcEquivalentPaths}`,
241
- `- Other inspected database group: ${observation.otherSourceGroup}`,
242
- `- Vault content status: ${observation.vault.status}${observation.vault.reason === void 0 ? "" : ` (${observation.vault.reason})`}`,
243
- `- Vault reference: ${markdownCode(observation.vault.reference)}`,
244
- `- Content: ${observation.content.status}${observation.content.reason === void 0 ? "" : ` (${observation.content.reason})`}`,
245
- `- Chunks read: ${observation.content.chunksRead}`
266
+ `### ${markdownCode(observation.reference)} source=${observation.source}`,
267
+ `path: ${markdownCode(observation.path)}`,
268
+ `doc: ${markdownCode(observation.id)} rev=${renderMaybe(observation.revision)} expected=${expectedId}`,
269
+ `state: ${renderState(observation)} conflicts=${observation.conflictCount}`,
270
+ `paths: case=${renderPathGroup(observation.caseEquivalentPaths, observation.caseEquivalentGroup)} nfcPeers=${renderPathGroup(observation.nfcEquivalentPaths, observation.nfcEquivalentGroup)} | otherDB=${observation.otherSourceGroup}`,
271
+ `data: recorded=${metadata?.recordedSize === void 0 ? "?" : `${metadata.recordedSize}B`} chunks=${metadata === void 0 ? "?" : `${metadata.chunkReferences}/${metadata.uniqueChunkReferences}/${metadata.embeddedChunkReferences}`} read=${observation.content.chunksRead}`,
272
+ `check: content=${renderContentStatus(observation.content)} vault=${renderContentStatus(observation.vault)}@${markdownCode(observation.vault.reference)}`
246
273
  ];
274
+ const measurements = [];
275
+ if (observation.content.size !== void 0) measurements.push(`content=${observation.content.size}B`);
276
+ if (observation.vault.size !== void 0) measurements.push(`vault=${observation.vault.size}B`);
277
+ if (observation.content.recordedSizeDifference !== void 0)
278
+ measurements.push(`deltaContent=${renderDifference(observation.content.recordedSizeDifference)}B`);
279
+ if (observation.vault.contentSizeDifference !== void 0)
280
+ measurements.push(`deltaVault=${renderDifference(observation.vault.contentSizeDifference)}B`);
281
+ if (observation.vault.mtimeDifferenceMs !== void 0)
282
+ measurements.push(`deltaMtime=${renderDifference(observation.vault.mtimeDifferenceMs)}ms`);
283
+ if (measurements.length > 0) lines.push(`measure: ${measurements.join(" ")}`);
247
284
  if (observation.content.unavailableChunks.length > 0) {
248
285
  lines.push(
249
286
  `- Unavailable chunks: ${observation.content.unavailableChunks.map((chunk) => `${markdownCode(chunk.id)} (${chunk.status})`).join(", ")}`
@@ -256,6 +293,66 @@ function renderObservation(observation) {
256
293
  }
257
294
  return lines;
258
295
  }
296
+ function unavailableStatus(status) {
297
+ return status === "missing" || status === "unreadable";
298
+ }
299
+ function hasMeasuredDifference(observation) {
300
+ return [
301
+ observation.content.recordedSizeDifference,
302
+ observation.vault.contentSizeDifference,
303
+ observation.vault.mtimeDifferenceMs
304
+ ].some((value) => value !== void 0 && value !== 0);
305
+ }
306
+ const REVIEW_CONDITIONS = [
307
+ ["Identity mismatches", (observation) => observation.identity !== "consistent"],
308
+ ["Current conflicts", (observation) => observation.branch === "conflict" || observation.conflictCount > 0],
309
+ ["Case-equivalent paths", (observation) => observation.caseEquivalentPaths],
310
+ ["NFC-equivalent paths", (observation) => observation.nfcEquivalentPaths],
311
+ ["Non-NFC paths", (observation) => !observation.pathIsNFC],
312
+ ["Other database group missing", (observation) => observation.otherSourceGroup === "missing"],
313
+ ["Missing or unreadable database content", (observation) => unavailableStatus(observation.content.status)],
314
+ ["Missing or unreadable Vault content", (observation) => unavailableStatus(observation.vault.status)],
315
+ ["Unavailable chunks", (observation) => observation.content.unavailableChunks.length > 0],
316
+ [
317
+ "Different or unavailable comparisons",
318
+ (observation) => observation.comparisons.some(
319
+ (comparison) => comparison.relation === "different" || comparison.relation === "unavailable"
320
+ )
321
+ ],
322
+ ["Measured differences", hasMeasuredDifference],
323
+ ["Metadata not recorded", (observation) => observation.metadata === void 0]
324
+ ];
325
+ function renderFindingSummary(observations) {
326
+ const states = /* @__PURE__ */ new Map();
327
+ for (const observation of observations) {
328
+ const state = renderState(observation);
329
+ states.set(state, (states.get(state) ?? 0) + 1);
330
+ }
331
+ const lines = [
332
+ "## State counts",
333
+ "",
334
+ "Counts are observations, not unique files. Local and remote observations are counted separately.",
335
+ "",
336
+ ...Array.from(states.keys()).sort().map((state) => `- ${state}: ${states.get(state)}`),
337
+ ...states.size === 0 ? ["No observations were returned."] : [],
338
+ "",
339
+ "## Review index",
340
+ "",
341
+ "These references match review conditions, not confirmed corruption. Unlisted observations may contain unchecked content.",
342
+ ""
343
+ ];
344
+ let matched = false;
345
+ for (const [caption, condition] of REVIEW_CONDITIONS) {
346
+ const references = observations.filter(condition).map((observation) => markdownCode(observation.reference));
347
+ if (references.length === 0) continue;
348
+ matched = true;
349
+ for (let offset = 0; offset < references.length; offset += 8)
350
+ lines.push(`- ${caption}: ${references.slice(offset, offset + 8).join(", ")}`);
351
+ }
352
+ if (!matched) lines.push("No observations matched the review conditions.");
353
+ lines.push("");
354
+ return lines;
355
+ }
259
356
  function formatIntegrityMarkdown(projected) {
260
357
  const observations = projected.observations;
261
358
  const mismatchCount = observations.filter((observation) => observation.identity !== "consistent").length;
@@ -297,8 +394,11 @@ function formatIntegrityMarkdown(projected) {
297
394
  "",
298
395
  "## Scope",
299
396
  "",
397
+ projected.scope.remote === "excluded" ? "- Remote database: excluded from this inspection" : "- Remote database: included",
300
398
  "- Metadata: normal files and current conflict branches",
399
+ "- Metadata values are decoded and decrypted before inspection.",
301
400
  "- Content: suspect groups only",
401
+ "- Size and time measurements use existing reads; absent measurements were not available or not checked.",
302
402
  "- History: not checked",
303
403
  "- Atomic snapshot: no",
304
404
  "",
@@ -319,14 +419,42 @@ function formatIntegrityMarkdown(projected) {
319
419
  `- Identity mismatches: ${mismatchCount}`,
320
420
  `- Conflict observations: ${conflictCount}`,
321
421
  `- Missing or unreadable content observations: ${unavailableContentCount}`,
422
+ `- Content not checked: ${observations.filter((observation) => observation.content.status === "not-checked").length}`,
423
+ `- Vault content not checked: ${observations.filter((observation) => observation.vault.status === "not-checked").length}`,
322
424
  `- Unavailable chunks: ${unavailableChunkCount}`,
323
425
  `- Errors: ${projected.errors.length}`,
324
426
  "",
427
+ "## Reading the findings",
428
+ "",
429
+ "- state: identity-branch-logicalDeletion-pathNormalisation-MetadataType; conflicts is the current conflict count.",
430
+ "- Identity: consistent, documentIdMismatch (document-id-mismatch in JSON), or namespaceMismatch (namespace-mismatch in JSON).",
431
+ "- Branch: winner or conflict. A winner can still have conflicts; a consistent identity does not prove content is readable or equal.",
432
+ "- Deletion: logicallyLive or logicallyDeleted. Path: NFC or nonNFC. Metadata: plain, newnote, notes, unknown, or notRecorded when absent.",
433
+ "- path is the original path or report lookup token. doc is the document ID; rev retains the revision generation.",
434
+ "- expected=self means the expected ID equals doc exactly. ? means not recorded, never zero.",
435
+ "- paths: case and nfcPeers are yes or none; @ gives the path-equivalence group reference.",
436
+ "- otherDB: present, missing, or not-checked for the same identity/path group in the other inspected database; present does not prove equal content.",
437
+ "- data: recorded is the Metadata size in bytes; chunks=total/unique/embedded references; read counts Chunks read, not network requests.",
438
+ "- check: available, missing, unreadable, or not-checked; parentheses retain the reason, and vault@ references the Vault comparison target.",
439
+ "- measure includes available values only: content and vault sizes in bytes, deltaContent=content-Metadata, deltaVault=Vault-content, deltaMtime=Vault-Metadata in milliseconds.",
440
+ "- Differences retain their sign (+ for positive); absent measurements were unavailable or not checked. Measurements do not select an authoritative version.",
441
+ "- Unavailable chunks, comparisons, path-equivalence groups, and errors retain their references and reasons.",
442
+ "",
443
+ ...renderFindingSummary(observations),
325
444
  "## Findings",
326
445
  ""
327
446
  );
328
447
  if (observations.length === 0) lines.push("No observations were returned.");
329
448
  for (const observation of observations) lines.push(...renderObservation(observation), "");
449
+ if (projected.pathEquivalenceGroups?.length) {
450
+ lines.push("## Path equivalence groups", "");
451
+ for (const group of projected.pathEquivalenceGroups) {
452
+ lines.push(
453
+ `- ${markdownCode(group.reference)} (${group.kind}): ${group.members.map(markdownCode).join(", ")}`
454
+ );
455
+ }
456
+ lines.push("");
457
+ }
330
458
  if (projected.errors.length > 0) {
331
459
  lines.push("## Errors", "");
332
460
  for (const error of projected.errors) {
@@ -341,13 +469,26 @@ function formatIntegrityMarkdown(projected) {
341
469
  lines.push(`- ${details.join(", ")}`);
342
470
  }
343
471
  }
344
- return lines.join("\n").trimEnd();
472
+ const body = lines.join("\n").trimEnd();
473
+ const summaryStatus = ["complete", "partial", "failed", "cancelled"].includes(projected.status) ? projected.status : "unknown";
474
+ const fenceLength = Math.max(3, longestBacktickRun(body) + 1);
475
+ const fence = "`".repeat(fenceLength);
476
+ return [
477
+ "<details>",
478
+ `<summary>Database integrity report (${summaryStatus})</summary>`,
479
+ "",
480
+ `${fence}text`,
481
+ body,
482
+ fence,
483
+ "</details>"
484
+ ].join("\n");
345
485
  }
346
486
  export {
347
487
  createDiagnosticNameMapper,
348
488
  formatDiagnosticJSON,
349
489
  formatIntegrityMarkdown,
350
490
  formatUsageTSV,
491
+ parseDiagnosticPathToken,
351
492
  projectIntegrityReport,
352
493
  projectUsageReport
353
494
  };
@@ -10,13 +10,25 @@ export interface DiagnosticReadOptions {
10
10
  revs?: boolean;
11
11
  revs_info?: boolean;
12
12
  }
13
+ export type DiagnosticDocumentReadResult = {
14
+ id: string;
15
+ document: EntryDoc;
16
+ error?: never;
17
+ } | {
18
+ id: string;
19
+ error: unknown;
20
+ document?: never;
21
+ };
13
22
  /** Only these reads are available to an analyser; it cannot initialise or repair a database. */
14
23
  export interface DiagnosticDatabaseSource {
15
24
  info(): Promise<{
16
25
  update_seq?: string | number;
26
+ doc_count?: number;
17
27
  }>;
18
28
  enumerateIds(): AsyncIterable<string>;
19
29
  get(id: string, options?: DiagnosticReadOptions): Promise<EntryDoc>;
30
+ /** Current documents only; exact conflict revisions still use get. Results retain input order. */
31
+ getMany?(ids: string[], options?: Pick<DiagnosticReadOptions, "conflicts">): Promise<DiagnosticDocumentReadResult[]>;
20
32
  }
21
33
  export interface DiagnosticError {
22
34
  source: DiagnosticSourceName | "storage" | "configuration";
@@ -29,6 +41,7 @@ export interface DiagnosticError {
29
41
  export type DiagnosticContent = {
30
42
  status: "available";
31
43
  bytes: Uint8Array<ArrayBuffer>;
44
+ mtime?: number;
32
45
  reason?: never;
33
46
  } | {
34
47
  status: "missing" | "unreadable" | "not-checked";
@@ -57,13 +70,25 @@ export interface DiagnosticObservation {
57
70
  conflictCount: number;
58
71
  pathIsNFC: boolean;
59
72
  caseEquivalentPaths: boolean;
73
+ caseEquivalentGroup?: string;
60
74
  nfcEquivalentPaths: boolean;
75
+ nfcEquivalentGroup?: string;
61
76
  /** Presence of this identity/path group in the other inspected database. */
62
77
  otherSourceGroup: "present" | "missing" | "not-checked";
78
+ metadata?: {
79
+ type: string;
80
+ recordedSize?: number;
81
+ chunkReferences: number;
82
+ uniqueChunkReferences: number;
83
+ embeddedChunkReferences: number;
84
+ };
63
85
  vault: {
64
86
  reference: string;
65
87
  status: DiagnosticContent["status"];
66
88
  reason?: DiagnosticReason;
89
+ size?: number;
90
+ contentSizeDifference?: number;
91
+ mtimeDifferenceMs?: number;
67
92
  };
68
93
  content: {
69
94
  status: DiagnosticContent["status"];
@@ -73,12 +98,19 @@ export interface DiagnosticObservation {
73
98
  id: string;
74
99
  status: "missing" | "unreadable";
75
100
  }[];
101
+ size?: number;
102
+ recordedSizeDifference?: number;
76
103
  };
77
104
  comparisons: {
78
105
  against: string;
79
106
  relation: "equal" | "different" | "unavailable" | "not-checked";
80
107
  }[];
81
108
  }
109
+ export interface DiagnosticPathEquivalenceGroup {
110
+ reference: string;
111
+ kind: "case" | "nfc";
112
+ members: string[];
113
+ }
82
114
  /** A whitelist: connection details, keys, and passphrases have no report field. */
83
115
  export interface DiagnosticIdentitySettings {
84
116
  encrypt: boolean;
@@ -94,17 +126,45 @@ export interface DatabaseIntegrityResult {
94
126
  settings: DiagnosticIdentitySettings;
95
127
  coverage: Record<DiagnosticSourceName, DiagnosticCoverage>;
96
128
  observations: DiagnosticObservation[];
129
+ pathEquivalenceGroups?: DiagnosticPathEquivalenceGroup[];
97
130
  errors: DiagnosticError[];
98
131
  /** Scope declarations are part of every report, including a successful one. */
99
132
  scope: {
133
+ remote: "included" | "excluded";
100
134
  metadata: "normal-files-and-current-conflicts";
101
135
  content: "suspect-groups-only";
102
136
  history: "not-checked";
103
137
  atomicSnapshot: false;
104
138
  };
105
139
  }
140
+ export interface DatabaseIntegrityProgress {
141
+ phase: "metadata" | "content";
142
+ source?: DiagnosticSourceName;
143
+ /** Completed documents (including exclusions), or completed suspect content groups. */
144
+ processed: number;
145
+ /** A source's document count, or the number of suspect groups; absent until known. */
146
+ total?: number;
147
+ elapsedMs: number;
148
+ }
149
+ /** Timing and counters only: no document identifiers, paths, or connection details. */
150
+ export interface DatabaseIntegrityLog {
151
+ phase: "metadata" | "grouping" | "content";
152
+ source?: DiagnosticSourceName;
153
+ event: "started" | "progress" | "completed" | "partial" | "cancelled";
154
+ processed: number;
155
+ total?: number;
156
+ elapsedMs: number;
157
+ concurrency: number;
158
+ activeReads: number;
159
+ peakActiveReads: number;
160
+ readOperations: number;
161
+ /** Sum of read durations, including overlap between parallel reads. */
162
+ readDurationMs: number;
163
+ }
106
164
  export interface DatabaseIntegrityOptions {
107
165
  local: DiagnosticDatabaseSource;
166
+ /** Defaults to true. False inspects only local evidence and never opens the remote source. */
167
+ includeRemote?: boolean;
108
168
  remote?: () => Promise<DiagnosticDatabaseSource & {
109
169
  close(): Promise<void>;
110
170
  }>;
@@ -115,10 +175,10 @@ export interface DatabaseIntegrityOptions {
115
175
  /** Captures profile and relevant settings identity without exporting it. */
116
176
  isConfigurationCurrent(): boolean;
117
177
  signal?: AbortSignal;
118
- onProgress?(progress: {
119
- phase: "metadata" | "content";
120
- processed: number;
121
- }): void;
178
+ /** Concurrent Metadata classifications or content groups, default four, clamped to 1–16. Source-internal bulk decoding has its own limit. */
179
+ concurrency?: number;
180
+ onProgress?(progress: DatabaseIntegrityProgress): void;
181
+ onLog?(event: DatabaseIntegrityLog): void;
122
182
  }
123
183
  export declare class DiagnosticReadError extends Error {
124
184
  readonly reason: DiagnosticReason;
@@ -12,8 +12,12 @@ export interface DatabaseUsageSource {
12
12
  get(id: string, options?: DatabaseUsageGetOptions): Promise<object | null | undefined>;
13
13
  }
14
14
  export interface DatabaseUsageProgress {
15
+ phase: "reading" | "accounting";
15
16
  idsProcessed: number;
16
17
  revisionsProcessed: number;
18
+ processed: number;
19
+ total: number;
20
+ totalIsFinal: boolean;
17
21
  }
18
22
  export type DatabaseUsageStatus = "complete" | "partial" | "failed" | "cancelled";
19
23
  export type DatabaseUsageErrorCode = "start_info_read_failed" | "start_update_seq_unavailable" | "end_info_read_failed" | "end_update_seq_unavailable" | "configuration_changed" | "id_enumeration_failed" | "revision_list_read_failed" | "revision_not_found" | "revision_read_failed" | "revision_document_invalid";