@vrtmrz/livesync-commonlib 0.1.42 → 0.1.43

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,241 @@
1
+ function asRecord(value) {
2
+ return value;
3
+ }
4
+ function getRevisionNumber(revision) {
5
+ const generation = revision.split("-")[0];
6
+ return `${generation ? Number.parseInt(generation) : 0}`;
7
+ }
8
+ function getRevisionHash(revision) {
9
+ const hash = revision.split("-")[1];
10
+ if (hash === void 0) throw new Error("invalid revision");
11
+ return hash.substring(0, 6);
12
+ }
13
+ function getRevisionInfos(value) {
14
+ if (!Array.isArray(value)) return [];
15
+ return value;
16
+ }
17
+ function getChildren(value) {
18
+ const children = value ?? [];
19
+ if (!Array.isArray(children) || !children.every((child) => typeof child === "string")) {
20
+ throw new Error("invalid chunk references");
21
+ }
22
+ return new Set(children);
23
+ }
24
+ function getStringField(value) {
25
+ if (typeof value !== "string") throw new Error("invalid document field");
26
+ return value;
27
+ }
28
+ async function analyseDatabaseUsage(options) {
29
+ const { source, signal, onProgress } = options;
30
+ const errors = [];
31
+ const documentMap = /* @__PURE__ */ new Map();
32
+ const chunkMap = /* @__PURE__ */ new Map();
33
+ const progress = { idsProcessed: 0, revisionsProcessed: 0 };
34
+ let startUpdateSeq;
35
+ let endUpdateSeq;
36
+ let enumerationFailed = false;
37
+ const reportProgress = () => onProgress?.({ ...progress });
38
+ const isCancelled = () => signal?.aborted === true;
39
+ let cancelled = isCancelled();
40
+ if (!cancelled) {
41
+ try {
42
+ const info = await source.info();
43
+ startUpdateSeq = info.update_seq;
44
+ if (startUpdateSeq === void 0) errors.push({ code: "start_update_seq_unavailable" });
45
+ } catch {
46
+ errors.push({ code: "start_info_read_failed" });
47
+ }
48
+ if (isCancelled()) cancelled = true;
49
+ }
50
+ try {
51
+ if (!cancelled && !isCancelled()) {
52
+ for await (const id of source.enumerateIds()) {
53
+ if (isCancelled()) {
54
+ cancelled = true;
55
+ break;
56
+ }
57
+ let revisionInfos = [];
58
+ let revisionList;
59
+ let revisionListReadFailed = false;
60
+ try {
61
+ revisionList = await source.get(id, { revs: true, revs_info: true, conflicts: true });
62
+ } catch {
63
+ if (!isCancelled()) {
64
+ errors.push({ code: "revision_list_read_failed", id });
65
+ revisionListReadFailed = true;
66
+ }
67
+ }
68
+ if (isCancelled()) {
69
+ cancelled = true;
70
+ break;
71
+ }
72
+ if (revisionList) revisionInfos = getRevisionInfos(asRecord(revisionList)._revs_info);
73
+ else if (!revisionListReadFailed) {
74
+ errors.push({ code: "revision_list_read_failed", id });
75
+ }
76
+ progress.idsProcessed++;
77
+ reportProgress();
78
+ for (const revisionInfo of revisionInfos) {
79
+ if (isCancelled()) {
80
+ cancelled = true;
81
+ break;
82
+ }
83
+ if (revisionInfo.status !== "available") continue;
84
+ const revision = typeof revisionInfo.rev === "string" ? revisionInfo.rev : void 0;
85
+ if (revision === void 0) {
86
+ errors.push({ code: "revision_document_invalid", id });
87
+ continue;
88
+ }
89
+ progress.revisionsProcessed++;
90
+ let rawDocument;
91
+ try {
92
+ rawDocument = await source.get(id, { rev: revision });
93
+ } catch {
94
+ if (isCancelled()) {
95
+ cancelled = true;
96
+ reportProgress();
97
+ break;
98
+ }
99
+ errors.push({ code: "revision_read_failed", id, revision });
100
+ reportProgress();
101
+ continue;
102
+ }
103
+ if (isCancelled()) {
104
+ cancelled = true;
105
+ reportProgress();
106
+ break;
107
+ }
108
+ if (!rawDocument) {
109
+ errors.push({ code: "revision_not_found", id, revision });
110
+ reportProgress();
111
+ continue;
112
+ }
113
+ try {
114
+ const document = asRecord(rawDocument);
115
+ if ("children" in document) {
116
+ const documentId = getStringField(document._id);
117
+ const documentRevision = getStringField(document._rev);
118
+ const path = getStringField(document.path);
119
+ const chunks = getChildren(document.children);
120
+ const revisions = documentMap.get(documentId) ?? [];
121
+ revisions.push({
122
+ id: documentId,
123
+ revision: documentRevision,
124
+ path,
125
+ chunks,
126
+ uniqueChunks: /* @__PURE__ */ new Set(),
127
+ sharedChunks: /* @__PURE__ */ new Set()
128
+ });
129
+ documentMap.set(documentId, revisions);
130
+ } else if (document.type === "leaf") {
131
+ const chunkId = getStringField(document._id);
132
+ if (!chunkMap.has(chunkId) && !document._deleted) {
133
+ if (typeof document.data !== "string") throw new Error("invalid chunk data");
134
+ chunkMap.set(chunkId, { length: document.data.length, refCount: 0 });
135
+ }
136
+ }
137
+ } catch {
138
+ errors.push({ code: "revision_document_invalid", id, revision });
139
+ }
140
+ reportProgress();
141
+ }
142
+ if (isCancelled()) cancelled = true;
143
+ if (cancelled) break;
144
+ }
145
+ } else if (isCancelled()) {
146
+ cancelled = true;
147
+ }
148
+ } catch {
149
+ if (isCancelled()) cancelled = true;
150
+ else {
151
+ enumerationFailed = true;
152
+ errors.push({ code: "id_enumeration_failed" });
153
+ }
154
+ }
155
+ for (const revisions of documentMap.values()) {
156
+ for (const document of revisions) {
157
+ for (const chunkId of document.chunks) {
158
+ const chunk = chunkMap.get(chunkId);
159
+ if (!chunk) continue;
160
+ if (chunk.refCount === 0) document.uniqueChunks.add(chunkId);
161
+ else document.sharedChunks.add(chunkId);
162
+ chunk.refCount++;
163
+ }
164
+ }
165
+ }
166
+ const totalSize = (ids) => {
167
+ let size = 0;
168
+ for (const id of ids) size += chunkMap.get(id)?.length ?? 0;
169
+ return size;
170
+ };
171
+ const rows = [];
172
+ for (const revisions of documentMap.values()) {
173
+ for (const document of revisions) {
174
+ const revisionNumber = getRevisionNumber(document.revision);
175
+ let revisionHash;
176
+ try {
177
+ revisionHash = getRevisionHash(document.revision);
178
+ } catch {
179
+ errors.push({ code: "revision_document_invalid", id: document.id, revision: document.revision });
180
+ continue;
181
+ }
182
+ rows.push({
183
+ title: `${document.path} (${document.revision})`,
184
+ id: document.id,
185
+ path: document.path,
186
+ revision: document.revision,
187
+ rev: revisionNumber,
188
+ revHash: revisionHash,
189
+ uniqueChunkCount: document.uniqueChunks.size,
190
+ sharedChunkCount: document.sharedChunks.size,
191
+ uniqueChunkSize: totalSize(document.uniqueChunks),
192
+ sharedChunkSize: totalSize(document.sharedChunks)
193
+ });
194
+ }
195
+ }
196
+ const orphanChunks = [...chunkMap.entries()].filter(([, chunk]) => chunk.refCount === 0);
197
+ rows.push({
198
+ title: "__orphan",
199
+ id: "__orphan",
200
+ path: "__orphan",
201
+ revision: "1-xxxxx",
202
+ rev: "1",
203
+ revHash: "xxxxx",
204
+ uniqueChunkCount: orphanChunks.length,
205
+ sharedChunkCount: 0,
206
+ uniqueChunkSize: orphanChunks.reduce((size, [, chunk]) => size + chunk.length, 0),
207
+ sharedChunkSize: 0
208
+ });
209
+ if (!cancelled && !isCancelled()) {
210
+ try {
211
+ const info = await source.info();
212
+ endUpdateSeq = info.update_seq;
213
+ if (endUpdateSeq === void 0) errors.push({ code: "end_update_seq_unavailable" });
214
+ } catch {
215
+ errors.push({ code: "end_info_read_failed" });
216
+ }
217
+ }
218
+ if (isCancelled()) cancelled = true;
219
+ const changedDuringRun = startUpdateSeq === void 0 || endUpdateSeq === void 0 ? void 0 : startUpdateSeq !== endUpdateSeq;
220
+ const snapshotUncertain = startUpdateSeq === void 0 || endUpdateSeq === void 0;
221
+ const hasDocumentRows = rows.length > 1;
222
+ let status;
223
+ if (cancelled) status = "cancelled";
224
+ else if (changedDuringRun === true || snapshotUncertain) status = "partial";
225
+ else if (enumerationFailed && !hasDocumentRows) status = "failed";
226
+ else if (errors.length > 0) status = "partial";
227
+ else status = "complete";
228
+ return {
229
+ version: 1,
230
+ kind: "usage",
231
+ status,
232
+ ...startUpdateSeq === void 0 ? {} : { startUpdateSeq },
233
+ ...endUpdateSeq === void 0 ? {} : { endUpdateSeq },
234
+ ...changedDuringRun === void 0 ? {} : { changedDuringRun },
235
+ rows,
236
+ errors
237
+ };
238
+ }
239
+ export {
240
+ analyseDatabaseUsage
241
+ };
@@ -1,12 +1,14 @@
1
+ import { Logger } from "../common/logger.js";
1
2
  import { type EntryDoc, type AnyEntry, type EntryLeaf, type DocumentID, type E2EEAlgorithm } from "../common/types.js";
