@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.
- package/README.md +40 -0
- package/dist/common/pathIdentity.d.ts +10 -1
- package/dist/common/pathIdentity.js +1 -0
- package/dist/diagnostics/couchdb.d.ts +15 -0
- package/dist/diagnostics/couchdb.js +202 -0
- package/dist/diagnostics/index.d.ts +10 -0
- package/dist/diagnostics/index.js +28 -0
- package/dist/diagnostics/integrity.d.ts +5 -0
- package/dist/diagnostics/integrity.js +901 -0
- package/dist/diagnostics/local.d.ts +4 -0
- package/dist/diagnostics/local.js +25 -0
- package/dist/diagnostics/names.d.ts +16 -0
- package/dist/diagnostics/names.js +50 -0
- package/dist/diagnostics/reports.d.ts +111 -0
- package/dist/diagnostics/reports.js +494 -0
- package/dist/diagnostics/types.d.ts +190 -0
- package/dist/diagnostics/types.js +13 -0
- package/dist/diagnostics/usage.d.ts +55 -0
- package/dist/diagnostics/usage.js +345 -0
- package/dist/pouchdb/encryption.d.ts +3 -1
- package/dist/pouchdb/encryption.js +38 -30
- package/dist/serviceFeatures/offlineScanner.d.ts +11 -2
- package/dist/serviceModules/Rebuilder.js +1 -1
- package/docs/database-diagnostics.md +69 -0
- package/docs/development.md +1 -0
- package/docs/proven-in-use.md +11 -1
- package/docs/unicode-path-identity.md +98 -0
- package/package.json +10 -1
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { createDiagnosticNameMapper, parseDiagnosticPathToken } from "./names.js";
|
|
2
|
+
function copyVersions(versions) {
|
|
3
|
+
return {
|
|
4
|
+
...versions.commonlib === void 0 ? {} : { commonlib: versions.commonlib },
|
|
5
|
+
...versions.plugin === void 0 ? {} : { plugin: versions.plugin },
|
|
6
|
+
...versions.application === void 0 ? {} : { application: versions.application }
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function copyCoverage(coverage) {
|
|
10
|
+
return {
|
|
11
|
+
status: coverage.status,
|
|
12
|
+
documents: coverage.documents,
|
|
13
|
+
metadata: coverage.metadata,
|
|
14
|
+
excluded: coverage.excluded,
|
|
15
|
+
conflictBranches: coverage.conflictBranches,
|
|
16
|
+
contentReads: coverage.contentReads,
|
|
17
|
+
changedDuringRun: coverage.changedDuringRun
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function finiteField(key, value, nonnegative = false) {
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) && (!nonnegative || value >= 0) ? { [key]: value } : {};
|
|
22
|
+
}
|
|
23
|
+
function projectIntegrityObservation(observation, mapper, projectReference) {
|
|
24
|
+
return {
|
|
25
|
+
reference: projectReference(observation.reference),
|
|
26
|
+
source: observation.source,
|
|
27
|
+
id: mapper.id(observation.id),
|
|
28
|
+
...observation.revision === void 0 ? {} : { revision: mapper.revision(observation.revision) },
|
|
29
|
+
path: mapper.path(observation.path),
|
|
30
|
+
...observation.expectedId === void 0 ? {} : { expectedId: mapper.id(observation.expectedId) },
|
|
31
|
+
identity: observation.identity,
|
|
32
|
+
logicallyDeleted: observation.logicallyDeleted,
|
|
33
|
+
branch: observation.branch,
|
|
34
|
+
conflictCount: observation.conflictCount,
|
|
35
|
+
pathIsNFC: observation.pathIsNFC,
|
|
36
|
+
caseEquivalentPaths: observation.caseEquivalentPaths,
|
|
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
|
+
},
|
|
53
|
+
otherSourceGroup: observation.otherSourceGroup,
|
|
54
|
+
vault: {
|
|
55
|
+
reference: projectReference(observation.vault.reference),
|
|
56
|
+
status: observation.vault.status,
|
|
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)
|
|
61
|
+
},
|
|
62
|
+
content: {
|
|
63
|
+
status: observation.content.status,
|
|
64
|
+
...observation.content.reason === void 0 ? {} : { reason: observation.content.reason },
|
|
65
|
+
chunksRead: observation.content.chunksRead,
|
|
66
|
+
unavailableChunks: observation.content.unavailableChunks.map((chunk) => ({
|
|
67
|
+
id: mapper.chunk(chunk.id),
|
|
68
|
+
status: chunk.status
|
|
69
|
+
})),
|
|
70
|
+
...finiteField("size", observation.content.size, true),
|
|
71
|
+
...finiteField("recordedSizeDifference", observation.content.recordedSizeDifference)
|
|
72
|
+
},
|
|
73
|
+
comparisons: observation.comparisons.map((comparison) => ({
|
|
74
|
+
against: projectReference(comparison.against),
|
|
75
|
+
relation: comparison.relation
|
|
76
|
+
}))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function projectIntegrityError(error, mapper) {
|
|
80
|
+
return {
|
|
81
|
+
source: error.source,
|
|
82
|
+
reason: error.reason,
|
|
83
|
+
...error.id === void 0 ? {} : { id: mapper.id(error.id) },
|
|
84
|
+
...error.revision === void 0 ? {} : { revision: mapper.revision(error.revision) },
|
|
85
|
+
...error.path === void 0 ? {} : { path: mapper.path(error.path) },
|
|
86
|
+
...error.chunkId === void 0 ? {} : { chunkId: mapper.chunk(error.chunkId) }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function projectIntegrityReport(result, mapper, versions = {}) {
|
|
90
|
+
const safeReference = /^((local|remote):[0-9]+|vault:(local|remote):[0-9]+|(case|nfc):[0-9]+)$/;
|
|
91
|
+
const referenceAliases = /* @__PURE__ */ new Map();
|
|
92
|
+
const projectReference = (value) => {
|
|
93
|
+
if (safeReference.test(value)) return value;
|
|
94
|
+
const existing = referenceAliases.get(value);
|
|
95
|
+
if (existing !== void 0) return existing;
|
|
96
|
+
const alias = `finding-${String(referenceAliases.size + 1).padStart(3, "0")}`;
|
|
97
|
+
referenceAliases.set(value, alias);
|
|
98
|
+
return alias;
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
kind: "integrity",
|
|
103
|
+
status: result.status,
|
|
104
|
+
nameMode: mapper.mode,
|
|
105
|
+
versions: copyVersions(versions),
|
|
106
|
+
settings: {
|
|
107
|
+
encrypt: result.settings.encrypt,
|
|
108
|
+
usePathObfuscation: result.settings.usePathObfuscation,
|
|
109
|
+
handleFilenameCaseSensitive: result.settings.handleFilenameCaseSensitive,
|
|
110
|
+
idDerivationVersion: result.settings.idDerivationVersion,
|
|
111
|
+
pathNormalizationVersion: result.settings.pathNormalizationVersion
|
|
112
|
+
},
|
|
113
|
+
coverage: {
|
|
114
|
+
local: copyCoverage(result.coverage.local),
|
|
115
|
+
remote: copyCoverage(result.coverage.remote)
|
|
116
|
+
},
|
|
117
|
+
observations: result.observations.map(
|
|
118
|
+
(observation) => projectIntegrityObservation(observation, mapper, projectReference)
|
|
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
|
+
},
|
|
127
|
+
errors: result.errors.map((error) => projectIntegrityError(error, mapper)),
|
|
128
|
+
scope: {
|
|
129
|
+
remote: result.scope.remote === "excluded" ? "excluded" : "included",
|
|
130
|
+
metadata: "normal-files-and-current-conflicts",
|
|
131
|
+
content: "suspect-groups-only",
|
|
132
|
+
history: "not-checked",
|
|
133
|
+
atomicSnapshot: false
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function isOrphanRow(row) {
|
|
138
|
+
return row.id === "__orphan" && row.path === "__orphan" && row.title === "__orphan";
|
|
139
|
+
}
|
|
140
|
+
function projectUsageRow(row, mapper) {
|
|
141
|
+
const statistics = {
|
|
142
|
+
uniqueChunkCount: row.uniqueChunkCount,
|
|
143
|
+
sharedChunkCount: row.sharedChunkCount,
|
|
144
|
+
uniqueChunkSize: row.uniqueChunkSize,
|
|
145
|
+
sharedChunkSize: row.sharedChunkSize
|
|
146
|
+
};
|
|
147
|
+
if (isOrphanRow(row)) {
|
|
148
|
+
return {
|
|
149
|
+
title: "__orphan",
|
|
150
|
+
id: "__orphan",
|
|
151
|
+
path: "__orphan",
|
|
152
|
+
revision: "1-xxxxx",
|
|
153
|
+
rev: "1",
|
|
154
|
+
revHash: "xxxxx",
|
|
155
|
+
...statistics
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const path = mapper.path(row.path);
|
|
159
|
+
const revision = mapper.revision(row.revision);
|
|
160
|
+
return {
|
|
161
|
+
title: `${path} (${revision})`,
|
|
162
|
+
id: mapper.id(row.id),
|
|
163
|
+
path,
|
|
164
|
+
revision,
|
|
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,
|
|
167
|
+
...statistics
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function projectUsageError(error, mapper) {
|
|
171
|
+
return {
|
|
172
|
+
code: error.code,
|
|
173
|
+
...error.id === void 0 ? {} : { id: mapper.id(error.id) },
|
|
174
|
+
...error.revision === void 0 ? {} : { revision: mapper.revision(error.revision) }
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function projectUsageReport(result, mapper, versions = {}) {
|
|
178
|
+
return {
|
|
179
|
+
version: 1,
|
|
180
|
+
kind: "usage",
|
|
181
|
+
status: result.status,
|
|
182
|
+
nameMode: mapper.mode,
|
|
183
|
+
versions: copyVersions(versions),
|
|
184
|
+
...result.changedDuringRun === void 0 ? {} : { changedDuringRun: result.changedDuringRun },
|
|
185
|
+
rows: result.rows.map((row) => projectUsageRow(row, mapper)),
|
|
186
|
+
errors: result.errors.map((error) => projectUsageError(error, mapper))
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const USAGE_COLUMNS = [
|
|
190
|
+
"title",
|
|
191
|
+
"id",
|
|
192
|
+
"path",
|
|
193
|
+
"rev",
|
|
194
|
+
"revHash",
|
|
195
|
+
"uniqueChunkCount",
|
|
196
|
+
"sharedChunkCount",
|
|
197
|
+
"uniqueChunkSize",
|
|
198
|
+
"sharedChunkSize"
|
|
199
|
+
];
|
|
200
|
+
const USAGE_HEADERS = [
|
|
201
|
+
"Title",
|
|
202
|
+
"Document ID",
|
|
203
|
+
"Path",
|
|
204
|
+
"Revision No",
|
|
205
|
+
"Revision Hash",
|
|
206
|
+
"Unique Chunk Count",
|
|
207
|
+
"Shared Chunk Count",
|
|
208
|
+
"Unique Chunk Size",
|
|
209
|
+
"Shared Chunk Size"
|
|
210
|
+
];
|
|
211
|
+
function escapeTSVField(value) {
|
|
212
|
+
const text = String(value);
|
|
213
|
+
return /[\t\r\n"]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
214
|
+
}
|
|
215
|
+
function formatUsageTSV(projected) {
|
|
216
|
+
const lines = [USAGE_HEADERS.map(escapeTSVField).join(" ")];
|
|
217
|
+
for (const row of projected.rows) {
|
|
218
|
+
lines.push(USAGE_COLUMNS.map((column) => escapeTSVField(row[column])).join(" "));
|
|
219
|
+
}
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
function formatDiagnosticJSON(projected) {
|
|
223
|
+
return JSON.stringify(projected, null, 2);
|
|
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
|
+
}
|
|
230
|
+
function markdownCode(value) {
|
|
231
|
+
const text = value.replace(/\r\n?|\n|\t/g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
232
|
+
const fence = "`".repeat(longestBacktickRun(text) + 1);
|
|
233
|
+
const padding = /^\s|\s$/.test(text) ? " " : "";
|
|
234
|
+
return `${fence}${padding}${text}${padding}${fence}`;
|
|
235
|
+
}
|
|
236
|
+
function renderMaybe(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}`;
|
|
261
|
+
}
|
|
262
|
+
function renderObservation(observation) {
|
|
263
|
+
const metadata = observation.metadata;
|
|
264
|
+
const expectedId = observation.expectedId === observation.id ? "self" : renderMaybe(observation.expectedId);
|
|
265
|
+
const lines = [
|
|
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)}`
|
|
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(" ")}`);
|
|
284
|
+
if (observation.content.unavailableChunks.length > 0) {
|
|
285
|
+
lines.push(
|
|
286
|
+
`- Unavailable chunks: ${observation.content.unavailableChunks.map((chunk) => `${markdownCode(chunk.id)} (${chunk.status})`).join(", ")}`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (observation.comparisons.length > 0) {
|
|
290
|
+
lines.push(
|
|
291
|
+
`- Comparisons: ${observation.comparisons.map((comparison) => `${markdownCode(comparison.against)}: ${comparison.relation}`).join("; ")}`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return lines;
|
|
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
|
+
}
|
|
356
|
+
function formatIntegrityMarkdown(projected) {
|
|
357
|
+
const observations = projected.observations;
|
|
358
|
+
const mismatchCount = observations.filter((observation) => observation.identity !== "consistent").length;
|
|
359
|
+
const conflictCount = observations.filter((observation) => observation.branch === "conflict").length;
|
|
360
|
+
const unavailableContentCount = observations.filter(
|
|
361
|
+
(observation) => observation.content.status === "missing" || observation.content.status === "unreadable"
|
|
362
|
+
).length;
|
|
363
|
+
const unavailableChunkCount = observations.reduce(
|
|
364
|
+
(count, observation) => count + observation.content.unavailableChunks.length,
|
|
365
|
+
0
|
|
366
|
+
);
|
|
367
|
+
const coverageRows = [
|
|
368
|
+
["Local", projected.coverage.local],
|
|
369
|
+
["Remote", projected.coverage.remote]
|
|
370
|
+
];
|
|
371
|
+
const lines = [
|
|
372
|
+
"# Database integrity report",
|
|
373
|
+
"",
|
|
374
|
+
`Status: ${projected.status}`,
|
|
375
|
+
`Names: ${projected.nameMode}`,
|
|
376
|
+
"",
|
|
377
|
+
"A complete status means the declared inspection finished. It does not mean that every finding is healthy.",
|
|
378
|
+
"Equal content applies only within the inspected scope and is not deletion advice.",
|
|
379
|
+
"",
|
|
380
|
+
"## Versions",
|
|
381
|
+
"",
|
|
382
|
+
...projected.versions.commonlib === void 0 ? [] : [`- Commonlib version: ${markdownCode(projected.versions.commonlib)}`],
|
|
383
|
+
...projected.versions.plugin === void 0 ? [] : [`- Plug-in version: ${markdownCode(projected.versions.plugin)}`],
|
|
384
|
+
...projected.versions.application === void 0 ? [] : [`- Application version: ${markdownCode(projected.versions.application)}`],
|
|
385
|
+
...projected.versions.commonlib === void 0 && projected.versions.plugin === void 0 && projected.versions.application === void 0 ? ["No version information was provided."] : [],
|
|
386
|
+
"",
|
|
387
|
+
"## Settings",
|
|
388
|
+
"",
|
|
389
|
+
`- Encryption: ${projected.settings.encrypt ? "enabled" : "disabled"}`,
|
|
390
|
+
`- Path obfuscation: ${projected.settings.usePathObfuscation ? "enabled" : "disabled"}`,
|
|
391
|
+
`- Filename case sensitivity: ${projected.settings.handleFilenameCaseSensitive ? "enabled" : "disabled"}`,
|
|
392
|
+
`- ID derivation version: ${projected.settings.idDerivationVersion}`,
|
|
393
|
+
`- Path normalisation version: ${projected.settings.pathNormalizationVersion}`,
|
|
394
|
+
"",
|
|
395
|
+
"## Scope",
|
|
396
|
+
"",
|
|
397
|
+
projected.scope.remote === "excluded" ? "- Remote database: excluded from this inspection" : "- Remote database: included",
|
|
398
|
+
"- Metadata: normal files and current conflict branches",
|
|
399
|
+
"- Metadata values are decoded and decrypted before inspection.",
|
|
400
|
+
"- Content: suspect groups only",
|
|
401
|
+
"- Size and time measurements use existing reads; absent measurements were not available or not checked.",
|
|
402
|
+
"- History: not checked",
|
|
403
|
+
"- Atomic snapshot: no",
|
|
404
|
+
"",
|
|
405
|
+
"## Coverage",
|
|
406
|
+
"",
|
|
407
|
+
"| Source | Status | Documents | Metadata | Excluded | Conflict branches | Content reads | Changed during run |",
|
|
408
|
+
"| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |"
|
|
409
|
+
];
|
|
410
|
+
for (const [source, coverage] of coverageRows) {
|
|
411
|
+
lines.push(
|
|
412
|
+
`| ${source} | ${coverage.status} | ${coverage.documents} | ${coverage.metadata} | ${coverage.excluded} | ${coverage.conflictBranches} | ${coverage.contentReads} | ${coverage.changedDuringRun} |`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
lines.push(
|
|
416
|
+
"",
|
|
417
|
+
"## Counts",
|
|
418
|
+
"",
|
|
419
|
+
`- Identity mismatches: ${mismatchCount}`,
|
|
420
|
+
`- Conflict observations: ${conflictCount}`,
|
|
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}`,
|
|
424
|
+
`- Unavailable chunks: ${unavailableChunkCount}`,
|
|
425
|
+
`- Errors: ${projected.errors.length}`,
|
|
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),
|
|
444
|
+
"## Findings",
|
|
445
|
+
""
|
|
446
|
+
);
|
|
447
|
+
if (observations.length === 0) lines.push("No observations were returned.");
|
|
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
|
+
}
|
|
458
|
+
if (projected.errors.length > 0) {
|
|
459
|
+
lines.push("## Errors", "");
|
|
460
|
+
for (const error of projected.errors) {
|
|
461
|
+
const details = [
|
|
462
|
+
`source=${error.source}`,
|
|
463
|
+
`reason=${error.reason}`,
|
|
464
|
+
...error.id === void 0 ? [] : [`id=${markdownCode(error.id)}`],
|
|
465
|
+
...error.revision === void 0 ? [] : [`revision=${markdownCode(error.revision)}`],
|
|
466
|
+
...error.path === void 0 ? [] : [`path=${markdownCode(error.path)}`],
|
|
467
|
+
...error.chunkId === void 0 ? [] : [`chunkId=${markdownCode(error.chunkId)}`]
|
|
468
|
+
];
|
|
469
|
+
lines.push(`- ${details.join(", ")}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
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");
|
|
485
|
+
}
|
|
486
|
+
export {
|
|
487
|
+
createDiagnosticNameMapper,
|
|
488
|
+
formatDiagnosticJSON,
|
|
489
|
+
formatIntegrityMarkdown,
|
|
490
|
+
formatUsageTSV,
|
|
491
|
+
parseDiagnosticPathToken,
|
|
492
|
+
projectIntegrityReport,
|
|
493
|
+
projectUsageReport
|
|
494
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { EntryDoc, MetaEntry } from "../common/types.js";
|
|
2
|
+
import type { IPathService } from "../services/base/IService.js";
|
|
3
|
+
export type DiagnosticStatus = "complete" | "partial" | "failed" | "cancelled";
|
|
4
|
+
export type DiagnosticSourceName = "local" | "remote";
|
|
5
|
+
export type DiagnosticNameMode = "original" | "pseudonym";
|
|
6
|
+
export type DiagnosticReason = "source-unavailable" | "unsupported-remote" | "document-unreadable" | "revision-unavailable" | "invalid-metadata" | "unsupported-content" | "chunk-missing" | "chunk-unreadable" | "security-seed-unavailable" | "content-unreadable" | "source-changed" | "storage-changed" | "configuration-changed";
|
|
7
|
+
export interface DiagnosticReadOptions {
|
|
8
|
+
rev?: string;
|
|
9
|
+
conflicts?: boolean;
|
|
10
|
+
revs?: boolean;
|
|
11
|
+
revs_info?: boolean;
|
|
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
|
+
};
|
|
22
|
+
/** Only these reads are available to an analyser; it cannot initialise or repair a database. */
|
|
23
|
+
export interface DiagnosticDatabaseSource {
|
|
24
|
+
info(): Promise<{
|
|
25
|
+
update_seq?: string | number;
|
|
26
|
+
doc_count?: number;
|
|
27
|
+
}>;
|
|
28
|
+
enumerateIds(): AsyncIterable<string>;
|
|
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[]>;
|
|
32
|
+
}
|
|
33
|
+
export interface DiagnosticError {
|
|
34
|
+
source: DiagnosticSourceName | "storage" | "configuration";
|
|
35
|
+
reason: DiagnosticReason;
|
|
36
|
+
id?: string;
|
|
37
|
+
revision?: string;
|
|
38
|
+
path?: string;
|
|
39
|
+
chunkId?: string;
|
|
40
|
+
}
|
|
41
|
+
export type DiagnosticContent = {
|
|
42
|
+
status: "available";
|
|
43
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
44
|
+
mtime?: number;
|
|
45
|
+
reason?: never;
|
|
46
|
+
} | {
|
|
47
|
+
status: "missing" | "unreadable" | "not-checked";
|
|
48
|
+
reason?: DiagnosticReason;
|
|
49
|
+
};
|
|
50
|
+
export interface DiagnosticCoverage {
|
|
51
|
+
status: "complete" | "partial" | "failed" | "not-checked";
|
|
52
|
+
documents: number;
|
|
53
|
+
metadata: number;
|
|
54
|
+
excluded: number;
|
|
55
|
+
conflictBranches: number;
|
|
56
|
+
contentReads: number;
|
|
57
|
+
changedDuringRun: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface DiagnosticObservation {
|
|
60
|
+
reference: string;
|
|
61
|
+
source: DiagnosticSourceName;
|
|
62
|
+
id: string;
|
|
63
|
+
revision?: string;
|
|
64
|
+
/** Exact stored spelling, retained independently of the host's identity normalisation. */
|
|
65
|
+
path: string;
|
|
66
|
+
expectedId?: string;
|
|
67
|
+
identity: "consistent" | "document-id-mismatch" | "namespace-mismatch";
|
|
68
|
+
logicallyDeleted: boolean;
|
|
69
|
+
branch: "winner" | "conflict";
|
|
70
|
+
conflictCount: number;
|
|
71
|
+
pathIsNFC: boolean;
|
|
72
|
+
caseEquivalentPaths: boolean;
|
|
73
|
+
caseEquivalentGroup?: string;
|
|
74
|
+
nfcEquivalentPaths: boolean;
|
|
75
|
+
nfcEquivalentGroup?: string;
|
|
76
|
+
/** Presence of this identity/path group in the other inspected database. */
|
|
77
|
+
otherSourceGroup: "present" | "missing" | "not-checked";
|
|
78
|
+
metadata?: {
|
|
79
|
+
type: string;
|
|
80
|
+
recordedSize?: number;
|
|
81
|
+
chunkReferences: number;
|
|
82
|
+
uniqueChunkReferences: number;
|
|
83
|
+
embeddedChunkReferences: number;
|
|
84
|
+
};
|
|
85
|
+
vault: {
|
|
86
|
+
reference: string;
|
|
87
|
+
status: DiagnosticContent["status"];
|
|
88
|
+
reason?: DiagnosticReason;
|
|
89
|
+
size?: number;
|
|
90
|
+
contentSizeDifference?: number;
|
|
91
|
+
mtimeDifferenceMs?: number;
|
|
92
|
+
};
|
|
93
|
+
content: {
|
|
94
|
+
status: DiagnosticContent["status"];
|
|
95
|
+
reason?: DiagnosticReason;
|
|
96
|
+
chunksRead: number;
|
|
97
|
+
unavailableChunks: {
|
|
98
|
+
id: string;
|
|
99
|
+
status: "missing" | "unreadable";
|
|
100
|
+
}[];
|
|
101
|
+
size?: number;
|
|
102
|
+
recordedSizeDifference?: number;
|
|
103
|
+
};
|
|
104
|
+
comparisons: {
|
|
105
|
+
against: string;
|
|
106
|
+
relation: "equal" | "different" | "unavailable" | "not-checked";
|
|
107
|
+
}[];
|
|
108
|
+
}
|
|
109
|
+
export interface DiagnosticPathEquivalenceGroup {
|
|
110
|
+
reference: string;
|
|
111
|
+
kind: "case" | "nfc";
|
|
112
|
+
members: string[];
|
|
113
|
+
}
|
|
114
|
+
/** A whitelist: connection details, keys, and passphrases have no report field. */
|
|
115
|
+
export interface DiagnosticIdentitySettings {
|
|
116
|
+
encrypt: boolean;
|
|
117
|
+
usePathObfuscation: boolean;
|
|
118
|
+
handleFilenameCaseSensitive: boolean;
|
|
119
|
+
idDerivationVersion: number;
|
|
120
|
+
pathNormalizationVersion: number;
|
|
121
|
+
}
|
|
122
|
+
export interface DatabaseIntegrityResult {
|
|
123
|
+
schemaVersion: 1;
|
|
124
|
+
kind: "integrity";
|
|
125
|
+
status: DiagnosticStatus;
|
|
126
|
+
settings: DiagnosticIdentitySettings;
|
|
127
|
+
coverage: Record<DiagnosticSourceName, DiagnosticCoverage>;
|
|
128
|
+
observations: DiagnosticObservation[];
|
|
129
|
+
pathEquivalenceGroups?: DiagnosticPathEquivalenceGroup[];
|
|
130
|
+
errors: DiagnosticError[];
|
|
131
|
+
/** Scope declarations are part of every report, including a successful one. */
|
|
132
|
+
scope: {
|
|
133
|
+
remote: "included" | "excluded";
|
|
134
|
+
metadata: "normal-files-and-current-conflicts";
|
|
135
|
+
content: "suspect-groups-only";
|
|
136
|
+
history: "not-checked";
|
|
137
|
+
atomicSnapshot: false;
|
|
138
|
+
};
|
|
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
|
+
}
|
|
164
|
+
export interface DatabaseIntegrityOptions {
|
|
165
|
+
local: DiagnosticDatabaseSource;
|
|
166
|
+
/** Defaults to true. False inspects only local evidence and never opens the remote source. */
|
|
167
|
+
includeRemote?: boolean;
|
|
168
|
+
remote?: () => Promise<DiagnosticDatabaseSource & {
|
|
169
|
+
close(): Promise<void>;
|
|
170
|
+
}>;
|
|
171
|
+
remoteUnavailableReason?: "unsupported-remote" | "source-unavailable";
|
|
172
|
+
path: Pick<IPathService, "getPath" | "path2id">;
|
|
173
|
+
settings: DiagnosticIdentitySettings;
|
|
174
|
+
readStorage(path: string): Promise<DiagnosticContent>;
|
|
175
|
+
/** Captures profile and relevant settings identity without exporting it. */
|
|
176
|
+
isConfigurationCurrent(): boolean;
|
|
177
|
+
signal?: AbortSignal;
|
|
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;
|
|
182
|
+
}
|
|
183
|
+
export declare class DiagnosticReadError extends Error {
|
|
184
|
+
readonly reason: DiagnosticReason;
|
|
185
|
+
readonly missing: boolean;
|
|
186
|
+
constructor(reason: DiagnosticReason, missing?: boolean);
|
|
187
|
+
}
|
|
188
|
+
export type InspectedMetadata = MetaEntry & {
|
|
189
|
+
_conflicts?: string[];
|
|
190
|
+
};
|