@vrtmrz/livesync-commonlib 0.3.0 → 0.3.2
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 +27 -4
- package/dist/API/DirectFileManipulatorV2.d.ts +1 -0
- package/dist/API/DirectFileManipulatorV2.js +6 -12
- package/dist/API/processSetting.d.ts +1 -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 +2 -0
- package/dist/common/models/tweak.definition.d.ts +1 -0
- package/dist/common/pathNamespace.d.ts +4 -0
- package/dist/common/pathNamespace.js +10 -0
- package/dist/common/settingConstants.d.ts +1 -0
- package/dist/common/settingConstants.js +4 -0
- package/dist/platform/AmbiguousNormalisedPathError.d.ts +16 -0
- package/dist/platform/AmbiguousNormalisedPathError.js +21 -0
- package/dist/platform/StoragePathResolver.d.ts +15 -0
- package/dist/platform/StoragePathResolver.js +113 -0
- package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.d.ts +74 -0
- package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.js +169 -0
- package/dist/platform/browser/index.d.ts +1 -0
- package/dist/platform/browser/index.js +2 -0
- package/dist/platform/browser/storage.d.ts +23 -0
- package/dist/platform/browser/storage.js +138 -0
- package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.d.ts +79 -0
- package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.js +218 -0
- package/dist/platform/node/index.d.ts +1 -0
- package/dist/platform/node/index.js +2 -0
- package/dist/platform/node/storage.d.ts +30 -1
- package/dist/platform/node/storage.js +88 -0
- package/dist/platform/pathComparison.d.ts +53 -0
- package/dist/platform/pathComparison.js +39 -0
- package/dist/platform/pathTypes.d.ts +154 -0
- package/dist/platform/pathTypes.js +0 -0
- package/dist/platform/storagePathResolution.d.ts +13 -0
- package/dist/platform/storagePathResolution.js +9 -0
- package/dist/replication/SyncParamsHandler.d.ts +1 -0
- package/dist/replication/SyncParamsHandler.js +5 -1
- package/dist/replication/couchdb/LiveSyncReplicator.js +5 -3
- package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
- package/dist/replication/journal/JournalReceiveFailure.js +16 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
- package/dist/replication/journal/JournalSyncCore.js +288 -166
- package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
- package/dist/replication/journal/journalCompatibility.d.ts +9 -0
- package/dist/replication/journal/journalCompatibility.js +58 -0
- package/dist/replication/journal/journalReceive.d.ts +23 -0
- package/dist/replication/journal/journalReceive.js +44 -0
- package/dist/replication/journal/journalRecords.d.ts +10 -0
- package/dist/replication/journal/journalRecords.js +96 -0
- package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
- package/dist/serviceModules/Rebuilder.js +2 -1
- package/dist/services/base/FileProcessingService.d.ts +2 -2
- package/dist/services/base/RemoteConnection.d.ts +2 -0
- package/dist/services/base/RemoteService.d.ts +1 -1
- package/dist/services/base/RemoteService.js +13 -5
- package/dist/services/implements/injectable/InjectableVaultService.d.ts +1 -1
- package/dist/services/lib/couchdbHttpCache.d.ts +5 -0
- package/dist/services/lib/couchdbHttpCache.js +36 -0
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +19 -0
- package/dist/string_and_binary/path.js +4 -7
- package/dist/worker/bgWorker.js +1 -1
- package/docs/adr/2026_11_shared_storage_path_resolution.md +75 -0
- package/docs/design_docs/storage_path_resolution.md +306 -0
- package/docs/development.md +11 -1
- package/docs/journal-receive-recovery.md +66 -0
- package/docs/platform-storage.md +61 -0
- package/docs/releasing.md +25 -0
- package/docs/unicode-path-identity.md +49 -17
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ This package is primarily for maintainers of those clients and for integrations
|
|
|
14
14
|
|
|
15
15
|
## Status and versioning
|
|
16
16
|
|
|
17
|
-
Commonlib [0.
|
|
18
|
-
is published on npm
|
|
17
|
+
Commonlib [0.3.1](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.1)
|
|
18
|
+
is published on npm. Self-hosted LiveSync
|
|
19
19
|
[1.0.44](https://github.com/vrtmrz/obsidian-livesync/releases/tag/1.0.44)
|
|
20
|
-
uses
|
|
20
|
+
uses Commonlib 0.2.3. The [0.2.3 release notes](updates.md#023) record its
|
|
21
21
|
handler-setter source compatibility change and positive-infinity formatting
|
|
22
22
|
exception.
|
|
23
23
|
|
|
@@ -40,6 +40,21 @@ ordinary replication before resuming local writers. Existing hosts can omit
|
|
|
40
40
|
this optimisation. Resume cursors retain their existing meaning; this resource
|
|
41
41
|
does not provide an arbitrary-time checkpoint jump or On Demand repair.
|
|
42
42
|
|
|
43
|
+
Unreleased CouchDB work separates encrypted HTTP responses by Security Seed
|
|
44
|
+
while retaining conditional revalidation within a Seed. See
|
|
45
|
+
[CouchDB response caching](docs/couchdb-http-cache.md) for the request,
|
|
46
|
+
replication-preparation, and host-adapter boundaries.
|
|
47
|
+
|
|
48
|
+
Journal receipt stops at a per-Journal decode, record, or write failure and
|
|
49
|
+
retries that Journal on the next receive attempt. A host can offer the explicit
|
|
50
|
+
'Skip stopped Journal and resume' operation for the current stopped failure;
|
|
51
|
+
it skips only that Journal, and a later failure stops receipt again. A skip can
|
|
52
|
+
leave content or history incomplete. Use the existing Reset journal received
|
|
53
|
+
history operation to replay entries after correcting the failure, while the
|
|
54
|
+
remote objects remain available and readable. See the
|
|
55
|
+
[Journal receive failures and recovery](docs/journal-receive-recovery.md) for
|
|
56
|
+
the receive, skip, and replay boundaries.
|
|
57
|
+
|
|
43
58
|
## Package entry points
|
|
44
59
|
|
|
45
60
|
| Entry point | Intended use | Current contract status |
|
|
@@ -48,6 +63,7 @@ does not provide an arbitrary-time checkpoint jump or On Demand repair.
|
|
|
48
63
|
| `@vrtmrz/livesync-commonlib/conflicts` | Keyed conflict-check scheduling with bounded concurrency and explicit completion | Focused, package-tested pre-1.0 contract |
|
|
49
64
|
| `@vrtmrz/livesync-commonlib/context` | Instance-owned events, translation injection, and host-neutral standard-I/O types | Focused, package-tested pre-1.0 contract |
|
|
50
65
|
| `@vrtmrz/livesync-commonlib/diagnostics` | Read-only Usage and integrity inspection, name projection, and reports | Focused pre-1.0 diagnostic contract |
|
|
66
|
+
| `@vrtmrz/livesync-commonlib/storage` | Path types, namespace and comparison helpers, and strict Unicode path resolution | Unreleased additive contract; host integration remains explicit |
|
|
51
67
|
| `@vrtmrz/livesync-commonlib/hashing` | Chunk ID calculation and exact file-content hashing | Focused, package-tested pre-1.0 contract |
|
|
52
68
|
| `@vrtmrz/livesync-commonlib/browser` | Rooted File System Access API storage | Focused, package-tested pre-1.0 contract |
|
|
53
69
|
| `@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 |
|
|
@@ -305,10 +321,17 @@ See [the remote configuration profile guide](docs/remote-configurations.md) for
|
|
|
305
321
|
|
|
306
322
|
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.
|
|
307
323
|
|
|
308
|
-
|
|
324
|
+
Commonlib 0.3.0 provides [Unicode NFC path identity](docs/unicode-path-identity.md) with CouchDB, Object Storage, and P2P for maintained CLI and Obsidian host integrations. Application adoption remains in [Self-hosted LiveSync #1309](https://github.com/vrtmrz/obsidian-livesync/pull/1309). Changing an existing database's path identity requires coordinated Rebuild and Fetch. P2P participants must all be updated and migrated.
|
|
309
325
|
|
|
310
326
|
## Direct database watches
|
|
311
327
|
|
|
328
|
+
Commonlib 0.3.1 lets direct CouchDB integrations select
|
|
329
|
+
`pathNormalizationVersion: 1` in `DirectFileManipulator` options. Omitted or
|
|
330
|
+
`0` retains Legacy identity. The configured mode must match the database;
|
|
331
|
+
NFC does not relabel a populated Legacy database. A confirmed empty database
|
|
332
|
+
can declare the selected feature before its first Metadata write. Hosts still
|
|
333
|
+
own physical path resolution and the coordinated migration of existing data.
|
|
334
|
+
|
|
312
335
|
Direct CouchDB reads use the same Chunk arrival waiting and bounded retry processing as application replication. They retrieve missing Chunks through the prepared source connection, including its decryption transforms, without writing fetched Chunks back to that source.
|
|
313
336
|
|
|
314
337
|
Existing integrations can use `DirectFileManipulator.beginWatch(callback, checkIsInterested?, onCheckpoint?)`. The file callback retains its `(doc: ReadyEntry, seq?: string | number)` signature. Resolving it acknowledges delivery. An incomplete read or rejected delivery remains pending and retries after delays from one second up to 30 seconds, while other documents continue. At most four file-processing operations run concurrently. A newer change for the same document supersedes its pending read, including deletion, rename, or exclusion by `checkIsInterested`.
|
|
@@ -15,6 +15,7 @@ export type DirectFileManipulatorOptions = {
|
|
|
15
15
|
obfuscatePassphrase: string | undefined;
|
|
16
16
|
idDerivationVersion?: 0 | 1;
|
|
17
17
|
idDerivationKey?: string;
|
|
18
|
+
pathNormalizationVersion?: 0 | 1;
|
|
18
19
|
encryptInternalMetadata?: boolean;
|
|
19
20
|
useDynamicIterationCount?: boolean;
|
|
20
21
|
customChunkSize?: number;
|
|
@@ -5,18 +5,17 @@ import {
|
|
|
5
5
|
DEFAULT_SYNC_PARAMETERS,
|
|
6
6
|
ProtocolVersions,
|
|
7
7
|
DOCID_SYNC_PARAMETERS,
|
|
8
|
-
VERSIONING_DOCID,
|
|
9
8
|
E2EEAlgorithms,
|
|
10
9
|
VER
|
|
11
10
|
} from "../common/types.js";
|
|
12
11
|
import { checkRemoteVersion } from "../pouchdb/negotiation.js";
|
|
13
12
|
import {
|
|
14
|
-
assessRemoteFeatureDocument,
|
|
15
|
-
NFC_PATH_IDENTITY_FEATURE,
|
|
16
13
|
requiredRemoteFeatures,
|
|
17
14
|
usesEncryptedInternalMetadata
|
|
18
15
|
} from "../pouchdb/remoteFeatureCompatibility.js";
|
|
19
16
|
import { assessRemoteDocumentIds } from "../pouchdb/remoteIdCompatibility.js";
|
|
17
|
+
import { assertDatabasePathIdentity } from "../pouchdb/pathIdentityCompatibility.js";
|
|
18
|
+
import { getPathNormalizationVersion } from "../common/pathNormalization.js";
|
|
20
19
|
import { PouchDB } from "../pouchdb/pouchdb-http.js";
|
|
21
20
|
import { LiveSyncLocalDB } from "../pouchdb/LiveSyncLocalDB.js";
|
|
22
21
|
import { isErrorOfMissingDoc } from "../pouchdb/utils_couchdb.js";
|
|
@@ -71,15 +70,7 @@ class DirectFileManipulator {
|
|
|
71
70
|
if (idCompatibility === "mismatched") {
|
|
72
71
|
throw new Error("Direct database document IDs do not match the configured ID key.");
|
|
73
72
|
}
|
|
74
|
-
|
|
75
|
-
const versionInfo = await this.liveSyncLocalDB.localDatabase.get(VERSIONING_DOCID);
|
|
76
|
-
const assessment = assessRemoteFeatureDocument(versionInfo);
|
|
77
|
-
if (assessment.status === "supported" && assessment.usedFeatures.includes(NFC_PATH_IDENTITY_FEATURE)) {
|
|
78
|
-
throw new Error("Direct database version or features are not compatible.");
|
|
79
|
-
}
|
|
80
|
-
} catch (error) {
|
|
81
|
-
if (!isErrorOfMissingDoc(error)) throw error;
|
|
82
|
-
}
|
|
73
|
+
await assertDatabasePathIdentity(this.liveSyncLocalDB.localDatabase, idSettings);
|
|
83
74
|
const requiredFeatures = requiredRemoteFeatures(this.settings);
|
|
84
75
|
if (!await checkRemoteVersion(
|
|
85
76
|
this.liveSyncLocalDB.localDatabase,
|
|
@@ -229,6 +220,9 @@ class DirectFileManipulator {
|
|
|
229
220
|
passphrase: this.options.passphrase ?? "",
|
|
230
221
|
idDerivationVersion: this.options.idDerivationVersion ?? 0,
|
|
231
222
|
idDerivationKey: this.options.idDerivationKey ?? "",
|
|
223
|
+
pathNormalizationVersion: getPathNormalizationVersion({
|
|
224
|
+
pathNormalizationVersion: this.options.pathNormalizationVersion
|
|
225
|
+
}),
|
|
232
226
|
deleteMetadataOfDeletedFiles: DEFAULT_SETTINGS.deleteMetadataOfDeletedFiles,
|
|
233
227
|
customChunkSize: this.options.customChunkSize ?? DEFAULT_SETTINGS.customChunkSize,
|
|
234
228
|
doNotPaceReplication: DEFAULT_SETTINGS.doNotPaceReplication,
|
|
@@ -156,6 +156,7 @@ export declare function decodeSettingsFromSetupURI(uri: string, passphrase: stri
|
|
|
156
156
|
jwtSub: string;
|
|
157
157
|
jwtExpDuration: number;
|
|
158
158
|
useRequestAPI: boolean;
|
|
159
|
+
useCouchDBCacheControlHeadersOnly?: boolean;
|
|
159
160
|
accessKey: string;
|
|
160
161
|
secretKey: string;
|
|
161
162
|
bucket: string;
|
|
@@ -189,6 +189,7 @@ const SETTINGS_SCHEMA_DEFAULTS = {
|
|
|
189
189
|
jwtSub: "",
|
|
190
190
|
jwtExpDuration: 5,
|
|
191
191
|
useRequestAPI: false,
|
|
192
|
+
useCouchDBCacheControlHeadersOnly: false,
|
|
192
193
|
bucketPrefix: "",
|
|
193
194
|
chunkSplitterVersion: ChunkAlgorithms.RabinKarp,
|
|
194
195
|
E2EEAlgorithm: E2EEAlgorithms.V2,
|
|
@@ -441,6 +441,10 @@ export declare const SETTINGS_PROPERTY_POLICY: {
|
|
|
441
441
|
readonly markdown: "include";
|
|
442
442
|
readonly persistence: "include";
|
|
443
443
|
};
|
|
444
|
+
readonly useCouchDBCacheControlHeadersOnly: {
|
|
445
|
+
readonly markdown: "include";
|
|
446
|
+
readonly persistence: "include";
|
|
447
|
+
};
|
|
444
448
|
readonly accessKey: {
|
|
445
449
|
readonly markdown: "credentials";
|
|
446
450
|
readonly persistence: "connection";
|
|
@@ -111,6 +111,7 @@ const SETTINGS_PROPERTY_POLICY = {
|
|
|
111
111
|
jwtSub: SettingPolicies.ConnectionCredentials,
|
|
112
112
|
jwtExpDuration: SettingPolicies.Default,
|
|
113
113
|
useRequestAPI: SettingPolicies.Default,
|
|
114
|
+
useCouchDBCacheControlHeadersOnly: SettingPolicies.Default,
|
|
114
115
|
accessKey: SettingPolicies.ConnectionCredentials,
|
|
115
116
|
secretKey: SettingPolicies.ConnectionCredentials,
|
|
116
117
|
bucket: SettingPolicies.LocalConnection,
|
|
@@ -38,6 +38,8 @@ export interface CouchDBConnection {
|
|
|
38
38
|
* Seems stable, so promoted to the normal setting.
|
|
39
39
|
*/
|
|
40
40
|
useRequestAPI: boolean;
|
|
41
|
+
/** Control CouchDB HTTP caching through headers alone. Defaults to false. */
|
|
42
|
+
useCouchDBCacheControlHeadersOnly?: boolean;
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
43
45
|
* Interface representing the settings for periodic replication.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { ICHeader, ICXHeader, PSCHeader } from "./models/fileaccess.const.js";
|
|
2
|
+
export type PathNamespace = "" | typeof ICHeader | typeof ICXHeader | typeof PSCHeader;
|
|
3
|
+
/** Separate one recognised Metadata namespace while retaining the body's spelling. */
|
|
4
|
+
export declare function splitPathNamespace(path: string): readonly [PathNamespace, string];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ICHeader, ICXHeader, PSCHeader } from "./models/fileaccess.const.js";
|
|
2
|
+
function splitPathNamespace(path) {
|
|
3
|
+
for (const prefix of [ICHeader, ICXHeader, PSCHeader]) {
|
|
4
|
+
if (path.startsWith(prefix)) return [prefix, path.slice(prefix.length)];
|
|
5
|
+
}
|
|
6
|
+
return ["", path];
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
splitPathNamespace
|
|
10
|
+
};
|
|
@@ -375,6 +375,10 @@ const SettingInformation = {
|
|
|
375
375
|
name: "Use Request API to avoid `inevitable` CORS problem",
|
|
376
376
|
desc: "If enabled, the request API will be used to avoid `inevitable` CORS problems. This is a workaround and may not work in all cases. PLEASE READ THE DOCUMENTATION BEFORE USING THIS OPTION. This is a less-secure option."
|
|
377
377
|
},
|
|
378
|
+
useCouchDBCacheControlHeadersOnly: {
|
|
379
|
+
name: "Use headers only for HTTP cache control (compatibility)",
|
|
380
|
+
desc: "Remove the query parameter used to invalidate cached responses (the same URL behaviour as in 1.0.44 and earlier). Enable only if your CouchDB server or proxy rejects the additional cache query parameter. Older cached encrypted responses may then be reused after a remote Rebuild."
|
|
381
|
+
},
|
|
378
382
|
hideFileWarningNotice: {
|
|
379
383
|
name: "Show status icon instead of file warnings banner",
|
|
380
384
|
desc: "If enabled, the \u26D4 icon will be shown inside the status instead of the file warnings banner. No details will be shown."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** More than one listed entry has the requested logical identity. */
|
|
2
|
+
export declare class AmbiguousNormalisedPathError extends Error {
|
|
3
|
+
/** Path whose logical identity was ambiguous. */
|
|
4
|
+
readonly path: string;
|
|
5
|
+
/** Physical candidate paths copied from the listing which exposed the ambiguity. */
|
|
6
|
+
readonly candidates: readonly string[];
|
|
7
|
+
/**
|
|
8
|
+
* Describe an ambiguous request without choosing a physical candidate.
|
|
9
|
+
*
|
|
10
|
+
* @param path - Path whose logical identity matched multiple entries.
|
|
11
|
+
* @param candidates - Matching physical paths, copied into a frozen array.
|
|
12
|
+
*/
|
|
13
|
+
constructor(
|
|
14
|
+
/** Path whose logical identity was ambiguous. */
|
|
15
|
+
path: string, candidates?: readonly string[]);
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class AmbiguousNormalisedPathError extends Error {
|
|
2
|
+
/**
|
|
3
|
+
* Describe an ambiguous request without choosing a physical candidate.
|
|
4
|
+
*
|
|
5
|
+
* @param path - Path whose logical identity matched multiple entries.
|
|
6
|
+
* @param candidates - Matching physical paths, copied into a frozen array.
|
|
7
|
+
*/
|
|
8
|
+
constructor(path, candidates = []) {
|
|
9
|
+
const detail = candidates.length ? ` (${candidates.map((name) => JSON.stringify(name)).join(", ")})` : "";
|
|
10
|
+
super(`Ambiguous path after Unicode normalisation: ${path}${detail}`);
|
|
11
|
+
this.path = path;
|
|
12
|
+
this.name = "AmbiguousNormalisedPathError";
|
|
13
|
+
this.candidates = Object.freeze([...candidates]);
|
|
14
|
+
}
|
|
15
|
+
path;
|
|
16
|
+
/** Physical candidate paths copied from the listing which exposed the ambiguity. */
|
|
17
|
+
candidates;
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
AmbiguousNormalisedPathError
|
|
21
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FilePath, ResolvedRename, StorageEntry, StoragePath, StoragePathResolver as ResolverContract, StoragePathResolverOptions } from "./pathTypes.js";
|
|
2
|
+
/** Resolves listed names using one immutable NFC policy and no retained directory cache. */
|
|
3
|
+
export declare class StoragePathResolver implements ResolverContract {
|
|
4
|
+
private readonly readDirectory;
|
|
5
|
+
private readonly policy;
|
|
6
|
+
constructor({ readDirectory, policy }: StoragePathResolverOptions);
|
|
7
|
+
resolveExisting(path: FilePath): Promise<StoragePath | null>;
|
|
8
|
+
resolveForWrite(path: FilePath): Promise<StoragePath>;
|
|
9
|
+
resolveRename(source: FilePath, target: FilePath): Promise<ResolvedRename | null>;
|
|
10
|
+
listResolvedDirectory(path: StoragePath): Promise<readonly StorageEntry[] | null>;
|
|
11
|
+
private resolve;
|
|
12
|
+
private findEntry;
|
|
13
|
+
private readEntries;
|
|
14
|
+
private key;
|
|
15
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { getPathNormalizationVersion } from "../common/pathNormalization.js";
|
|
2
|
+
import { AmbiguousNormalisedPathError } from "./AmbiguousNormalisedPathError.js";
|
|
3
|
+
import { getStoragePathComparisonKey } from "./pathComparison.js";
|
|
4
|
+
import { validateStoragePath } from "./storagePath.js";
|
|
5
|
+
function storagePath(path) {
|
|
6
|
+
return validateStoragePath(path);
|
|
7
|
+
}
|
|
8
|
+
function childPath(parent, name) {
|
|
9
|
+
return storagePath(parent === "" ? name : `${parent}/${name}`);
|
|
10
|
+
}
|
|
11
|
+
function segments(path) {
|
|
12
|
+
validateStoragePath(path);
|
|
13
|
+
return path.split("/").filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
class StoragePathResolver {
|
|
16
|
+
readDirectory;
|
|
17
|
+
policy;
|
|
18
|
+
constructor({ readDirectory, policy }) {
|
|
19
|
+
if (getPathNormalizationVersion(policy) !== 1) {
|
|
20
|
+
throw new Error("Storage path resolution requires NFC path identity");
|
|
21
|
+
}
|
|
22
|
+
if (typeof policy.handleFilenameCaseSensitive !== "boolean") {
|
|
23
|
+
throw new TypeError("Filename case handling must be explicit");
|
|
24
|
+
}
|
|
25
|
+
this.readDirectory = readDirectory;
|
|
26
|
+
this.policy = Object.freeze({ ...policy });
|
|
27
|
+
}
|
|
28
|
+
async resolveExisting(path) {
|
|
29
|
+
return await this.resolve(path, false);
|
|
30
|
+
}
|
|
31
|
+
async resolveForWrite(path) {
|
|
32
|
+
const resolved = await this.resolve(path, true);
|
|
33
|
+
if (resolved === null) throw new Error(`Could not resolve storage target: ${path}`);
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
async resolveRename(source, target) {
|
|
37
|
+
validateStoragePath(source, false);
|
|
38
|
+
validateStoragePath(target, false);
|
|
39
|
+
const resolvedSource = await this.resolveExisting(source);
|
|
40
|
+
if (resolvedSource === null) return null;
|
|
41
|
+
const resolvedTarget = await this.resolveForWrite(target);
|
|
42
|
+
if (resolvedTarget !== resolvedSource || target === resolvedSource) {
|
|
43
|
+
return { source: resolvedSource, target: resolvedTarget };
|
|
44
|
+
}
|
|
45
|
+
const requestedParts = segments(target);
|
|
46
|
+
const leaf = requestedParts[requestedParts.length - 1];
|
|
47
|
+
const parent = storagePath(resolvedTarget.slice(0, Math.max(0, resolvedTarget.lastIndexOf("/"))));
|
|
48
|
+
return { source: resolvedSource, target: childPath(parent, leaf) };
|
|
49
|
+
}
|
|
50
|
+
async listResolvedDirectory(path) {
|
|
51
|
+
validateStoragePath(path);
|
|
52
|
+
const entries = await this.readEntries(path);
|
|
53
|
+
if (entries === null) return null;
|
|
54
|
+
const names = /* @__PURE__ */ new Map();
|
|
55
|
+
return entries.map((entry) => {
|
|
56
|
+
const actualPath = childPath(path, entry.name);
|
|
57
|
+
const key = this.key(entry.name);
|
|
58
|
+
const previous = names.get(key);
|
|
59
|
+
if (previous !== void 0) {
|
|
60
|
+
throw new AmbiguousNormalisedPathError(path, [previous, actualPath]);
|
|
61
|
+
}
|
|
62
|
+
names.set(key, actualPath);
|
|
63
|
+
return { path: actualPath, kind: entry.kind };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async resolve(path, allowMissing) {
|
|
67
|
+
const parts = segments(path);
|
|
68
|
+
let parent = storagePath("");
|
|
69
|
+
if (parts.length === 0) {
|
|
70
|
+
return allowMissing || await this.readEntries(parent) !== null ? parent : null;
|
|
71
|
+
}
|
|
72
|
+
for (let index = 0; index < parts.length; index++) {
|
|
73
|
+
const entries = await this.readEntries(parent);
|
|
74
|
+
const match = entries === null ? void 0 : this.findEntry(entries, parts[index], parent, path);
|
|
75
|
+
if (!match) {
|
|
76
|
+
return allowMissing ? childPath(parent, parts.slice(index).join("/")) : null;
|
|
77
|
+
}
|
|
78
|
+
parent = childPath(parent, match.name);
|
|
79
|
+
if (index < parts.length - 1 && match.kind !== "directory") {
|
|
80
|
+
throw new Error(`A storage path parent is not a directory: ${parent}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return parent;
|
|
84
|
+
}
|
|
85
|
+
findEntry(entries, name, parent, requestedPath) {
|
|
86
|
+
const key = this.key(name);
|
|
87
|
+
const matches = entries.filter((entry) => this.key(entry.name) === key);
|
|
88
|
+
if (matches.length > 1) {
|
|
89
|
+
throw new AmbiguousNormalisedPathError(
|
|
90
|
+
requestedPath,
|
|
91
|
+
matches.map((entry) => childPath(parent, entry.name))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return matches[0];
|
|
95
|
+
}
|
|
96
|
+
async readEntries(path) {
|
|
97
|
+
const entries = await this.readDirectory(path);
|
|
98
|
+
if (entries === null) return null;
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (typeof entry.name !== "string" || entry.name === "" || entry.name === "." || entry.name === ".." || /[\/\\\0]/u.test(entry.name) || entry.kind !== "file" && entry.kind !== "directory") {
|
|
101
|
+
throw new TypeError(`Invalid directory entry below ${JSON.stringify(path)}`);
|
|
102
|
+
}
|
|
103
|
+
childPath(path, entry.name);
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
key(name) {
|
|
108
|
+
return getStoragePathComparisonKey(name, this.policy);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export {
|
|
112
|
+
StoragePathResolver
|
|
113
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { UXDataWriteOptions } from "../../common/types.js";
|
|
2
|
+
import type { IStorageAdapter } from "../../serviceModules/adapters/IStorageAdapter.js";
|
|
3
|
+
import type { FilePath, StoragePath } from "../pathTypes.js";
|
|
4
|
+
import { type FileSystemAccessStorageStat } from "./storage.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve NFC-equivalent logical paths against current File System Access directory names.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Each operation snapshots Unicode and case settings. Version `0` delegates to
|
|
10
|
+
* the existing adapter; version `1` resolves names and rejects observed aliases
|
|
11
|
+
* before I/O. Physical spelling and the host adapter's mutation semantics remain
|
|
12
|
+
* unchanged. Resolution does not reserve paths against concurrent changes.
|
|
13
|
+
*/
|
|
14
|
+
export declare class UnicodeResolvingFileSystemAccessStorageAdapter implements IStorageAdapter<FileSystemAccessStorageStat> {
|
|
15
|
+
private readonly getPathNormalizationVersion;
|
|
16
|
+
private readonly isCaseInsensitive;
|
|
17
|
+
private readonly storage;
|
|
18
|
+
/**
|
|
19
|
+
* Bind a host-owned root and callbacks for each operation's comparison settings.
|
|
20
|
+
*
|
|
21
|
+
* @param rootHandle - Host-selected directory handle with the required permissions.
|
|
22
|
+
* @param getPathNormalizationVersion - Supplies `0` or `1`; defaults to Legacy `0`.
|
|
23
|
+
* @param isCaseInsensitive - Whether comparisons ignore case; defaults to false.
|
|
24
|
+
*/
|
|
25
|
+
constructor(rootHandle: FileSystemDirectoryHandle, getPathNormalizationVersion?: () => number, isCaseInsensitive?: () => boolean);
|
|
26
|
+
/** Whether the current settings select NFC path resolution. */
|
|
27
|
+
get isUnicodePathNormalizationEnabled(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a logical path under one snapshot of the current settings.
|
|
30
|
+
*
|
|
31
|
+
* @param path - Original root-relative file path.
|
|
32
|
+
* @param caseInsensitive - Override this lookup's case rule without changing settings.
|
|
33
|
+
* @param allowMissing - Keep a missing write suffix when true; defaults to true.
|
|
34
|
+
* @returns The physical target, or `null` for an absent path when `allowMissing`
|
|
35
|
+
* is false in NFC mode. Legacy mode returns the validated original path.
|
|
36
|
+
* @throws Propagates NFC ambiguity and reader failures, and rejects invalid input.
|
|
37
|
+
*/
|
|
38
|
+
resolveLogicalPath(path: FilePath, caseInsensitive?: boolean, allowMissing?: boolean): Promise<StoragePath | null>;
|
|
39
|
+
/** Read metadata for a physical path without performing another logical lookup. */
|
|
40
|
+
statExact(path: StoragePath): Promise<FileSystemAccessStorageStat | null>;
|
|
41
|
+
/** List a physical directory, rejecting equivalent child names in NFC mode. */
|
|
42
|
+
listExact(path: StoragePath): Promise<{
|
|
43
|
+
files: string[];
|
|
44
|
+
folders: string[];
|
|
45
|
+
}>;
|
|
46
|
+
/** Check whether the logical path exists under the current Unicode policy. */
|
|
47
|
+
exists(path: string): Promise<boolean>;
|
|
48
|
+
/** Read logical-path metadata, retaining Legacy adapter behaviour in version `0`. */
|
|
49
|
+
trystat(path: string): Promise<FileSystemAccessStorageStat | null>;
|
|
50
|
+
/** Read logical-path metadata, retaining Legacy adapter behaviour in version `0`. */
|
|
51
|
+
stat(path: string): Promise<FileSystemAccessStorageStat | null>;
|
|
52
|
+
/** Create a directory at the resolved write target. */
|
|
53
|
+
mkdir(path: string): Promise<void>;
|
|
54
|
+
/** Remove the entry at its resolved physical path. */
|
|
55
|
+
remove(path: string): Promise<void>;
|
|
56
|
+
/** Read text from the resolved physical file. */
|
|
57
|
+
read(path: string): Promise<string>;
|
|
58
|
+
/** Read bytes from the resolved physical file. */
|
|
59
|
+
readBinary(path: string): Promise<ArrayBuffer>;
|
|
60
|
+
/** Write text at the resolved target while retaining existing physical spelling. */
|
|
61
|
+
write(path: string, data: string, options?: UXDataWriteOptions): Promise<void>;
|
|
62
|
+
/** Write bytes at the resolved target while retaining existing physical spelling. */
|
|
63
|
+
writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void>;
|
|
64
|
+
/** Append text at the resolved target using the host adapter semantics. */
|
|
65
|
+
append(path: string, data: string, options?: UXDataWriteOptions): Promise<void>;
|
|
66
|
+
/** List logical directory contents, checking equivalent child names in NFC mode. */
|
|
67
|
+
list(basePath: string): Promise<{
|
|
68
|
+
files: string[];
|
|
69
|
+
folders: string[];
|
|
70
|
+
}>;
|
|
71
|
+
private snapshotPolicy;
|
|
72
|
+
private createResolver;
|
|
73
|
+
private groupStorageEntries;
|
|
74
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { getPathNormalizationVersion } from "../../common/pathNormalization.js";
|
|
2
|
+
import { parseFilePath } from "../pathComparison.js";
|
|
3
|
+
import { createStoragePathResolver } from "../storagePathResolution.js";
|
|
4
|
+
import { FileSystemAccessStorageAdapter } from "./storage.js";
|
|
5
|
+
class UnicodeResolvingFileSystemAccessStorageAdapter {
|
|
6
|
+
/**
|
|
7
|
+
* Bind a host-owned root and callbacks for each operation's comparison settings.
|
|
8
|
+
*
|
|
9
|
+
* @param rootHandle - Host-selected directory handle with the required permissions.
|
|
10
|
+
* @param getPathNormalizationVersion - Supplies `0` or `1`; defaults to Legacy `0`.
|
|
11
|
+
* @param isCaseInsensitive - Whether comparisons ignore case; defaults to false.
|
|
12
|
+
*/
|
|
13
|
+
constructor(rootHandle, getPathNormalizationVersion2 = () => 0, isCaseInsensitive = () => false) {
|
|
14
|
+
this.getPathNormalizationVersion = getPathNormalizationVersion2;
|
|
15
|
+
this.isCaseInsensitive = isCaseInsensitive;
|
|
16
|
+
this.storage = new FileSystemAccessStorageAdapter(rootHandle);
|
|
17
|
+
}
|
|
18
|
+
getPathNormalizationVersion;
|
|
19
|
+
isCaseInsensitive;
|
|
20
|
+
storage;
|
|
21
|
+
/** Whether the current settings select NFC path resolution. */
|
|
22
|
+
get isUnicodePathNormalizationEnabled() {
|
|
23
|
+
return this.snapshotPolicy().pathNormalizationVersion === 1;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a logical path under one snapshot of the current settings.
|
|
27
|
+
*
|
|
28
|
+
* @param path - Original root-relative file path.
|
|
29
|
+
* @param caseInsensitive - Override this lookup's case rule without changing settings.
|
|
30
|
+
* @param allowMissing - Keep a missing write suffix when true; defaults to true.
|
|
31
|
+
* @returns The physical target, or `null` for an absent path when `allowMissing`
|
|
32
|
+
* is false in NFC mode. Legacy mode returns the validated original path.
|
|
33
|
+
* @throws Propagates NFC ambiguity and reader failures, and rejects invalid input.
|
|
34
|
+
*/
|
|
35
|
+
async resolveLogicalPath(path, caseInsensitive, allowMissing = true) {
|
|
36
|
+
const policy = this.snapshotPolicy(caseInsensitive);
|
|
37
|
+
const logicalPath = parseFilePath(path);
|
|
38
|
+
if (policy.pathNormalizationVersion === 0) return logicalPath;
|
|
39
|
+
const resolver = this.createResolver(policy);
|
|
40
|
+
return allowMissing ? await resolver.resolveForWrite(logicalPath) : await resolver.resolveExisting(logicalPath);
|
|
41
|
+
}
|
|
42
|
+
/** Read metadata for a physical path without performing another logical lookup. */
|
|
43
|
+
async statExact(path) {
|
|
44
|
+
const policy = this.snapshotPolicy();
|
|
45
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.trystat(path);
|
|
46
|
+
return await this.storage.statResolved(path);
|
|
47
|
+
}
|
|
48
|
+
/** List a physical directory, rejecting equivalent child names in NFC mode. */
|
|
49
|
+
async listExact(path) {
|
|
50
|
+
const policy = this.snapshotPolicy();
|
|
51
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.list(path);
|
|
52
|
+
const entries = await this.createResolver(policy).listResolvedDirectory(path);
|
|
53
|
+
return this.groupStorageEntries(entries ?? []);
|
|
54
|
+
}
|
|
55
|
+
/** Check whether the logical path exists under the current Unicode policy. */
|
|
56
|
+
async exists(path) {
|
|
57
|
+
const policy = this.snapshotPolicy();
|
|
58
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.exists(path);
|
|
59
|
+
return await this.createResolver(policy).resolveExisting(parseFilePath(path)) !== null;
|
|
60
|
+
}
|
|
61
|
+
/** Read logical-path metadata, retaining Legacy adapter behaviour in version `0`. */
|
|
62
|
+
async trystat(path) {
|
|
63
|
+
const policy = this.snapshotPolicy();
|
|
64
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.trystat(path);
|
|
65
|
+
const resolved = await this.createResolver(policy).resolveExisting(parseFilePath(path));
|
|
66
|
+
return resolved === null ? null : await this.storage.statResolved(resolved);
|
|
67
|
+
}
|
|
68
|
+
/** Read logical-path metadata, retaining Legacy adapter behaviour in version `0`. */
|
|
69
|
+
async stat(path) {
|
|
70
|
+
return await this.trystat(path);
|
|
71
|
+
}
|
|
72
|
+
/** Create a directory at the resolved write target. */
|
|
73
|
+
async mkdir(path) {
|
|
74
|
+
const policy = this.snapshotPolicy();
|
|
75
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.mkdir(path);
|
|
76
|
+
const resolved = await this.createResolver(policy).resolveForWrite(parseFilePath(path));
|
|
77
|
+
await this.storage.mkdirResolved(resolved);
|
|
78
|
+
}
|
|
79
|
+
/** Remove the entry at its resolved physical path. */
|
|
80
|
+
async remove(path) {
|
|
81
|
+
const policy = this.snapshotPolicy();
|
|
82
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.remove(path);
|
|
83
|
+
const resolved = await this.createResolver(policy).resolveExisting(parseFilePath(path));
|
|
84
|
+
if (resolved !== null) await this.storage.removeResolved(resolved);
|
|
85
|
+
}
|
|
86
|
+
/** Read text from the resolved physical file. */
|
|
87
|
+
async read(path) {
|
|
88
|
+
const policy = this.snapshotPolicy();
|
|
89
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.read(path);
|
|
90
|
+
const resolved = await this.createResolver(policy).resolveExisting(parseFilePath(path));
|
|
91
|
+
if (resolved === null) throw new Error(`File not found: ${path}`);
|
|
92
|
+
return await this.storage.readResolved(resolved);
|
|
93
|
+
}
|
|
94
|
+
/** Read bytes from the resolved physical file. */
|
|
95
|
+
async readBinary(path) {
|
|
96
|
+
const policy = this.snapshotPolicy();
|
|
97
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.readBinary(path);
|
|
98
|
+
const resolved = await this.createResolver(policy).resolveExisting(parseFilePath(path));
|
|
99
|
+
if (resolved === null) throw new Error(`File not found: ${path}`);
|
|
100
|
+
return await this.storage.readBinaryResolved(resolved);
|
|
101
|
+
}
|
|
102
|
+
/** Write text at the resolved target while retaining existing physical spelling. */
|
|
103
|
+
async write(path, data, options) {
|
|
104
|
+
const policy = this.snapshotPolicy();
|
|
105
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.write(path, data, options);
|
|
106
|
+
const resolved = await this.createResolver(policy).resolveForWrite(parseFilePath(path));
|
|
107
|
+
await this.storage.writeResolved(resolved, data, options);
|
|
108
|
+
}
|
|
109
|
+
/** Write bytes at the resolved target while retaining existing physical spelling. */
|
|
110
|
+
async writeBinary(path, data, options) {
|
|
111
|
+
const policy = this.snapshotPolicy();
|
|
112
|
+
if (policy.pathNormalizationVersion === 0) {
|
|
113
|
+
return await this.storage.writeBinary(path, data, options);
|
|
114
|
+
}
|
|
115
|
+
const resolved = await this.createResolver(policy).resolveForWrite(parseFilePath(path));
|
|
116
|
+
await this.storage.writeBinaryResolved(resolved, data, options);
|
|
117
|
+
}
|
|
118
|
+
/** Append text at the resolved target using the host adapter semantics. */
|
|
119
|
+
async append(path, data, options) {
|
|
120
|
+
const policy = this.snapshotPolicy();
|
|
121
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.append(path, data, options);
|
|
122
|
+
const resolved = await this.createResolver(policy).resolveForWrite(parseFilePath(path));
|
|
123
|
+
await this.storage.appendResolved(resolved, data, options);
|
|
124
|
+
}
|
|
125
|
+
/** List logical directory contents, checking equivalent child names in NFC mode. */
|
|
126
|
+
async list(basePath) {
|
|
127
|
+
const policy = this.snapshotPolicy();
|
|
128
|
+
if (policy.pathNormalizationVersion === 0) return await this.storage.list(basePath);
|
|
129
|
+
const resolver = this.createResolver(policy);
|
|
130
|
+
const resolved = await resolver.resolveExisting(parseFilePath(basePath));
|
|
131
|
+
if (resolved === null) return { files: [], folders: [] };
|
|
132
|
+
const entries = await resolver.listResolvedDirectory(resolved);
|
|
133
|
+
return this.groupStorageEntries(entries ?? []);
|
|
134
|
+
}
|
|
135
|
+
snapshotPolicy(caseInsensitive) {
|
|
136
|
+
const pathNormalizationVersion = getPathNormalizationVersion({
|
|
137
|
+
pathNormalizationVersion: this.getPathNormalizationVersion()
|
|
138
|
+
});
|
|
139
|
+
const isCaseInsensitive = caseInsensitive ?? this.isCaseInsensitive();
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
pathNormalizationVersion,
|
|
142
|
+
handleFilenameCaseSensitive: !isCaseInsensitive
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
createResolver(policy) {
|
|
146
|
+
if (policy.pathNormalizationVersion !== 1) {
|
|
147
|
+
throw new Error("The storage path resolver requires NFC path normalisation");
|
|
148
|
+
}
|
|
149
|
+
return createStoragePathResolver({
|
|
150
|
+
readDirectory: (path) => this.storage.readDirectory(path),
|
|
151
|
+
policy: {
|
|
152
|
+
pathNormalizationVersion: 1,
|
|
153
|
+
handleFilenameCaseSensitive: policy.handleFilenameCaseSensitive
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
groupStorageEntries(entries) {
|
|
158
|
+
const files = [];
|
|
159
|
+
const folders = [];
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.kind === "file") files.push(entry.path);
|
|
162
|
+
else folders.push(entry.path);
|
|
163
|
+
}
|
|
164
|
+
return { files, folders };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export {
|
|
168
|
+
UnicodeResolvingFileSystemAccessStorageAdapter
|
|
169
|
+
};
|
|
@@ -1 +1,2 @@
|
|
|
1
1
|
export { FileSystemAccessStorageAdapter, createFileSystemAccessStorage, validateStoragePath, type CreateFileSystemAccessStorageOptions, type FileSystemAccessStorageStat, } from "./storage.js";
|
|
2
|
+
export { UnicodeResolvingFileSystemAccessStorageAdapter } from "./UnicodeResolvingFileSystemAccessStorageAdapter.js";
|