2
3
  import { encryptWorker, decryptWorker, encryptHKDFWorker, decryptHKDFWorker } from "#worker";
3
4
  export declare const encrypt: typeof encryptWorker;
4
5
  export declare const decrypt: typeof decryptWorker;
5
6
  export declare const encryptHKDF: typeof encryptHKDFWorker;
6
7
  export declare const decryptHKDF: typeof decryptHKDFWorker;
8
+ export declare function isEncryptedMeta<T extends AnyEntry>(doc: T): boolean;
7
9
  export declare let preprocessOutgoing: (doc: AnyEntry | EntryLeaf) => Promise<AnyEntry | EntryLeaf>;
8
10
  export declare let preprocessIncoming: (doc: EntryDoc) => Promise<EntryDoc>;
9
- export declare function getConfiguredFunctionsForEncryption(passphrase: string, useDynamicIterationCount: boolean, migrationDecrypt: boolean, getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>, algorithm: E2EEAlgorithm, encryptInternalMetadata?: boolean): {
11
+ export declare function getConfiguredFunctionsForEncryption(passphrase: string, useDynamicIterationCount: boolean, migrationDecrypt: boolean, getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>, algorithm: E2EEAlgorithm, encryptInternalMetadata?: boolean, log?: typeof Logger): {
10
12
  incoming: (doc: AnyEntry | EntryLeaf) => Promise<AnyEntry | EntryLeaf>;
11
13
  outgoing: (doc: EntryDoc) => Promise<AnyEntry | EntryLeaf>;
12
14
  };
@@ -32,20 +32,20 @@ function getEncryptionVersion(data) {
32
32
  }
33
33
  return EncryptionVersions.UNENCRYPTED;
34
34
  }
