@vrtmrz/livesync-commonlib 0.1.41 → 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.
Files changed (74) hide show
  1. package/README.md +29 -1
  2. package/dist/API/DirectFileManipulatorV2.js +16 -1
  3. package/dist/API/processSetting.d.ts +1 -0
  4. package/dist/API/processSetting.js +6 -1
  5. package/dist/common/fileContentHash.d.ts +2 -0
  6. package/dist/common/fileContentHash.js +18 -0
  7. package/dist/common/models/setting.const.defaults.js +1 -0
  8. package/dist/common/models/setting.const.qr.js +1 -0
  9. package/dist/common/models/setting.policy.d.ts +4 -0
  10. package/dist/common/models/setting.policy.js +1 -0
  11. package/dist/common/models/setting.type.d.ts +8 -1
  12. package/dist/common/models/shared.definition.configNames.js +5 -0
  13. package/dist/common/models/tweak.definition.d.ts +6 -0
  14. package/dist/common/models/tweak.definition.js +1 -0
  15. package/dist/common/pathIdentity.d.ts +19 -0
  16. package/dist/common/pathIdentity.js +35 -0
  17. package/dist/common/pathNormalization.d.ts +7 -0
  18. package/dist/common/pathNormalization.js +13 -0
  19. package/dist/common/settingConstants.d.ts +1 -0
  20. package/dist/diagnostics/couchdb.d.ts +15 -0
  21. package/dist/diagnostics/couchdb.js +129 -0
  22. package/dist/diagnostics/index.d.ts +10 -0
  23. package/dist/diagnostics/index.js +26 -0
  24. package/dist/diagnostics/integrity.d.ts +5 -0
  25. package/dist/diagnostics/integrity.js +674 -0
  26. package/dist/diagnostics/local.d.ts +4 -0
  27. package/dist/diagnostics/local.js +25 -0
  28. package/dist/diagnostics/reports.d.ts +108 -0
  29. package/dist/diagnostics/reports.js +353 -0
  30. package/dist/diagnostics/types.d.ts +130 -0
  31. package/dist/diagnostics/types.js +13 -0
  32. package/dist/diagnostics/usage.d.ts +51 -0
  33. package/dist/diagnostics/usage.js +241 -0
  34. package/dist/hashing.d.ts +1 -0
  35. package/dist/hashing.js +3 -1
  36. package/dist/interfaces/FileReflectionProvenance.d.ts +4 -0
  37. package/dist/managers/EntryManager/EntryManager.d.ts +1 -0
  38. package/dist/managers/EntryManager/EntryManager.js +9 -0
  39. package/dist/pouchdb/StreamingFetch.d.ts +3 -1
  40. package/dist/pouchdb/StreamingFetch.js +3 -1
  41. package/dist/pouchdb/encryption.d.ts +3 -1
  42. package/dist/pouchdb/encryption.js +38 -30
  43. package/dist/pouchdb/pathIdentityCompatibility.d.ts +8 -0
  44. package/dist/pouchdb/pathIdentityCompatibility.js +64 -0
  45. package/dist/pouchdb/remoteFeatureCompatibility.d.ts +2 -0
  46. package/dist/pouchdb/remoteFeatureCompatibility.js +9 -1
  47. package/dist/replication/couchdb/LiveSyncReplicator.js +11 -0
  48. package/dist/replication/index.d.ts +1 -1
  49. package/dist/replication/index.js +2 -0
  50. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +2 -0
  51. package/dist/replication/journal/LiveSyncJournalReplicator.js +28 -6
  52. package/dist/replication/trystero/P2PRoomSessionOwner.js +18 -5
  53. package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
  54. package/dist/replication/trystero/useP2PReplicatorFeature.js +2 -1
  55. package/dist/serviceFeatures/offlineScanner.d.ts +12 -3
  56. package/dist/serviceFeatures/offlineScanner.js +4 -2
  57. package/dist/serviceFeatures/targetFilter.d.ts +1 -1
  58. package/dist/serviceFeatures/targetFilter.js +28 -17
  59. package/dist/serviceModules/Rebuilder.d.ts +5 -1
  60. package/dist/serviceModules/Rebuilder.js +85 -3
  61. package/dist/serviceModules/ServiceFileHandlerBase.d.ts +5 -2
  62. package/dist/serviceModules/ServiceFileHandlerBase.js +110 -49
  63. package/dist/services/base/IService.d.ts +2 -1
  64. package/dist/services/base/PathService.d.ts +3 -3
  65. package/dist/services/base/PathService.js +6 -3
  66. package/dist/settings.d.ts +2 -0
  67. package/dist/settings.js +3 -0
  68. package/dist/worker/bgWorker.js +1 -1
  69. package/docs/conflict-resolution.md +17 -8
  70. package/docs/database-diagnostics.md +24 -0
  71. package/docs/development.md +1 -0
  72. package/docs/proven-in-use.md +11 -1
  73. package/docs/unicode-path-identity.md +98 -0
  74. package/package.json +9 -1
