@vrtmrz/livesync-commonlib 0.1.43 → 0.2.0
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 +57 -6
- package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
- package/dist/API/DirectFileManipulatorV2.js +3 -0
- package/dist/common/configForDoc.d.ts +1 -0
- package/dist/common/configForDoc.js +11 -1
- package/dist/common/models/db.definition.d.ts +1 -0
- package/dist/common/models/setting.const.preferred.js +1 -3
- package/dist/diagnostics/couchdb.js +86 -13
- package/dist/diagnostics/index.d.ts +2 -2
- package/dist/diagnostics/index.js +2 -0
- package/dist/diagnostics/integrity.js +385 -158
- package/dist/diagnostics/names.d.ts +16 -0
- package/dist/diagnostics/names.js +50 -0
- package/dist/diagnostics/reports.d.ts +13 -10
- package/dist/diagnostics/reports.js +191 -50
- package/dist/diagnostics/types.d.ts +64 -4
- package/dist/diagnostics/usage.d.ts +4 -0
- package/dist/diagnostics/usage.js +158 -54
- package/dist/managers/EntryManager/EntryManagerImpls.js +3 -2
- package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
- package/dist/pouchdb/StreamingFetch.d.ts +5 -1
- package/dist/pouchdb/StreamingFetch.js +529 -65
- package/dist/replication/RemoteResource.d.ts +23 -2
- package/dist/replication/RemoteResource.js +6 -2
- package/dist/replication/couchdb/LiveSyncReplicator.d.ts +8 -0
- package/dist/replication/couchdb/LiveSyncReplicator.js +132 -1
- package/dist/replication/couchdb/fastForward.d.ts +19 -0
- package/dist/replication/couchdb/fastForward.js +119 -0
- package/dist/replication/index.d.ts +3 -1
- package/dist/replication/joinedDevices.d.ts +25 -0
- package/dist/replication/joinedDevices.js +123 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
- package/dist/replication/journal/JournalSyncCore.js +11 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
- package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
- package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
- package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
- package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
- package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
- package/dist/serviceModules/Rebuilder.js +41 -15
- package/dist/services/BrowserServices.d.ts +4 -2
- package/dist/services/BrowserServices.js +2 -5
- package/dist/services/HeadlessServices.d.ts +3 -0
- package/dist/services/HeadlessServices.js +2 -5
- package/dist/services/ServiceHub.d.ts +4 -4
- package/dist/services/base/IService.d.ts +10 -18
- package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
- package/dist/services/base/SettingService.d.ts +8 -18
- package/dist/services/base/SettingService.js +28 -46
- package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
- package/dist/services/implements/base/UIService.d.ts +2 -0
- package/dist/services/implements/base/UIService.js +5 -0
- package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
- package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
- package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
- package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
- package/dist/string_and_binary/chunks.js +7 -5
- package/dist/uiCommands.d.ts +26 -0
- package/dist/uiCommands.js +57 -0
- package/dist/worker/bgWorker.js +1 -1
- package/docs/database-diagnostics.md +45 -0
- package/docs/development.md +15 -3
- package/docs/proven-in-use.md +45 -5
- package/docs/releasing.md +20 -13
- package/docs/settings-lifecycle.md +37 -5
- package/package.json +13 -17
- package/dist/services/base/ConfigService.d.ts +0 -7
- package/dist/services/base/ConfigService.js +0 -6
- package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
- package/dist/services/implements/browser/ConfigServiceBrowserCompat.js +0 -23
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
218
|
-
const
|
|
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 ? "
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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";
|