35
- async function tryDecryptV1AsFallback(encryptedData, passphrase, useDynamicIterationCount) {
35
+ async function tryDecryptV1AsFallback(encryptedData, passphrase, useDynamicIterationCount, log = Logger) {
36
36
  try {
37
37
  return await decrypt(encryptedData, passphrase, useDynamicIterationCount);
38
38
  } catch (ex) {
39
39
  try {
40
- Logger(
40
+ log(
41
41
  "Failed to decrypt with V1 method. Fallback to disable useDynamicIterationCount.",
42
42
  LOG_LEVEL_VERBOSE
43
43
  );
44
- Logger(ex, LOG_LEVEL_VERBOSE);
44
+ log(ex, LOG_LEVEL_VERBOSE);
45
45
  return await decrypt(encryptedData, passphrase, false);
46
46
  } catch (ex2) {
47
- Logger("Completely failed to decrypt with V1 method.", LOG_LEVEL_VERBOSE);
48
- Logger(ex2, LOG_LEVEL_VERBOSE);
47
+ log("Completely failed to decrypt with V1 method.", LOG_LEVEL_VERBOSE);
48
+ log(ex2, LOG_LEVEL_VERBOSE);
49
49
  return false;
50
50
  }
51
51
  }
@@ -153,7 +153,7 @@ async function incomingEncryptHKDF(doc, passphrase, useDynamicIterationCount, ge
153
153
  }
154
154
  return saveDoc;
155
155
  }
156
- async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount, getPBKDF2Salt) {
156
+ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount, getPBKDF2Salt, log) {
157
157
  const loadDoc = {
158
158
  ...doc
159
159
  };
@@ -165,21 +165,26 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
165
165
  loadDoc.data = await decryptHKDF(loadDoc.data, passphrase, pbkdf2salt);
166
166
  delete loadDoc.e_;
167
167
  } else if (encryptionVersion === EncryptionVersions.ENCRYPTED) {
168
- const decryptedData = await tryDecryptV1AsFallback(loadDoc.data, passphrase, useDynamicIterationCount);
168
+ const decryptedData = await tryDecryptV1AsFallback(
169
+ loadDoc.data,
170
+ passphrase,
171
+ useDynamicIterationCount,
172
+ log
173
+ );
169
174
  if (decryptedData === false) {
170
- Logger(MESSAGE_FALLBACK_DECRYPT_FAILED, LOG_LEVEL_NOTICE);
175
+ log(MESSAGE_FALLBACK_DECRYPT_FAILED, LOG_LEVEL_NOTICE);
171
176
  throw new Error(MESSAGE_FALLBACK_DECRYPT_FAILED);
172
177
  }
173
178
  loadDoc.data = decryptedData;
174
179
  delete loadDoc.e_;
175
180
  } else if (encryptionVersion === EncryptionVersions.UNENCRYPTED) {
176
181
  } else {
177
- Logger("Unknown encryption version. Cannot decrypt.", LOG_LEVEL_NOTICE);
182
+ log("Unknown encryption version. Cannot decrypt.", LOG_LEVEL_NOTICE);
178
183
  throw new Error("Unknown encryption version. Cannot decrypt.");
179
184
  }
180
185
  } catch (ex) {
181
- Logger(DECRYPTION_HKDF_FAILED, LOG_LEVEL_NOTICE);
182
- Logger(ex);
186
+ log(DECRYPTION_HKDF_FAILED, LOG_LEVEL_NOTICE);
187
+ log(ex);
183
188
  throw ex;
184
189
  }