package/README.md CHANGED
@@ -26,7 +26,8 @@ The package is ESM-only and declares Node 20 or later. Browser entry points are
26
26
  | -------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
27
27
  | `@vrtmrz/livesync-commonlib` | `DirectFileManipulator` for integrations which access CouchDB directly | Deliberately small, but its high-level lifecycle and file-operation semantics are not final |
28
28
  | `@vrtmrz/livesync-commonlib/context` | Instance-owned events, translation injection, and host-neutral standard-I/O types | Focused, package-tested pre-1.0 contract |
29
- | `@vrtmrz/livesync-commonlib/hashing` | Chunk ID calculation with the database's hash manager | Focused, package-tested pre-1.0 contract |
29
+ | `@vrtmrz/livesync-commonlib/diagnostics` | Read-only Usage and integrity inspection, name projection, and reports | Focused pre-1.0 diagnostic contract |
30
+ | `@vrtmrz/livesync-commonlib/hashing` | Chunk ID calculation and exact file-content hashing | Focused, package-tested pre-1.0 contract |
30
31
  | `@vrtmrz/livesync-commonlib/browser` | Rooted File System Access API storage | Focused, package-tested pre-1.0 contract |
31
32
  | `@vrtmrz/livesync-commonlib/node` | Rooted Node storage, Node standard I/O, and selected Node capabilities | Supported platform façade; keeps Node-only dependencies behind an explicit boundary |
32
33
  | `@vrtmrz/livesync-commonlib/p2p` | Host-composed P2P service views and composition helpers | Focused, package-tested pre-1.0 contract with a temporary compatibility façade |
@@ -119,12 +120,37 @@ See [the settings lifecycle guide](docs/settings-lifecycle.md) before initialisi
119
120
 
120
121
  The focused Setup URI entry offers an Ephemeral mode bound to the current fixed seven-day window and a Persistent mode compatible with existing readers. Both use the same URI structure. See [the Setup URI sharing guide](docs/setup-uri.md) for the availability boundary, import behaviour, and compatibility limits.
121
122
 
123
+ ## Database diagnostics
124
+
125
+ The focused `/diagnostics` entry provides read-only Usage and integrity reports, with original names or temporary pseudonyms selected by the host for each run. Integrity inspection covers normal-file Metadata, current conflict branches, and content comparisons for suspect groups in the local database and CouchDB. See [Database diagnostics](docs/database-diagnostics.md) for coverage, cancellation, and privacy boundaries.
126
+
127
+ ```typescript
128
+ import {
129
+ analyseDatabaseUsage,
130
+ createDiagnosticNameMapper,
131
+ createLocalDiagnosticSource,
132
+ formatUsageTSV,
133
+ projectUsageReport,
134
+ } from '@vrtmrz/livesync-commonlib/diagnostics';
135
+
136
+ const result = await analyseDatabaseUsage({ source: createLocalDiagnosticSource(localPouchDB) });
137
+ const report = projectUsageReport(result, createDiagnosticNameMapper('pseudonym'));
138
+ if (report.status === 'complete') {
139
+ const tsv = formatUsageTSV(report);
140
+ // Present the report through the host's own UI or output stream.
141
+ }
142
+ ```
143
+
122
144
  ## Chunk ID calculation
123
145
 
124
146
  The `/hashing` entry exports `HashManager` and its `HashManagerCoreOptions` type. It uses the same implementation as database writes, including legacy algorithms and independent ID keys. Hosts supply their settings service and await `initialise()` before calling `computeHash(piece)`.
125
147
 
126
148
  `computeHash` returns the Chunk hash, including the `+` prefix when encryption is enabled; the database adds its `h:` namespace separately. Reuse a manager for repeated calculations with the same configuration, create a new manager when changing the legacy algorithm or passphrase, and call `clearCaches()` when retiring it. This releases retained key preparation; it does not erase existing settings or in-flight operations. The entry performs ID calculation and does not write Chunks to a database.
127
149
 
150
+ ## File content hashing
151
+
152
+ `computeFileContentHash(content: Blob)` returns a `sha256:<lowercase hexadecimal>` digest of the exact bytes. It includes Blob reading and serialises concurrent hashes to limit whole-file buffer overlap. File-reflection provenance uses this helper to recognise unchanged content independently of retained revision history; it does not replace Chunk ID calculation.
153
+
128
154
  ## Remote connection profiles
129
155
 
130
156
  New profile-management code can create or update a CouchDB, Object Storage, or P2P connection through the focused remote-configurations entry. Profile IDs provide identity, display names remain editable presentation, and the active selection is explicit. Older flat connection fields remain a runtime projection and an import-compatibility boundary rather than the preferred persistence model.
@@ -135,6 +161,8 @@ See [the remote configuration profile guide](docs/remote-configurations.md) for
135
161
 
