@vrtmrz/livesync-commonlib 0.3.0 → 0.3.1

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 (57) hide show
  1. package/README.md +17 -4
  2. package/dist/API/DirectFileManipulatorV2.d.ts +1 -0
  3. package/dist/API/DirectFileManipulatorV2.js +6 -12
  4. package/dist/API/processSetting.d.ts +1 -0
  5. package/dist/common/models/setting.const.defaults.js +1 -0
  6. package/dist/common/models/setting.const.qr.js +1 -0
  7. package/dist/common/models/setting.policy.d.ts +4 -0
  8. package/dist/common/models/setting.policy.js +1 -0
  9. package/dist/common/models/setting.type.d.ts +2 -0
  10. package/dist/common/models/tweak.definition.d.ts +1 -0
  11. package/dist/common/pathNamespace.d.ts +4 -0
  12. package/dist/common/pathNamespace.js +10 -0
  13. package/dist/common/settingConstants.d.ts +1 -0
  14. package/dist/common/settingConstants.js +4 -0
  15. package/dist/platform/AmbiguousNormalisedPathError.d.ts +16 -0
  16. package/dist/platform/AmbiguousNormalisedPathError.js +21 -0
  17. package/dist/platform/StoragePathResolver.d.ts +15 -0
  18. package/dist/platform/StoragePathResolver.js +113 -0
  19. package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.d.ts +74 -0
  20. package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.js +169 -0
  21. package/dist/platform/browser/index.d.ts +1 -0
  22. package/dist/platform/browser/index.js +2 -0
  23. package/dist/platform/browser/storage.d.ts +23 -0
  24. package/dist/platform/browser/storage.js +138 -0
  25. package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.d.ts +79 -0
  26. package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.js +218 -0
  27. package/dist/platform/node/index.d.ts +1 -0
  28. package/dist/platform/node/index.js +2 -0
  29. package/dist/platform/node/storage.d.ts +30 -1
  30. package/dist/platform/node/storage.js +88 -0
  31. package/dist/platform/pathComparison.d.ts +53 -0
  32. package/dist/platform/pathComparison.js +39 -0
  33. package/dist/platform/pathTypes.d.ts +154 -0
  34. package/dist/platform/pathTypes.js +0 -0
  35. package/dist/platform/storagePathResolution.d.ts +13 -0
  36. package/dist/platform/storagePathResolution.js +9 -0
  37. package/dist/replication/couchdb/LiveSyncReplicator.js +5 -3
  38. package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
  39. package/dist/serviceModules/Rebuilder.js +2 -1
  40. package/dist/services/base/FileProcessingService.d.ts +2 -2
  41. package/dist/services/base/RemoteConnection.d.ts +2 -0
  42. package/dist/services/base/RemoteService.d.ts +1 -1
  43. package/dist/services/base/RemoteService.js +13 -5
  44. package/dist/services/implements/injectable/InjectableVaultService.d.ts +1 -1
  45. package/dist/services/lib/couchdbHttpCache.d.ts +5 -0
  46. package/dist/services/lib/couchdbHttpCache.js +36 -0
  47. package/dist/storage.d.ts +4 -0
  48. package/dist/storage.js +19 -0
  49. package/dist/string_and_binary/path.js +4 -7
  50. package/dist/worker/bgWorker.js +1 -1
  51. package/docs/adr/2026_11_shared_storage_path_resolution.md +73 -0
  52. package/docs/design_docs/storage_path_resolution.md +305 -0
  53. package/docs/development.md +11 -1
  54. package/docs/platform-storage.md +61 -0
  55. package/docs/releasing.md +25 -0
  56. package/docs/unicode-path-identity.md +48 -17
  57. 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.2.3](https://github.com/vrtmrz/livesync-commonlib/releases/tag/0.2.3)
18
- is published on npm through both `latest` and `next`. Self-hosted LiveSync
17
+ Commonlib [0.3.0](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.0)
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 that exact version. See the [0.2.3 release notes](updates.md#023) for the
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,11 @@ 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
+
43
48
  ## Package entry points
44
49
 
45
50
  | Entry point | Intended use | Current contract status |
@@ -48,6 +53,7 @@ does not provide an arbitrary-time checkpoint jump or On Demand repair.
48
53
  | `@vrtmrz/livesync-commonlib/conflicts` | Keyed conflict-check scheduling with bounded concurrency and explicit completion | Focused, package-tested pre-1.0 contract |
49
54
  | `@vrtmrz/livesync-commonlib/context` | Instance-owned events, translation injection, and host-neutral standard-I/O types | Focused, package-tested pre-1.0 contract |
50
55
  | `@vrtmrz/livesync-commonlib/diagnostics` | Read-only Usage and integrity inspection, name projection, and reports | Focused pre-1.0 diagnostic contract |
56
+ | `@vrtmrz/livesync-commonlib/storage` | Path types, namespace and comparison helpers, and strict Unicode path resolution | Unreleased additive contract; host integration remains explicit |
51
57
  | `@vrtmrz/livesync-commonlib/hashing` | Chunk ID calculation and exact file-content hashing | Focused, package-tested pre-1.0 contract |
52
58
  | `@vrtmrz/livesync-commonlib/browser` | Rooted File System Access API storage | Focused, package-tested pre-1.0 contract |
53
59
  | `@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 +311,17 @@ See [the remote configuration profile guide](docs/remote-configurations.md) for
305
311
 
306
312
  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
313
 
308
- The maintained CLI and Obsidian hosts can opt into [Unicode NFC path identity](docs/unicode-path-identity.md) with CouchDB, Object Storage, and P2P in the unreleased transport extension. Existing databases require an explicit Rebuild or Fetch migration before using it. P2P participants must all be updated and migrated.
314
+ 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
315
 
310
316
  ## Direct database watches
311
317
 
318
+ An unreleased extension lets direct CouchDB integrations select
319
+ `pathNormalizationVersion: 1` in `DirectFileManipulator` options. Omitted or
320
+ `0` retains Legacy identity. The configured mode must match the database;
321
+ NFC does not relabel a populated Legacy database. A confirmed empty database
322
+ can declare the selected feature before its first Metadata write. Hosts still
323
+ own physical path resolution and the coordinated migration of existing data.
324
+
312
325
  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
326
 
314
327
  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
- try {
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,
@@ -184,6 +184,7 @@ const KeyIndexOfSettings = {
184
184
  idDerivationKey: 167,
185
185
  pathNormalizationVersion: 168,
186
186
  batchSaveInLiveSync: 169,
187
+ useCouchDBCacheControlHeadersOnly: 170,
187
188
  encryptedIdDerivationKey: -1
188
189
  };
189
190
  export {
@@ -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.
@@ -238,6 +238,7 @@ export declare const TweakValuesTemplate: {
238
238
  jwtSub?: string;
239
239
  jwtExpDuration?: number;
240
240
  useRequestAPI?: boolean;
241
+ useCouchDBCacheControlHeadersOnly?: boolean;
241
242
  accessKey?: string;
242
243
  secretKey?: string;
243
244
  bucket?: string;
@@ -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
+ };
@@ -126,6 +126,7 @@ export declare const AllSettingDefault: {
126
126
  jwtSub: string;
127
127
  jwtExpDuration: number;
128
128
  useRequestAPI: boolean;
129
+ useCouchDBCacheControlHeadersOnly?: boolean;
129
130
  accessKey: string;
130
131
  secretKey: string;
131
132
  bucket: string;
@@ -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";
@@ -3,8 +3,10 @@ import {
3
3
  createFileSystemAccessStorage,
4
4
  validateStoragePath
5
5
  } from "./storage.js";
6
+ import { UnicodeResolvingFileSystemAccessStorageAdapter } from "./UnicodeResolvingFileSystemAccessStorageAdapter.js";
6
7
  export {
7
8
  FileSystemAccessStorageAdapter,
9
+ UnicodeResolvingFileSystemAccessStorageAdapter,
8
10
  createFileSystemAccessStorage,
9
11
  validateStoragePath
10
12
  };