185
190
  }
@@ -192,16 +197,16 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
192
197
  loadDoc[key] = metadata[key];
193
198
  }
194
199
  } catch (ex) {
195
- Logger(`${DECRYPTION_HKDF_FAILED} on Path`, LOG_LEVEL_NOTICE);
196
- Logger(ex);
200
+ log(`${DECRYPTION_HKDF_FAILED} on Path`, LOG_LEVEL_NOTICE);
201
+ log(ex);
197
202
  throw ex;
198
203
  }
199
204
  } else {
200
205
  const path = getPath(loadDoc);
201
206
  if (isPathProbablyObfuscated(path)) {
202
- const decryptedPath = await tryDecryptV1AsFallback(path, passphrase, useDynamicIterationCount);
207
+ const decryptedPath = await tryDecryptV1AsFallback(path, passphrase, useDynamicIterationCount, log);
203
208
  if (decryptedPath === false) {
204
- Logger(`${MESSAGE_FALLBACK_DECRYPT_FAILED} on Path`, LOG_LEVEL_NOTICE);
209
+ log(`${MESSAGE_FALLBACK_DECRYPT_FAILED} on Path`, LOG_LEVEL_NOTICE);
205
210
  throw new Error(MESSAGE_FALLBACK_DECRYPT_FAILED);
206
211
  }
207
212
  loadDoc.path = decryptedPath;
@@ -215,7 +220,8 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
215
220
  const decryptedEden = await tryDecryptV1AsFallback(
216
221
  loadDoc.eden[EDEN_ENCRYPTED_KEY].data,
217
222
  passphrase,
218
- useDynamicIterationCount
223
+ useDynamicIterationCount,
224
+ log
219
225
  );
220
226
  if (decryptedEden === false) throw new Error(MESSAGE_FALLBACK_DECRYPT_FAILED);
221
227
  readEden = {
@@ -224,8 +230,8 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
224
230
  };
225
231
  edenDecrypted = true;
226
232
  } catch (ex) {
227
- Logger(`${DECRYPTION_FALLBACK_FAILED} on Eden`, LOG_LEVEL_NOTICE);
228
- Logger(ex);
233
+ log(`${DECRYPTION_FALLBACK_FAILED} on Eden`, LOG_LEVEL_NOTICE);
234
+ log(ex);
229
235
  throw ex;
230
236
  }
231
237
  }
@@ -243,8 +249,8 @@ async function outgoingDecryptHKDF(doc, migrationDecrypt, decrypted, passphrase,
243
249
  };
244
250
  edenDecrypted = true;
245
251
  } catch (ex) {
246
- Logger(`${DECRYPTION_HKDF_FAILED} on Eden`, LOG_LEVEL_NOTICE);
247
- Logger(ex);
252
+ log(`${DECRYPTION_HKDF_FAILED} on Eden`, LOG_LEVEL_NOTICE);
253
+ log(ex);
248
254
  throw ex;
249
255
  }
250
256
  }
@@ -296,7 +302,7 @@ async function incomingEncryptV1(doc, passphrase, useDynamicIterationCount) {
296
302
  }
297
303
  return saveDoc;
298
304
  }
299
- async function outgoingDecryptV1(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount) {
305
+ async function outgoingDecryptV1(doc, migrationDecrypt, decrypted, passphrase, useDynamicIterationCount, log) {
300
306
  const loadDoc = {
301
307
  ...doc
302
308
  };
@@ -361,15 +367,15 @@ async function outgoingDecryptV1(doc, migrationDecrypt, decrypted, passphrase, u
361
367
  if (migrationDecrypt && ex2 instanceof SyntaxError) {
362
368
  return loadDoc;
363
369
  }
364
- Logger("Decryption failed.", LOG_LEVEL_NOTICE);
365
- Logger(ex2, LOG_LEVEL_VERBOSE);
366
- Logger(`id:${loadDoc._id}-${loadDoc._rev?.substring(0, 10)}`, LOG_LEVEL_VERBOSE);
370
+ log("Decryption failed.", LOG_LEVEL_NOTICE);
371
+ log(ex2, LOG_LEVEL_VERBOSE);
372
+ log(`id:${loadDoc._id}-${loadDoc._rev?.substring(0, 10)}`, LOG_LEVEL_VERBOSE);
367
373
  throw ex2;
368
374
  }
369
375
  } else {
370
- Logger("Decryption failed.", LOG_LEVEL_NOTICE);
371
- Logger(ex, LOG_LEVEL_VERBOSE);
372
- Logger(`id:${loadDoc._id}-${loadDoc._rev?.substring(0, 10)}`, LOG_LEVEL_VERBOSE);
376
+ log("Decryption failed.", LOG_LEVEL_NOTICE);
377
+ log(ex, LOG_LEVEL_VERBOSE);
378
+ log(`id:${loadDoc._id}-${loadDoc._rev?.substring(0, 10)}`, LOG_LEVEL_VERBOSE);
373
379
  throw ex;
374
380
  }
375
381
  }