136
162
  CouchDB stores the representations already in use as feature identifiers in its version document. Assess that document before synchronisation or direct file operations, and retain unknown identifiers for host diagnostics. See [the remote feature compatibility contract](docs/remote-feature-compatibility.md) for the wire format, update rules, and recovery boundaries.
137
163
 
164
+ The maintained CLI and Obsidian hosts can opt into [Unicode NFC path identity](docs/unicode-path-identity.md) with CouchDB. Existing databases require an explicit Rebuild or Fetch migration before using it.
165
+
138
166
  ## Contract scope
139
167
 
140
168
  The package is currently an infrastructure and compatibility boundary. The context, rooted-storage, standard-I/O, and revision-tree safety contracts have focused cross-platform, instance-isolation, or real-PouchDB tests. Platform details which cannot be shared, such as file timestamp fidelity and browser permission handling, remain host concerns and are documented separately.
@@ -3,11 +3,17 @@ import {
3
3
  DEFAULT_SYNC_PARAMETERS,
4
4
  ProtocolVersions,
5
5
  DOCID_SYNC_PARAMETERS,
6
+ VERSIONING_DOCID,
6
7
  E2EEAlgorithms,
7
8
  VER
8
9
  } from "../common/types.js";
9
10
  import { checkRemoteVersion } from "../pouchdb/negotiation.js";
10
- import { requiredRemoteFeatures, usesEncryptedInternalMetadata } from "../pouchdb/remoteFeatureCompatibility.js";
11
+ import {
12
+ assessRemoteFeatureDocument,
13
+ NFC_PATH_IDENTITY_FEATURE,
14
+ requiredRemoteFeatures,
15
+ usesEncryptedInternalMetadata
16
+ } from "../pouchdb/remoteFeatureCompatibility.js";
11
17
  import { assessRemoteDocumentIds } from "../pouchdb/remoteIdCompatibility.js";
12
18
  import { PouchDB } from "../pouchdb/pouchdb-http.js";
13
19
  import { LiveSyncLocalDB } from "../pouchdb/LiveSyncLocalDB.js";
