@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.
- package/README.md +29 -1
- package/dist/API/DirectFileManipulatorV2.js +16 -1
- package/dist/API/processSetting.d.ts +1 -0
- package/dist/API/processSetting.js +6 -1
- package/dist/common/fileContentHash.d.ts +2 -0
- package/dist/common/fileContentHash.js +18 -0
- package/dist/common/models/setting.const.defaults.js +1 -0
- package/dist/common/models/setting.const.qr.js +1 -0
- package/dist/common/models/setting.policy.d.ts +4 -0
- package/dist/common/models/setting.policy.js +1 -0
- package/dist/common/models/setting.type.d.ts +8 -1
- package/dist/common/models/shared.definition.configNames.js +5 -0
- package/dist/common/models/tweak.definition.d.ts +6 -0
- package/dist/common/models/tweak.definition.js +1 -0
- package/dist/common/pathIdentity.d.ts +19 -0
- package/dist/common/pathIdentity.js +35 -0
- package/dist/common/pathNormalization.d.ts +7 -0
- package/dist/common/pathNormalization.js +13 -0
- package/dist/common/settingConstants.d.ts +1 -0
- package/dist/diagnostics/couchdb.d.ts +15 -0
- package/dist/diagnostics/couchdb.js +129 -0
- package/dist/diagnostics/index.d.ts +10 -0
- package/dist/diagnostics/index.js +26 -0
- package/dist/diagnostics/integrity.d.ts +5 -0
- package/dist/diagnostics/integrity.js +674 -0
- package/dist/diagnostics/local.d.ts +4 -0
- package/dist/diagnostics/local.js +25 -0
- package/dist/diagnostics/reports.d.ts +108 -0
- package/dist/diagnostics/reports.js +353 -0
- package/dist/diagnostics/types.d.ts +130 -0
- package/dist/diagnostics/types.js +13 -0
- package/dist/diagnostics/usage.d.ts +51 -0
- package/dist/diagnostics/usage.js +241 -0
- package/dist/hashing.d.ts +1 -0
- package/dist/hashing.js +3 -1
- package/dist/interfaces/FileReflectionProvenance.d.ts +4 -0
- package/dist/managers/EntryManager/EntryManager.d.ts +1 -0
- package/dist/managers/EntryManager/EntryManager.js +9 -0
- package/dist/pouchdb/StreamingFetch.d.ts +3 -1
- package/dist/pouchdb/StreamingFetch.js +3 -1
- package/dist/pouchdb/encryption.d.ts +3 -1
- package/dist/pouchdb/encryption.js +38 -30
- package/dist/pouchdb/pathIdentityCompatibility.d.ts +8 -0
- package/dist/pouchdb/pathIdentityCompatibility.js +64 -0
- package/dist/pouchdb/remoteFeatureCompatibility.d.ts +2 -0
- package/dist/pouchdb/remoteFeatureCompatibility.js +9 -1
- package/dist/replication/couchdb/LiveSyncReplicator.js +11 -0
- package/dist/replication/index.d.ts +1 -1
- package/dist/replication/index.js +2 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +2 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.js +28 -6
- package/dist/replication/trystero/P2PRoomSessionOwner.js +18 -5
- package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
- package/dist/replication/trystero/useP2PReplicatorFeature.js +2 -1
- package/dist/serviceFeatures/offlineScanner.d.ts +12 -3
- package/dist/serviceFeatures/offlineScanner.js +4 -2
- package/dist/serviceFeatures/targetFilter.d.ts +1 -1
- package/dist/serviceFeatures/targetFilter.js +28 -17
- package/dist/serviceModules/Rebuilder.d.ts +5 -1
- package/dist/serviceModules/Rebuilder.js +85 -3
- package/dist/serviceModules/ServiceFileHandlerBase.d.ts +5 -2
- package/dist/serviceModules/ServiceFileHandlerBase.js +110 -49
- package/dist/services/base/IService.d.ts +2 -1
- package/dist/services/base/PathService.d.ts +3 -3
- package/dist/services/base/PathService.js +6 -3
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +3 -0
- package/dist/worker/bgWorker.js +1 -1
- package/docs/conflict-resolution.md +17 -8
- package/docs/database-diagnostics.md +24 -0
- package/docs/development.md +1 -0
- package/docs/proven-in-use.md +11 -1
- package/docs/unicode-path-identity.md +98 -0
- package/package.json +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/
|
|
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 {
|
|
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,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
|
+
};
|
|
@@ -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>;
|