@@ -382,7 +388,7 @@ let preprocessOutgoing = async (doc) => {
382
388
  let preprocessIncoming = async (doc) => {
383
389
  return await Promise.resolve(doc);
384
390
  };
385
- function getConfiguredFunctionsForEncryption(passphrase, useDynamicIterationCount, migrationDecrypt, getPBKDF2Salt, algorithm, encryptInternalMetadata = false) {
391
+ function getConfiguredFunctionsForEncryption(passphrase, useDynamicIterationCount, migrationDecrypt, getPBKDF2Salt, algorithm, encryptInternalMetadata = false, log = Logger) {
386
392
  const decryptedCache = /* @__PURE__ */ new Map();
387
393
  const incoming = (doc) => algorithm === E2EEAlgorithms.V2 ? incomingEncryptHKDF(doc, passphrase, useDynamicIterationCount, getPBKDF2Salt, encryptInternalMetadata) : incomingEncryptV1(doc, passphrase, useDynamicIterationCount);
388
394
  const outgoing = (doc) => algorithm !== E2EEAlgorithms.ForceV1 ? outgoingDecryptHKDF(
@@ -391,8 +397,9 @@ function getConfiguredFunctionsForEncryption(passphrase, useDynamicIterationCoun
391
397
  decryptedCache,
392
398
  passphrase,
393
399
  useDynamicIterationCount,
394
- getPBKDF2Salt
395
- ) : outgoingDecryptV1(doc, migrationDecrypt, decryptedCache, passphrase, useDynamicIterationCount);
400
+ getPBKDF2Salt,
401
+ log
402
+ ) : outgoingDecryptV1(doc, migrationDecrypt, decryptedCache, passphrase, useDynamicIterationCount, log);
396
403
  return {
397
404
  incoming,
398
405
  outgoing
@@ -459,6 +466,7 @@ export {
459
466
  encrypt,
460
467
  encryptHKDF,
461
468
  getConfiguredFunctionsForEncryption,
469
+ isEncryptedMeta,
462
470
  preprocessIncoming,
463
471
  preprocessOutgoing,
464
472
  shouldDecryptEden,
@@ -1,6 +1,7 @@
1
1
  import { type DocumentID, type FilePathWithPrefix, type FilePathWithPrefixLC, type MetaEntry, type UXFileInfoStub, type ObsidianLiveSyncSettings, type LOG_LEVEL } from "../common/types.js";
2
2
  import { type LogFunction } from "../services/lib/logUtils.js";
3
3
  import type { NecessaryServices } from "../interfaces/ServiceModule.js";
4
+ import type { IPathService } from "../services/base/IService.js";
4
5
  import { UnresolvedErrorManager } from "../services/base/UnresolvedErrorManager.js";
5
6
  import { VaultScanResults, type VaultScanResult } from "../services/base/VaultScanResult.js";
6
7
  export { VaultScanResults };
@@ -105,7 +106,11 @@ export declare function getMetadataDocumentNamespace(value: string): MetadataDoc
105
106
  * separate consistent document for the same logical path may still proceed
106
107
  * through ordinary path-based handling.
107
108
  */
108
- export declare function inspectMetadataDocumentIdentity(host: NecessaryServices<"path", never>, doc: MetaEntry): Promise<MetadataDocumentIdentityInspection>;
109
+ export declare function inspectMetadataDocumentIdentity(host: {
110
+ services: {
111
+ path: Pick<IPathService, "getPath" | "path2id">;
112
+ };
113
+ }, doc: MetaEntry): Promise<MetadataDocumentIdentityInspection>;
109
114
  /**
110
115
  * Enumerate Metadata from its actual local document IDs and report only the
111
116
  * entries which ordinary path-based inspection cannot address.
@@ -139,7 +144,11 @@ export declare function collectDeletedFiles(host: NecessaryServices<"setting" |
139
144
  * @param doc Meta entry document
140
145
  * @returns Path string
141
146
  */
142
- export declare function getPathFromEntry(host: NecessaryServices<"path", never>, doc: MetaEntry): FilePathWithPrefix;
147
+ export declare function getPathFromEntry(host: {
148
+ services: {
149
+ path: Pick<IPathService, "getPath">;
150
+ };
151
+ }, doc: MetaEntry): FilePathWithPrefix;
143
152
  /**
144
153
  * Synchronise a single file between database and storage based on freshness comparison.
145
154
  * @param host Services container
@@ -183,7 +183,7 @@ Please enable them from the settings screen after setup is complete.`,
183
183
  await assertUniqueStoragePathIdentities(selected, this.path);
184
184
  if (databaseReady) {
185
185
  await assertMetadataPathIdentities(
186
- this.database.localDatabase.findAllDocs(),
186
+ this.database.localDatabase.findAllDocs({ conflicts: true }),
187
187
  this.path,
188
188
  requireMatchingIds
189
189
  );
@@ -0,0 +1,24 @@
1
+ ---
2
+ date: 2026-10-19
3
+ commonlib_version: '0.1.43'
4
+ livesync_version: '1.0.38'
5
+ status: unreleased
6
+ ---
7
+
8
+ # Database diagnostics
9
+
10
+ The focused `/diagnostics` entry supplies read-only database usage analysis and integrity inspection. Hosts choose original names or temporary pseudonyms before each run, provide progress and cancellation controls, and present the resulting reports. Analysis uses the original identifiers; name projection happens afterwards.
11
+
12
+ Usage retains the existing available-revision selection and incremental chunk accounting. The first processed reference counts a chunk as unique; later references count it as shared. Sizes count JavaScript string units, as in the existing report. These columns do not measure physical database size or establish whether a chunk can be deleted.
13
+
14
+ Integrity inspection enumerates actual stored document IDs, including logically deleted normal-file Metadata and current conflict branches. It uses the same identity classifier as the existing Metadata inspector. It reads content only for groups with identity discrepancies, conflicting branches, duplicate path claims, or differences between the inspected local and remote Metadata. Content comparisons use reconstructed bytes, including the Vault file where available. Missing, unreadable, and uninspected content remain distinct outcomes. Equal content within this scope does not establish that an entry is safe to delete.
15
+
16
+ The first implementation supports the local database and CouchDB. Internal-file, customisation, and plug-in storage Metadata, older revision history, and other remote backends are explicitly outside integrity inspection. Usage continues to cover the revisions available to its local source. A remote failure retains the local findings and marks the integrity report partial.
17
+
18
+ The CouchDB reader only sends GET requests. It reads existing synchronisation parameters when decryption needs a Security Seed and never creates missing parameters, initialises a database, or invokes replication admission. Local content reads do not fetch or cache remote Chunks. Owned requests are cancelled and released when the run ends; host-native request adapters may finish an already dispatched request after cancellation, but its result is discarded.
19
+
20
+ Database update sequences and the host's configuration identity are checked again before completion. Storage readers must detect changes while reading a file. These observations can detect concurrent changes; they do not provide an atomic snapshot or prevent a file from changing later.
21
+
22
+ Reports project a whitelist of facts. Pseudonyms preserve exact path equality across sources and keep document, revision, and Chunk identifiers separate. Case and Unicode normalisation facts are computed before projection. Maps are local to one run and are not persisted. Original-name mode still excludes credentials, connection addresses, encryption keys, raw errors, and file contents. JSON and Markdown use the same projected integrity result; TSV uses the same name mapper for Usage.
23
+
24
+ Hosts may pass Commonlib, plug-in, and application versions to either report projector through its optional third argument. Only those three version fields are retained. Integrity reports also retain the five identity-related settings used for the inspection; credentials and the active connection configuration remain outside the report.
@@ -8,6 +8,7 @@ The source tree is larger than the supported package surface. Consumers may impo
8
8
 
9
9
  - `src/index.ts`, `src/context.ts`, `src/settings.ts`, `src/remoteConfigurations.ts`, `src/p2p/index.ts`, `src/platform/browser/index.ts`, and `src/platform/node/index.ts` define the focused entries;
10
10
  - `src/replication/index.ts` defines the focused provider-capability and interaction contract for hosts which compose replication;
11
+ - `src/diagnostics/index.ts` defines read-only Usage and integrity inspection, report projection, and formatting; hosts own the run dialogue and output destination;
11
12
  - `src/rpc/index.ts` defines a transitional entry for the existing LiveSync P2P composition, not a stable Commonlib 1.0 contract;
12
13
  - `_tools/build-package.mjs` compiles those entries and creates the publishable manifest under `.package`;
13
14
  - `docs/migration/downstream-imports.json` is the reviewed inventory from which explicit `compat/*` exports are generated;
@@ -8,6 +8,8 @@ The code paths below identify the maintained Self-hosted LiveSync composition po
8
8
 
9
9
  ## Obsidian plug-in
10
10
 
11
+ Unreleased [database diagnostics](database-diagnostics.md) tests cover preserved Usage accounting, normal-file identity classification, current conflict branches, byte comparisons, embedded Chunks, missing and unreadable content, cancellation, concurrent changes, and consistent pseudonyms. Real in-memory PouchDB tests verify pagination and conflict revision reads while retaining the original database handle. A CouchDB 3.5.0 integration test verifies GET-only access, encrypted Metadata and Chunk reads with an existing Security Seed, missing-seed failure without initialisation, and unchanged database update sequences and Seed documents. Focused tests also reject ciphertext which remains undecoded after an ID mismatch. Self-hosted LiveSync owns the serviceFeature, run-time name choice, dialogue lifecycle, and report copy and download controls. Its real Obsidian 1.12.7 test on Linux verifies cancellation before a run, both name modes, the nine-column Usage report, copy and download payloads, local integrity findings when CouchDB is unavailable, and unchanged settings, database update sequences, and Vault file names. Mobile emulation verifies the choice dialogue's viewport, safe-area, and touch-target layout; native iOS and Android behaviour remains unverified.
12
+
11
13
  Unreleased content-evidence tests replicate 99, 100, and 599 updates between real in-memory PouchDB databases, including a receiver with `revs_limit: 100`. They verify unchanged reflection after the recorded revision is pruned, including a recreated handler, and preserve same-size edits, intentional reverts, and records without valid content evidence. Separate tests verify legacy upgrades and changes during hashing. These are package-owned tests; they do not establish Windows, mobile, or remote CouchDB behaviour.
12
14
 
13
15
  The exact local packed candidate also passes Self-hosted LiveSync 1.0.37's checks, unit tests, application builds, and CLI E2E. Its focused real-Obsidian `stale-file-restart` scenario prunes the recorded revision before restarting, verifies unchanged reflection and legacy-record upgrades, and preserves an unknown-origin file. The Review Harness exercises the exported hash helper in desktop Obsidian. Maintainer-supplied Android and iOS reports additionally verify the helper and its benchmark on those devices; these timing measurements do not establish complete mobile synchronisation behaviour or peak memory use.
@@ -92,7 +94,9 @@ The CLI extends the same neutral context with its database path and injected `St
92
94
 
93
95
  The CLI filesystem composition receives a configured base directory and constructs the rooted Node storage adapter in `src/apps/cli/adapters/NodeFileSystemAdapter.ts`. Commonlib's shared storage result table checks the focused root contract; the consumer adds its file cache, Vault-shaped operations, case handling, and command policy.
94
96
 
95
- The unreleased [Unicode path identity contract](unicode-path-identity.md) adds opt-in NFC document IDs for CLI and Obsidian use with CouchDB. Commonlib tests exercise real in-memory Metadata lookup across different host normalisers. CLI temporary-filesystem tests cover resolution, updates, deletions, unchanged physical spellings, and ambiguous NFC paths. File-reflection tests preserve NFC-equivalent physical names while retaining case-only rename behaviour. With the packed candidate installed, the Linux CLI-to-real-Obsidian scenario verifies creation, remote updates, and daemon-applied deletions with and without E2EE and Path Obfuscation. A separate run migrates two legacy CLI databases with Rebuild and Fetch before the Obsidian round trip, preserving both NFD physical names and replacing the remote document ID. These checks do not establish native macOS or iOS behaviour.
97
+ The [Unicode path identity contract](unicode-path-identity.md), introduced in Commonlib `0.1.42`, provides opt-in NFC document IDs for CLI and Obsidian use with CouchDB. Commonlib tests exercise real in-memory Metadata lookup across different host normalisers. CLI temporary-filesystem tests cover resolution, updates, deletions, unchanged physical spellings, and ambiguous NFC paths. File-reflection tests preserve NFC-equivalent physical names while retaining case-only rename behaviour. With the packed candidate installed, the Linux CLI-to-real-Obsidian scenario verifies creation, remote updates, and daemon-applied deletions with and without E2EE and Path Obfuscation. A separate run migrates two legacy CLI databases with Rebuild and Fetch before the Obsidian round trip, preserving both NFD physical names and replacing the remote document ID. These checks do not establish native macOS or iOS behaviour.
98
+
99
+ The unreleased reconstruction refinement excludes unconflicted logical deletions only when replacing the local database. Rebuilder tests exercise real PouchDB enumeration before the reset boundary across Rebuild, Standard Fetch, and Fast Fetch. Real CouchDB tests cover deletion propagation, fully tombstoned documents, unresolved conflict leaves, and a stale local copy after a remote deletion. Non-deleted duplicates and unresolved conflicts still stop reconstruction, and remote-only Rebuild retains its ID check. These package-owned tests establish preflight decisions; the maintained downstream migration scenario separately owns complete CLI reconstruction and real-Obsidian Fast Fetch with the exact packed candidate.
96
100
 
97
101
  Unreleased Node timestamp tests verify that `stat` and `trystat` retain an available birth time when change time advances, fall back when birth time is zero, and return integer milliseconds. Packed-package checks exercise the shared `getNodeFileCreationTime` export. With the exact locally packed candidate installed, downstream CLI tests verify that an in-place edit retains creation time in local Metadata. The CLI-to-real-Obsidian scenario additionally verifies the edited content and creation time in received Metadata. These checks do not establish the reporter's ZFS behaviour or physical-file creation times in Obsidian.
98
102
 
@@ -0,0 +1,98 @@
1
+ ---
2
+ date: 2026-10-19
3
+ commonlib-version: "0.1.42"
4
+ self-hosted-livesync-version: "1.0.38"
5
+ status: unreleased
6
+ ---
7
+
8
+ # Unicode path identity
9
+
10
+ `pathNormalizationVersion` controls the logical path used to derive a normal-file
11
+ Metadata document ID. An absent value or `0` preserves legacy behaviour. Value
12
+ `1` applies Unicode NFC before the existing case and ID derivation rules. It
13
+ does not alter the Metadata path or require physical files to be renamed.
14
+ Unsupported values are rejected.
15
+
16
+ NFC identity shipped in Commonlib `0.1.42` and Self-hosted LiveSync `1.0.38`.
17
+ The deletion handling below is an unreleased refinement of reconstruction.
18
+
19
+ ## Package contract
20
+
21
+ The Commonlib settings entry exposes `PathNormalizationVersion`,
22
+ `PathIdentitySettings`, `getPathNormalizationVersion`, and
23
+ `normalisePathForIdentity`. Consumers must use the shared path identity
24
+ implementation for Metadata IDs rather than add a host-specific normalisation
25
+ rule. Host storage adapters remain responsible for mapping a logical path back
26
+ to its existing physical entry.
27
+
28
+ ## Database compatibility
29
+
30
+ Version `1` uses `nfc-path-identity-v1` in the existing generation-13
31
+ `used_features` list. This is separate from `idDerivationVersion`, which selects
32
+ the cryptographic ID key. The NFC setting is a required Tweak even when
33
+ encryption is disabled.
34
+
35
+ A fresh database declares the feature before its first Metadata mutation. An
36
+ existing database without the feature cannot acquire it through an ordinary
37
+ Metadata write or replication while it already contains Metadata. A client
38
+ configured for version `0` refuses an NFC database. These checks also apply
39
+ when ordinary configuration-mismatch warnings are disabled. The existing
40
+ version-document reception hook stops an active replication session when it
41
+ detects incompatible path identity. It does not revoke server credentials or
42
+ atomically cancel work already admitted by another device.
43
+
44
+ Fast Fetch checkpoints include the path identity version, so a checkpoint
45
+ created under version `0` cannot resume under version `1`, or vice versa.
46
+
47
+ ## Migration contract
48
+
49
+ 1. Update every participating client, stop synchronisation, and retain a
50
+ complete backup.
51
+ 2. Select a complete, authoritative Vault. Resolve distinct files or Metadata
52
+ documents which would derive the same NFC document ID. Case-insensitive
53
+ handling also applies its existing case rule.
54
+ 3. Set version `1` on the source and Rebuild both databases from that Vault.
55
+ 4. Set version `1` on the remaining clients and Fetch the rebuilt remote before
56
+ resuming synchronisation.
57
+
58
+ Reconstruction checks the available storage listing and local Metadata before
59
+ resetting either database. When the local database is not open, the storage
60
+ check conservatively includes every listed file. A collision stops the
61
+ operation; it never chooses a file to keep or merges separate histories.
62
+ Ordinary synchronisation does not rename legacy document IDs in place.
63
+
64
+ Full Rebuild, Standard Fetch, and Fast Fetch replace the local database. Their
65
+ Metadata check requests conflict information and excludes a logically deleted
66
+ document (`deleted: true`) only when it has no unresolved conflict leaves.
67
+ A deleted winner with any unresolved conflict remains a collision candidate.
68
+ Remote-only Rebuild retains logically deleted Metadata and still requires its
69
+ stored document IDs to match the selected identity.
70
+
71
+ A fully tombstoned document is already absent from ordinary Metadata
72
+ enumeration. A deletion made only on the remote does not exclude a still
73
+ non-deleted local document until that deletion has been replicated.
74
+
75
+ The Setup URI shares `pathNormalizationVersion`; an absent value decodes as
76
+ legacy mode, and unsupported versions are rejected. Importing a shared value
77
+ does not itself perform the required reconstruction.
78
+
79
+ ## Supported hosts
80
+
81
+ The initial support boundary is the maintained CLI and Obsidian with CouchDB.
82
+ `PathService` owns logical ID derivation. The CLI resolves NFC-equivalent names
83
+ against directory entries while preserving the actual entry spelling; its
84
+ generic Node storage contract remains exact-path access. Obsidian supplies its
85
+ normalised storage paths and preserves an existing physical spelling when it
86
+ is NFC-equivalent to the received path.
87
+
88
+ Journal and P2P refuse version `1`. Direct file access refuses a remote which
89
+ declares the NFC feature. Other host adapters must establish physical path
90
+ resolution before offering this setting. The regression tests cover ASCII,
91
+ NFC, NFD, and mixed paths through in-memory PouchDB Metadata lookup, with and
92
+ without path obfuscation, plus database admission, reconstruction ordering,
93
+ collisions, and physical path handling. These tests do not establish native
94
+ iOS or macOS filesystem behaviour.
95
+
96
+ The full host setting, Rebuild/Fetch, CLI, and Obsidian migration contract is
97
+ in the [Self-hosted LiveSync Unicode path identity design](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/design_docs/unicode_path_identity.md)
98
+ and its [decision record](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/adr/2026_10_unicode_path_identity.md).