@@ -67,6 +73,15 @@ class DirectFileManipulator {
67
73
  if (idCompatibility === "mismatched") {
68
74
  throw new Error("Direct database document IDs do not match the configured ID key.");
69
75
  }
76
+ try {
77
+ const versionInfo = await this.liveSyncLocalDB.localDatabase.get(VERSIONING_DOCID);
78
+ const assessment = assessRemoteFeatureDocument(versionInfo);
79
+ if (assessment.status === "supported" && assessment.usedFeatures.includes(NFC_PATH_IDENTITY_FEATURE)) {
80
+ throw new Error("Direct database version or features are not compatible.");
81
+ }
82
+ } catch (error) {
83
+ if (!isErrorOfMissingDoc(error)) throw error;
84
+ }
70
85
  const requiredFeatures = requiredRemoteFeatures(this.settings);
71
86
  if (!await checkRemoteVersion(
72
87
  this.liveSyncLocalDB.localDatabase,
@@ -57,6 +57,7 @@ export declare function decodeSettingsFromSetupURI(uri: string, passphrase: stri
57
57
  encryptInternalMetadata: boolean;
58
58
  idDerivationVersion: 0 | 1;
59
59
  idDerivationKey: string;
60
+ pathNormalizationVersion: import("../settings.js").PathNormalizationVersion;
60
61
  liveSync: boolean;
61
62
  syncOnSave: boolean;
62
63
  syncOnStart: boolean;
@@ -10,9 +10,11 @@ import {
10
10
  import { decryptString, encryptString } from "../encryption/stringEncryption.js";
11
11
  import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
12
12
  import { configuredIdKey } from "../common/idDerivation.js";
13
+ import { getPathNormalizationVersion } from "../common/pathNormalization.js";
13
14
  function encodeSettingsToQRCodeData(settings) {
14
15
  settings = omitP2PRuntimeSettings(settings);
15
16
  configuredIdKey(settings);
17
+ getPathNormalizationVersion(settings);
16
18
  const fullIndexes = Object.entries(KeyIndexOfSettings);
17
19
  let maxIndex = 0;
18
20
  for (const [, index] of fullIndexes) {
@@ -53,6 +55,7 @@ function decodeSettingsFromQRCodeData(qr) {
53
55
  );
54
56
  }
55
57
  configuredIdKey(newSettings);
58
+ newSettings.pathNormalizationVersion = getPathNormalizationVersion(newSettings);
56
59
  return omitP2PRuntimeSettings(newSettings);
57
60
  }
58
61
  var OutputFormat = /* @__PURE__ */ ((OutputFormat2) => {
@@ -134,6 +137,7 @@ async function encodeSettingsToSetupURI(settingString, passphrase, removePropert
134
137
  const setting = {
135
138
  ...omitP2PRuntimeSettings(settingString)
136
139
  };
140
+ getPathNormalizationVersion(setting);
137
141
  configuredIdKey({
138
142
  idDerivationVersion: setting.idDerivationVersion ?? 0,
139
143
  idDerivationKey: setting.idDerivationKey ?? ""
@@ -211,7 +215,8 @@ async function decodeSettingsFromSetupURI(uri, passphrase) {
211
215
  ...imported,
212
216
  encryptInternalMetadata: typeof imported.encryptInternalMetadata === "boolean" ? imported.encryptInternalMetadata : false,
213
217
  idDerivationVersion: imported.idDerivationVersion ?? 0,
214
- idDerivationKey: imported.idDerivationKey ?? ""
218
+ idDerivationKey: imported.idDerivationKey ?? "",
219
+ pathNormalizationVersion: getPathNormalizationVersion(imported)
215
220
  };
216
221
  configuredIdKey(settings);
217
222
  return omitP2PRuntimeSettings(settings);
@@ -0,0 +1,2 @@
1
+ /** Hash the exact bytes of one immutable file snapshot. */
2
+ export declare function computeFileContentHash(content: Blob): Promise<`sha256:${string}`>;
@@ -0,0 +1,18 @@
1
+ import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
2
+ import { getWebCrypto } from "../mods.js";
3
+ import { uint8ArrayToHexString } from "../string_and_binary/convert.js";
4
+ const hashing = Semaphore(1);
5
+ async function computeFileContentHash(content) {
6
+ const release = await hashing.acquire();
7
+ try {
8
+ const crypto = await getWebCrypto();
9
+ const bytes = await content.arrayBuffer();
10
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
11
+ return `sha256:${uint8ArrayToHexString(new Uint8Array(digest))}`;
12
+ } finally {
13
+ release();
14
+ }
15
+ }
16
+ export {
17
+ computeFileContentHash
18
+ };
@@ -58,6 +58,7 @@ const SETTINGS_SCHEMA_DEFAULTS = {
58
58
  passphrase: "",
59
59
  idDerivationVersion: 0,
60
60
  idDerivationKey: "",
61
+ pathNormalizationVersion: 0,
61
62
  usePathObfuscation: false,
62
63
  encryptInternalMetadata: false,
63
64
  doNotDeleteFolder: false,
@@ -180,6 +180,7 @@ const KeyIndexOfSettings = {
180
180
  encryptInternalMetadata: 165,
181
181
  idDerivationVersion: 166,
182
182
  idDerivationKey: 167,
183
+ pathNormalizationVersion: 168,
183
184
  encryptedIdDerivationKey: -1
184
185
  };
185
186
  export {
@@ -485,6 +485,10 @@ export declare const SETTINGS_PROPERTY_POLICY: {
485
485
  readonly markdown: "credentials";
486
486
  readonly persistence: "include";
487
487
  };
488
+ readonly pathNormalizationVersion: {
489
+ readonly markdown: "credentials";
490
+ readonly persistence: "include";
491
+ };
488
492
  readonly idDerivationKey: {
489
493
  readonly markdown: "credentials";
490
494
  readonly persistence: "id-key";
@@ -122,6 +122,7 @@ const SETTINGS_PROPERTY_POLICY = {
122
122
  encrypt: SettingPolicies.Default,
123
123
  passphrase: SettingPolicies.E2EEPassphrase,
124
124
  idDerivationVersion: SettingPolicies.MarkdownCredentials,
125
+ pathNormalizationVersion: SettingPolicies.MarkdownCredentials,
125
126
  idDerivationKey: SettingPolicies.IdDerivationKey,
126
127
  usePathObfuscation: SettingPolicies.Default,
127
128
  encryptInternalMetadata: SettingPolicies.Default,
@@ -575,6 +575,13 @@ export interface RemoteTypeSettings {
575
575
  remoteType: RemoteType;
576
576
  }
577
577
  export type E2EEAlgorithm = (typeof E2EEAlgorithms)[keyof typeof E2EEAlgorithms] | "";
578
+ /** Path identity modes retained in persisted settings and shared tweak snapshots. */
579
+ export type PathNormalizationVersion = 0 | 1;
580
+ /** Identity settings for mapping logical paths to remote document IDs. */
581
+ export interface PathIdentitySettings {
582
+ /** Zero preserves legacy path identity; one applies Unicode NFC before ID derivation. */
583
+ pathNormalizationVersion: PathNormalizationVersion;
584
+ }
578
585
  /**
579
586
  * Represents the settings used for End-to-End encryption.
580
587
  */
@@ -937,7 +944,7 @@ export interface RemoteConfigurations {
937
944
  }
938
945
  interface ObsidianLiveSyncSettings_PluginSetting extends SyncMethodSettings, UISettings, FileHandlingSettings, MergeBehaviourSettings, EncryptedUserSettings, PeriodicReplicationSettings, InternalFileSettings, PluginSyncSettings, ModeSettings, ExtraTweakSettings, BetaTweakSettings, ObsoleteSettings, DebugModeSettings, SettingSyncSettings, SafetyValveSettings, DataOnSettings, RemoteConfigurations {
939
946
  }
940
- export type RemoteDBSettings = CouchDBConnection & BucketSyncSetting & RemoteTypeSettings & EncryptionSettings & ChunkSettings & EdenSettings & DataOnRemoteDBSettings & ObsoleteRemoteDBSettings & OnDemandChunkSettings & BetaRemoteDBSettings & ReplicationSetting & RemoteDBTweakSettings & FileHandlingSettings & ProcessingBehaviourSettings & OptionalAndNotExposedRemoteDBSettings & CrossPlatformInteroperabilitySettings & ConflictHandlingSettings & EdgeCaseHandlingSettings & DeletedFileMetadataSettings & P2PSyncSetting & RemoteConfigurations;
947
+ export type RemoteDBSettings = CouchDBConnection & BucketSyncSetting & RemoteTypeSettings & PathIdentitySettings & EncryptionSettings & ChunkSettings & EdenSettings & DataOnRemoteDBSettings & ObsoleteRemoteDBSettings & OnDemandChunkSettings & BetaRemoteDBSettings & ReplicationSetting & RemoteDBTweakSettings & FileHandlingSettings & ProcessingBehaviourSettings & OptionalAndNotExposedRemoteDBSettings & CrossPlatformInteroperabilitySettings & ConflictHandlingSettings & EdgeCaseHandlingSettings & DeletedFileMetadataSettings & P2PSyncSetting & RemoteConfigurations;
941
948
  export type ObsidianLiveSyncSettings = ObsidianLiveSyncSettings_PluginSetting & RemoteDBSettings & LocalDBSettings;
942
949
  export interface HasSettings<T extends Partial<ObsidianLiveSyncSettings>> {
943
950
  settings: T;
@@ -64,6 +64,11 @@ const configurationNames = {
64
64
  name: "Handle files as Case-Sensitive",
65
65
  desc: "If this enabled, All files are handled as case-Sensitive (Previous behaviour)."
66
66
  },
67
+ pathNormalizationVersion: {
68
+ name: "Path identity mode",
69
+ desc: "CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1.",
70
+ isAdvanced: true
71
+ },
67
72
  doNotUseFixedRevisionForChunks: {
68
73
  name: "Content-derived chunk revisions (obsolete setting)",
69
74
  desc: "Chunk revisions are always derived from their content. This stored key is retained only for compatibility."
@@ -25,6 +25,11 @@ export declare const TweakCompatibilityRules: readonly [{
25
25
  readonly templateValue: 0;
26
26
  readonly reconstruction: "required";
27
27
  readonly knownDefault: 0;
28
+ }, {
29
+ readonly key: "pathNormalizationVersion";
30
+ readonly templateValue: 0;
31
+ readonly reconstruction: "required";
32
+ readonly knownDefault: 0;
28
33
  }, {
29
34
  readonly key: "encryptInternalMetadata";
30
35
  readonly templateValue: false;
@@ -116,6 +121,7 @@ export declare const TweakValuesDefault: Partial<ObsidianLiveSyncSettings>;
116
121
  export declare const TweakValuesTemplate: {
117
122
  tweakModified: number;
118
123
  usePluginSyncV2: boolean;
124
+ pathNormalizationVersion: import("./setting.type.js").PathNormalizationVersion;
119
125
  encrypt: boolean;
120
126
  idDerivationVersion: 0 | 1;
121
127
  usePathObfuscation: boolean;
@@ -6,6 +6,7 @@ const TweakCompatibilityRules = Object.freeze([
6
6
  { key: "encrypt", templateValue: false, reconstruction: "required" },
7
7
  { key: "usePathObfuscation", templateValue: false, reconstruction: "required" },
8
8
  { key: "idDerivationVersion", templateValue: 0, reconstruction: "required", knownDefault: 0 },
9
+ { key: "pathNormalizationVersion", templateValue: 0, reconstruction: "required", knownDefault: 0 },
9
10
  { key: "encryptInternalMetadata", templateValue: false, reconstruction: "none", knownDefault: false },
10
11
  { key: "enableCompression", templateValue: false, reconstruction: "none" },
11
12
  { key: "useEden", templateValue: false, reconstruction: "none" },
@@ -0,0 +1,19 @@
1
+ import type { EntryHasPath, FilePathWithPrefix } from "./types.js";
2
+ import type { IPathService } from "../services/base/IService.js";
3
+ /** Check the complete source listing before an operation can discard either database. */
4
+ export declare function assertUniqueStoragePathIdentities(paths: Iterable<FilePathWithPrefix>, pathService: Pick<IPathService, "path2id">): Promise<void>;
5
+ /**
6
+ * Distinct Metadata documents must not be merged by a change of path identity.
7
+ * Callers must enumerate entries with `conflicts: true` so unresolved revisions remain visible.
8
+ * `requireMatchingIds: false` is reserved for preflight checks before replacing the local database;
9
+ * only that mode excludes conflict-free logical deletions from collision checks.
10
+ */
11
+ export declare function assertMetadataPathIdentities(entries: AsyncIterable<EntryHasPath & {
12
+ _id: string;
13
+ deleted?: boolean;
14
+ _conflicts?: string[];
15
+ }> | Iterable<EntryHasPath & {
16
+ _id: string;
17
+ deleted?: boolean;
18
+ _conflicts?: string[];
19
+ }>, pathService: Pick<IPathService, "path2id">, requireMatchingIds: boolean): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { id2path_base } from "../string_and_binary/path.js";
2
+ function collision(first, second) {
3
+ return new Error(
4
+ `File path identity collision: ${JSON.stringify(first)} and ${JSON.stringify(second)}. Rename or exclude the distinct files before continuing.`
5
+ );
6
+ }
7
+ async function assertUniqueStoragePathIdentities(paths, pathService) {
8
+ const identities = /* @__PURE__ */ new Map();
9
+ for (const path of paths) {
10
+ const id = await pathService.path2id(path);
11
+ const previous = identities.get(id);
12
+ if (previous !== void 0 && previous !== path) throw collision(previous, path);
13
+ identities.set(id, path);
14
+ }
15
+ }
16
+ async function assertMetadataPathIdentities(entries, pathService, requireMatchingIds) {
17
+ const identities = /* @__PURE__ */ new Map();
18
+ for await (const entry of entries) {
19
+ if (!requireMatchingIds && entry.deleted === true && !entry._conflicts?.length) continue;
20
+ const path = id2path_base(entry._id, entry);
21
+ const id = await pathService.path2id(path);
22
+ const previous = identities.get(id);
23
+ if (previous && previous.id !== entry._id) throw collision(previous.path, path);
24
+ identities.set(id, { path, id: entry._id });
25
+ if (requireMatchingIds && entry._id !== id) {
26
+ throw new Error(
27
+ `The stored document ID for ${JSON.stringify(path)} uses a different path identity. Rebuild from the authoritative Vault or Fetch the migrated database before synchronising.`
28
+ );
29
+ }
30
+ }
31
+ }
32
+ export {
33
+ assertMetadataPathIdentities,
34
+ assertUniqueStoragePathIdentities
35
+ };
@@ -0,0 +1,7 @@
1
+ import type { PathNormalizationVersion } from "./models/setting.type.js";
2
+ /** Resolve the stored path identity version, treating an absent legacy value as version zero. */
3
+ export declare function getPathNormalizationVersion(settings: {
4
+ pathNormalizationVersion?: number;
5
+ }): PathNormalizationVersion;
6
+ /** Normalise a logical path for document ID derivation without changing its stored path. */
7
+ export declare function normalisePathForIdentity(path: string, version?: number): string;
@@ -0,0 +1,13 @@
1
+ function getPathNormalizationVersion(settings) {
2
+ const version = settings.pathNormalizationVersion === void 0 ? 0 : settings.pathNormalizationVersion;
3
+ if (version === 0 || version === 1) return version;
4
+ throw new Error(`Unsupported path normalisation version: ${version}`);
5
+ }
6
+ function normalisePathForIdentity(path, version) {
7
+ const resolvedVersion = getPathNormalizationVersion({ pathNormalizationVersion: version });
8
+ return resolvedVersion === 1 ? path.normalize("NFC") : path;
9
+ }
10
+ export {
11
+ getPathNormalizationVersion,
12
+ normalisePathForIdentity
13
+ };
@@ -134,6 +134,7 @@ export declare const AllSettingDefault: {
134
134
  bucketPrefix: string;
135
135
  forcePathStyle: boolean;
136
136
  remoteType: import("./types.js").RemoteType;
137
+ pathNormalizationVersion: import("./models/setting.type.js").PathNormalizationVersion;
137
138
  encrypt: boolean;
138
139
  passphrase: string;
139
140
  idDerivationVersion: 0 | 1;
@@ -0,0 +1,15 @@
1
+ import { type RemoteDBSettings } from "../common/types.js";
2
+ import { type DiagnosticDatabaseSource } from "./types.js";
3
+ export interface CouchDBDiagnosticSourceOptions {
4
+ settings: RemoteDBSettings;
5
+ fetch(request: string, options: RequestInit): Promise<Response>;
6
+ signal?: AbortSignal;
7
+ }
8
+ /**
9
+ * Open an operation-owned HTTP reader. All requests are GETs, including the
10
+ * Security Seed lookup; the replication connection and its setup paths are
11
+ * deliberately absent from this boundary.
12
+ */
13
+ export declare function createCouchDBDiagnosticSource({ settings: originalSettings, fetch, signal, }: CouchDBDiagnosticSourceOptions): DiagnosticDatabaseSource & {
14
+ close(): Promise<void>;
15
+ };
@@ -0,0 +1,129 @@
1
+ import { DOCID_SYNC_PARAMETERS } from "../common/types.js";
2
+ import { isObfuscatedEntry, parseHeaderValues } from "../common/utils.js";
3
+ import { getConfiguredFunctionsForEncryption, isEncryptedMeta } from "../pouchdb/encryption.js";
4
+ import { isPathProbablyObfuscated } from "octagonal-wheels/encryption/encryption";
5
+ import { decompressDoc } from "../pouchdb/compress.js";
6
+ import { AuthorizationHeaderGenerator, generateCredentialObject } from "../replication/httplib.js";
7
+ import { base64ToArrayBuffer } from "../string_and_binary/convert.js";
8
+ import { DiagnosticReadError } from "./types.js";
9
+ function createCouchDBDiagnosticSource({
10
+ settings: originalSettings,
11
+ fetch,
12
+ signal
13
+ }) {
14
+ const settings = { ...originalSettings };
15
+ const root = `${settings.couchDB_URI.replace(/\/+$/, "")}/${encodeURIComponent(settings.couchDB_DBNAME)}`;
16
+ const url = new URL(root);
17
+ if (!settings.couchDB_DBNAME || !["https:", "http:"].includes(url.protocol) || url.search || url.hash) {
18
+ throw new DiagnosticReadError("source-unavailable");
19
+ }
20
+ if (settings.encrypt && !settings.passphrase && !settings.permitEmptyPassphrase) {
21
+ throw new DiagnosticReadError("source-unavailable");
22
+ }
23
+ const controller = new AbortController();
24
+ const abort = () => controller.abort();
25
+ if (signal?.aborted) abort();
26
+ else signal?.addEventListener("abort", abort, { once: true });
27
+ const auth = new AuthorizationHeaderGenerator();
28
+ const credentials = generateCredentialObject(settings);
29
+ const headers = new Headers(parseHeaderValues(settings.couchDB_CustomHeaders));
30
+ const checkCancelled = () => {
31
+ if (controller.signal.aborted) throw new DOMException("Diagnostic cancelled", "AbortError");
32
+ };
33
+ const readJSON = async (path, parameters = {}) => {
34
+ checkCancelled();
35
+ const requestURL = new URL(`${root}${path}`);
36
+ for (const [key, value] of Object.entries(parameters)) requestURL.searchParams.set(key, value);
37
+ headers.set("authorization", await auth.getAuthorizationHeader(credentials));
38
+ checkCancelled();
39
+ const response = await fetch(requestURL.toString(), {
40
+ method: "GET",
41
+ headers,
42
+ cache: "no-store",
43
+ signal: controller.signal
44
+ });
45
+ checkCancelled();
46
+ if (!response.ok) throw new DiagnosticReadError("document-unreadable", response.status === 404);
47
+ const result = await response.json();
48
+ checkCancelled();
49
+ return result;
50
+ };
51
+ let salt;
52
+ const readSalt = () => salt ??= (async () => {
53
+ try {
54
+ const parameters = await readJSON(`/${DOCID_SYNC_PARAMETERS}`);
55
+ if (typeof parameters.pbkdf2salt !== "string" || !parameters.pbkdf2salt) {
56
+ throw new DiagnosticReadError("security-seed-unavailable");
57
+ }
58
+ const bytes = new Uint8Array(base64ToArrayBuffer(parameters.pbkdf2salt));
59
+ if (!bytes.length) throw new DiagnosticReadError("security-seed-unavailable");
60
+ return bytes;
61
+ } catch {
62
+ checkCancelled();
63
+ throw new DiagnosticReadError("security-seed-unavailable");
64
+ }
65
+ })();
66
+ const decrypt = settings.encrypt ? getConfiguredFunctionsForEncryption(
67
+ settings.passphrase,
68
+ settings.useDynamicIterationCount,
69
+ false,
70
+ readSalt,
71
+ settings.E2EEAlgorithm,
72
+ false,
73
+ () => {
74
+ }
75
+ ).outgoing : async (doc) => doc;
76
+ return {
77
+ async info() {
78
+ const info = await readJSON("");
79
+ return { update_seq: info.update_seq };
80
+ },
81
+ async *enumerateIds() {
82
+ let previous;
83
+ while (true) {
84
+ const parameters = { limit: "256" };
85
+ if (previous !== void 0) {
86
+ parameters.startkey = JSON.stringify(previous);
87
+ parameters.skip = "1";
88
+ }
89
+ const page = await readJSON("/_all_docs", parameters);
90
+ if (!Array.isArray(page.rows)) throw new DiagnosticReadError("source-unavailable");
91
+ if (page.rows.length === 0) return;
92
+ for (const row of page.rows) {
93
+ checkCancelled();
94
+ if (typeof row.id !== "string") throw new DiagnosticReadError("source-unavailable");
95
+ if (!row.value?.deleted) yield row.id;
96
+ }
97
+ const last = page.rows.at(-1).id;
98
+ if (last === previous) throw new DiagnosticReadError("source-unavailable");
99
+ previous = last;
100
+ if (page.rows.length < 256) return;
101
+ }
102
+ },
103
+ async get(id, options = {}) {
104
+ const parameters = {};
105
+ if (options.rev !== void 0) parameters.rev = options.rev;
106
+ if (options.conflicts) parameters.conflicts = "true";
107
+ if (options.revs) parameters.revs = "true";
108
+ if (options.revs_info) parameters.revs_info = "true";
109
+ const raw = await readJSON(`/${encodeURIComponent(id)}`, parameters);
110
+ if (raw._id !== id || typeof raw._rev !== "string") {
111
+ throw new DiagnosticReadError("document-unreadable");
112
+ }
113
+ const decoded = await decompressDoc(await decrypt(raw));
114
+ if ("e_" in decoded && decoded.e_ === true || "path" in decoded && typeof decoded.path === "string" && (isEncryptedMeta(decoded) || isObfuscatedEntry(decoded) && isPathProbablyObfuscated(decoded.path))) {
115
+ throw new DiagnosticReadError("document-unreadable");
116
+ }
117
+ checkCancelled();
118
+ return decoded;
119
+ },
120
+ close() {
121
+ signal?.removeEventListener("abort", abort);
122
+ controller.abort();
123
+ return Promise.resolve();
124
+ }
125
+ };
126
+ }
127
+ export {
128
+ createCouchDBDiagnosticSource
129
+ };
@@ -0,0 +1,10 @@
1
+ export { analyseDatabaseUsage } from "./usage.js";
2
+ export type { DatabaseUsageSource, DatabaseUsageResult, DatabaseUsageRow, DatabaseUsageProgress, DatabaseUsageError, } from "./usage.js";
3
+ export { analyseDatabaseIntegrity } from "./integrity.js";
4
+ export { createLocalDiagnosticSource } from "./local.js";
5
+ export { createCouchDBDiagnosticSource } from "./couchdb.js";
6
+ export type { CouchDBDiagnosticSourceOptions } from "./couchdb.js";
7
+ export { createDiagnosticNameMapper, projectIntegrityReport, projectUsageReport, formatDiagnosticJSON, formatIntegrityMarkdown, formatUsageTSV, } from "./reports.js";
8
+ export type { DiagnosticNameMapper, DiagnosticReportVersions, ProjectedDiagnosticReport, ProjectedDatabaseIntegrityReport, ProjectedDatabaseUsageReport, } from "./reports.js";
9
+ export { DiagnosticReadError } from "./types.js";
10
+ export type { DiagnosticStatus, DiagnosticNameMode, DiagnosticSourceName, DiagnosticReason, DiagnosticReadOptions, DiagnosticDatabaseSource, DiagnosticError, DiagnosticContent, DiagnosticCoverage, DiagnosticObservation, DiagnosticIdentitySettings, DatabaseIntegrityResult, DatabaseIntegrityOptions, } from "./types.js";
@@ -0,0 +1,26 @@
1
+ import { analyseDatabaseUsage } from "./usage.js";
2
+ import { analyseDatabaseIntegrity } from "./integrity.js";
3
+ import { createLocalDiagnosticSource } from "./local.js";
4
+ import { createCouchDBDiagnosticSource } from "./couchdb.js";
5
+ import {
6
+ createDiagnosticNameMapper,
7
+ projectIntegrityReport,
8
+ projectUsageReport,
9
+ formatDiagnosticJSON,
10
+ formatIntegrityMarkdown,
11
+ formatUsageTSV
12
+ } from "./reports.js";
13
+ import { DiagnosticReadError } from "./types.js";
14
+ export {
15
+ DiagnosticReadError,
16
+ analyseDatabaseIntegrity,
17
+ analyseDatabaseUsage,
18
+ createCouchDBDiagnosticSource,
19
+ createDiagnosticNameMapper,
20
+ createLocalDiagnosticSource,
21
+ formatDiagnosticJSON,
22
+ formatIntegrityMarkdown,
23
+ formatUsageTSV,
24
+ projectIntegrityReport,
25
+ projectUsageReport
26
+ };
@@ -0,0 +1,5 @@
1
+ import type { DatabaseIntegrityOptions, DatabaseIntegrityResult } from "./types.js";
2
+ /**
3
+ * Inspect current normal-file Metadata and content evidence without changing a source.
4
+ */
5
+ export declare function analyseDatabaseIntegrity(options: DatabaseIntegrityOptions): Promise<DatabaseIntegrityResult>;