@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.
- package/README.md +17 -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/couchdb/LiveSyncReplicator.js +5 -3
- 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 +73 -0
- package/docs/design_docs/storage_path_resolution.md +305 -0
- package/docs/development.md +11 -1
- package/docs/platform-storage.md +61 -0
- package/docs/releasing.md +25 -0
- package/docs/unicode-path-identity.md +48 -17
- package/package.json +9 -1
|
@@ -59,15 +59,61 @@ class NodeStorageAdapter {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** Read metadata for an exact physical path, returning null only when it is absent. */
|
|
63
|
+
async statResolved(path) {
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = await nodeFsPromises.stat(await this.resolvePath(path));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === "ENOENT") return null;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
size: stat.size,
|
|
73
|
+
mtime: Math.floor(stat.mtimeMs),
|
|
74
|
+
ctime: getNodeFileCreationTime(stat),
|
|
75
|
+
type: stat.isDirectory() ? "folder" : "file"
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Enumerate immediate physical children, returning null only when the directory is absent. */
|
|
79
|
+
async readDirectory(path) {
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await nodeFsPromises.readdir(await this.resolvePath(path), { withFileTypes: true });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code === "ENOENT") return null;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const directoryEntries = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.isDirectory()) directoryEntries.push({ name: entry.name, kind: "directory" });
|
|
90
|
+
else if (entry.isFile()) directoryEntries.push({ name: entry.name, kind: "file" });
|
|
91
|
+
}
|
|
92
|
+
return directoryEntries;
|
|
93
|
+
}
|
|
62
94
|
async stat(path) {
|
|
63
95
|
return await this.trystat(path);
|
|
64
96
|
}
|
|
65
97
|
async mkdir(path) {
|
|
98
|
+
await this.mkdirPath(path);
|
|
99
|
+
}
|
|
100
|
+
/** Create an exact resolved directory path. */
|
|
101
|
+
async mkdirResolved(path) {
|
|
102
|
+
await this.mkdirPath(path);
|
|
103
|
+
}
|
|
104
|
+
async mkdirPath(path) {
|
|
66
105
|
const fullPath = await this.resolvePath(path);
|
|
67
106
|
await nodeFsPromises.mkdir(fullPath, { recursive: true });
|
|
68
107
|
await this.assertNoSymbolicLinks(fullPath);
|
|
69
108
|
}
|
|
70
109
|
async remove(path) {
|
|
110
|
+
await this.removePath(path);
|
|
111
|
+
}
|
|
112
|
+
/** Remove an exact resolved file or directory path. */
|
|
113
|
+
async removeResolved(path) {
|
|
114
|
+
await this.removePath(path);
|
|
115
|
+
}
|
|
116
|
+
async removePath(path) {
|
|
71
117
|
const fullPath = await this.resolvePath(path, false);
|
|
72
118
|
const stat = await nodeFsPromises.stat(fullPath);
|
|
73
119
|
if (stat.isDirectory()) {
|
|
@@ -77,6 +123,13 @@ class NodeStorageAdapter {
|
|
|
77
123
|
}
|
|
78
124
|
}
|
|
79
125
|
async read(path) {
|
|
126
|
+
return await this.readPath(path);
|
|
127
|
+
}
|
|
128
|
+
/** Read a file by its exact resolved physical path. */
|
|
129
|
+
async readResolved(path) {
|
|
130
|
+
return await this.readPath(path);
|
|
131
|
+
}
|
|
132
|
+
async readPath(path) {
|
|
80
133
|
const handle = await nodeFsPromises.open(
|
|
81
134
|
await this.resolvePath(path, false),
|
|
82
135
|
nodeFsConstants.O_RDONLY | NO_FOLLOW
|
|
@@ -88,6 +141,13 @@ class NodeStorageAdapter {
|
|
|
88
141
|
}
|
|
89
142
|
}
|
|
90
143
|
async readBinary(path) {
|
|
144
|
+
return await this.readBinaryPath(path);
|
|
145
|
+
}
|
|
146
|
+
/** Read bytes by their exact resolved physical path. */
|
|
147
|
+
async readBinaryResolved(path) {
|
|
148
|
+
return await this.readBinaryPath(path);
|
|
149
|
+
}
|
|
150
|
+
async readBinaryPath(path) {
|
|
91
151
|
const handle = await nodeFsPromises.open(
|
|
92
152
|
await this.resolvePath(path, false),
|
|
93
153
|
nodeFsConstants.O_RDONLY | NO_FOLLOW
|
|
@@ -100,22 +160,50 @@ class NodeStorageAdapter {
|
|
|
100
160
|
}
|
|
101
161
|
}
|
|
102
162
|
async write(path, data, options) {
|
|
163
|
+
await this.writePath(path, data, options);
|
|
164
|
+
}
|
|
165
|
+
/** Write text to an exact resolved physical path. */
|
|
166
|
+
async writeResolved(path, data, options) {
|
|
167
|
+
await this.writePath(path, data, options);
|
|
168
|
+
}
|
|
169
|
+
async writePath(path, data, options) {
|
|
103
170
|
const fullPath = await this.resolvePath(path, false);
|
|
104
171
|
await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
|
|
105
172
|
await this.writeFile(fullPath, data, options, false);
|
|
106
173
|
}
|
|
107
174
|
async writeBinary(path, data, options) {
|
|
175
|
+
await this.writeBinaryPath(path, data, options);
|
|
176
|
+
}
|
|
177
|
+
/** Write bytes to an exact resolved physical path. */
|
|
178
|
+
async writeBinaryResolved(path, data, options) {
|
|
179
|
+
await this.writeBinaryPath(path, data, options);
|
|
180
|
+
}
|
|
181
|
+
async writeBinaryPath(path, data, options) {
|
|
108
182
|
const fullPath = await this.resolvePath(path, false);
|
|
109
183
|
await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
|
|
110
184
|
await this.writeFile(fullPath, new Uint8Array(data), options, false);
|
|
111
185
|
}
|
|
112
186
|
async append(path, data, options) {
|
|
187
|
+
await this.appendPath(path, data, options);
|
|
188
|
+
}
|
|
189
|
+
/** Append text to an exact resolved physical path. */
|
|
190
|
+
async appendResolved(path, data, options) {
|
|
191
|
+
await this.appendPath(path, data, options);
|
|
192
|
+
}
|
|
193
|
+
async appendPath(path, data, options) {
|
|
113
194
|
const fullPath = await this.resolvePath(path, false);
|
|
114
195
|
await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
|
|
115
196
|
await this.writeFile(fullPath, data, options, true);
|
|
116
197
|
}
|
|
117
198
|
/** Atomically rename or move an entry without leaving the injected root. */
|
|
118
199
|
async rename(sourcePath, targetPath) {
|
|
200
|
+
await this.renamePath(sourcePath, targetPath);
|
|
201
|
+
}
|
|
202
|
+
/** Rename or move exact resolved physical paths without leaving the injected root. */
|
|
203
|
+
async renameResolved(sourcePath, targetPath) {
|
|
204
|
+
await this.renamePath(sourcePath, targetPath);
|
|
205
|
+
}
|
|
206
|
+
async renamePath(sourcePath, targetPath) {
|
|
119
207
|
const fullSourcePath = await this.resolvePath(sourcePath, false);
|
|
120
208
|
const fullTargetPath = await this.resolvePath(targetPath, false);
|
|
121
209
|
await nodeFsPromises.mkdir(nodePath.dirname(fullTargetPath), { recursive: true });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DocumentPath, DocumentPathParts, FilePath, PathComparisonKey, PathComparisonSettings } from "./pathTypes.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate raw storage input without changing its spelling.
|
|
4
|
+
*
|
|
5
|
+
* @param path - A slash-separated path relative to the host-selected root.
|
|
6
|
+
* @returns The original path branded as `FilePath`; the empty path denotes the root.
|
|
7
|
+
* @throws Rejects absolute, drive-qualified, backslash, and traversal-segment input.
|
|
8
|
+
* @remarks Does not normalise Unicode, resolve physical names, or check existence.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseFilePath(path: string): FilePath;
|
|
11
|
+
/**
|
|
12
|
+
* Mark an original document path before routing its Metadata namespace.
|
|
13
|
+
*
|
|
14
|
+
* @param path - Original document-path text, optionally including one namespace.
|
|
15
|
+
* @returns The same text branded as `DocumentPath`.
|
|
16
|
+
* @throws Rejects a non-string value.
|
|
17
|
+
* @remarks Storage validation follows namespace routing. This function does not
|
|
18
|
+
* recover original spelling from a comparison key or validate a physical path.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseDocumentPath(path: string): DocumentPath;
|
|
21
|
+
/**
|
|
22
|
+
* Split one recognised Metadata namespace from an original document path.
|
|
23
|
+
*
|
|
24
|
+
* @param path - Original document path; a further colon remains in the file path.
|
|
25
|
+
* @returns The namespace, or an empty prefix, and the original remaining spelling.
|
|
26
|
+
* @remarks Does not validate the resulting file path for storage access.
|
|
27
|
+
*/
|
|
28
|
+
export declare function splitDocumentPath(path: DocumentPath): DocumentPathParts;
|
|
29
|
+
/**
|
|
30
|
+
* Join a routed namespace and its original file path without normalising either.
|
|
31
|
+
*
|
|
32
|
+
* @param parts - One recognised namespace and an original file path.
|
|
33
|
+
* @returns Their concatenation as an original document path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function joinDocumentPath({ prefix, path }: DocumentPathParts): DocumentPath;
|
|
36
|
+
/**
|
|
37
|
+
* Derive an identity key for a file path within one storage root.
|
|
38
|
+
*
|
|
39
|
+
* @param path - Original file path without a Metadata namespace.
|
|
40
|
+
* @param settings - Explicit Unicode and case comparison settings.
|
|
41
|
+
* @returns A storage-domain comparison key; retain the original path for I/O.
|
|
42
|
+
* @throws Rejects an unsupported path normalisation version.
|
|
43
|
+
*/
|
|
44
|
+
export declare function getStoragePathComparisonKey(path: FilePath, settings: PathComparisonSettings): PathComparisonKey<"storage">;
|
|
45
|
+
/**
|
|
46
|
+
* Derive a document identity key while preserving its Metadata namespace.
|
|
47
|
+
*
|
|
48
|
+
* @param path - Original document path, optionally including one namespace.
|
|
49
|
+
* @param settings - Explicit Unicode and case comparison settings for the body.
|
|
50
|
+
* @returns A document-domain key which distinguishes namespaces with equal bodies.
|
|
51
|
+
* @throws Rejects an unsupported path normalisation version.
|
|
52
|
+
*/
|
|
53
|
+
export declare function getDocumentPathComparisonKey(path: DocumentPath, settings: PathComparisonSettings): PathComparisonKey<"document">;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { normalisePathForIdentity } from "../common/pathNormalization.js";
|
|
2
|
+
import { splitPathNamespace } from "../common/pathNamespace.js";
|
|
3
|
+
import { validateStoragePath } from "./storagePath.js";
|
|
4
|
+
function parseFilePath(path) {
|
|
5
|
+
return validateStoragePath(path);
|
|
6
|
+
}
|
|
7
|
+
function parseDocumentPath(path) {
|
|
8
|
+
if (typeof path !== "string") throw new TypeError("A document path must be a string");
|
|
9
|
+
return path;
|
|
10
|
+
}
|
|
11
|
+
function splitDocumentPath(path) {
|
|
12
|
+
const [prefix, body] = splitPathNamespace(path);
|
|
13
|
+
return { prefix, path: body };
|
|
14
|
+
}
|
|
15
|
+
function joinDocumentPath({ prefix, path }) {
|
|
16
|
+
return prefix + path;
|
|
17
|
+
}
|
|
18
|
+
function normaliseFilePath(path) {
|
|
19
|
+
return normalisePathForIdentity(path, 1);
|
|
20
|
+
}
|
|
21
|
+
function comparisonPath(path, settings) {
|
|
22
|
+
const normalised = settings.pathNormalizationVersion === 1 ? normaliseFilePath(path) : normalisePathForIdentity(path, settings.pathNormalizationVersion);
|
|
23
|
+
return settings.handleFilenameCaseSensitive ? normalised : normalised.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
function getStoragePathComparisonKey(path, settings) {
|
|
26
|
+
return comparisonPath(path, settings);
|
|
27
|
+
}
|
|
28
|
+
function getDocumentPathComparisonKey(path, settings) {
|
|
29
|
+
const { prefix, path: body } = splitDocumentPath(path);
|
|
30
|
+
return prefix + comparisonPath(body, settings);
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
getDocumentPathComparisonKey,
|
|
34
|
+
getStoragePathComparisonKey,
|
|
35
|
+
joinDocumentPath,
|
|
36
|
+
parseDocumentPath,
|
|
37
|
+
parseFilePath,
|
|
38
|
+
splitDocumentPath
|
|
39
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { TaggedType } from "octagonal-wheels/common/types";
|
|
2
|
+
import type { FilePath, FilePathWithPrefix, FilePathWithPrefixLC } from "../common/models/db.type.js";
|
|
3
|
+
import type { PathNormalizationVersion } from "../common/models/setting.type.js";
|
|
4
|
+
import type { PathNamespace } from "../common/pathNamespace.js";
|
|
5
|
+
/**
|
|
6
|
+
* An original file path without a Metadata namespace.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* This compatibility type does not establish NFC form, physical spelling, or
|
|
10
|
+
* existence. Validate raw storage input with `parseFilePath`.
|
|
11
|
+
*/
|
|
12
|
+
export type { FilePath };
|
|
13
|
+
/**
|
|
14
|
+
* An original document path with an optional recognised Metadata namespace.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Excludes the legacy comparison-key variant. Route the namespace before
|
|
18
|
+
* validating the remaining file path for storage access.
|
|
19
|
+
*/
|
|
20
|
+
export type DocumentPath = Exclude<FilePathWithPrefix, FilePathWithPrefixLC>;
|
|
21
|
+
declare const unicodeForm: unique symbol;
|
|
22
|
+
declare const storagePath: unique symbol;
|
|
23
|
+
/**
|
|
24
|
+
* A file path in Unicode NFC form, without case conversion.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* NFC form alone does not establish physical storage spelling or existence.
|
|
28
|
+
* This refinement is used within normalisation helpers and is not re-exported
|
|
29
|
+
* by the focused storage entry point.
|
|
30
|
+
*/
|
|
31
|
+
export type NfcFilePath = FilePath & {
|
|
32
|
+
readonly [unicodeForm]: "NFC";
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* A root-relative storage target whose existing components use resolved spelling.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Produced by resolution or supported physical enumeration. A write target may
|
|
39
|
+
* contain missing components; this type does not guarantee existence, entry
|
|
40
|
+
* kind, or protection against concurrent changes. The empty path denotes the
|
|
41
|
+
* root, subject to each operation's root rules.
|
|
42
|
+
*/
|
|
43
|
+
export type StoragePath = FilePath & {
|
|
44
|
+
readonly [storagePath]: true;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A path identity key produced by the configured Unicode and case comparison.
|
|
48
|
+
*
|
|
49
|
+
* @typeParam Domain - `storage` compares within one storage root; `document`
|
|
50
|
+
* retains the Metadata namespace so different document kinds remain distinct.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Retain the original path separately. A comparison key cannot establish an
|
|
54
|
+
* original filename and must not be passed to storage or namespace operations.
|
|
55
|
+
*/
|
|
56
|
+
export type PathComparisonKey<Domain extends "storage" | "document"> = TaggedType<string, `PathComparisonKey:${Domain}`>;
|
|
57
|
+
/** Unicode and case settings used to derive path comparison keys. */
|
|
58
|
+
export type PathComparisonSettings = Readonly<{
|
|
59
|
+
/** `0` retains Legacy Unicode handling; `1` compares Unicode NFC forms. */
|
|
60
|
+
pathNormalizationVersion: PathNormalizationVersion;
|
|
61
|
+
/** Preserve letter case when true; otherwise apply the existing `toLowerCase()` rule. */
|
|
62
|
+
handleFilenameCaseSensitive: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/** The namespace and original file path obtained by splitting one document path. */
|
|
65
|
+
export type DocumentPathParts = Readonly<{
|
|
66
|
+
/** One recognised Metadata namespace, or the empty string for an ordinary file. */
|
|
67
|
+
prefix: PathNamespace;
|
|
68
|
+
/** Original spelling after removing that one namespace. */
|
|
69
|
+
path: FilePath;
|
|
70
|
+
}>;
|
|
71
|
+
/** One immediate physical child returned by a strict directory reader. */
|
|
72
|
+
export type DirectoryEntry = Readonly<{
|
|
73
|
+
/** Unmodified child name, rather than a relative path containing multiple components. */
|
|
74
|
+
name: string;
|
|
75
|
+
/** Entry kind reported by the storage host. */
|
|
76
|
+
kind: "file" | "directory";
|
|
77
|
+
}>;
|
|
78
|
+
/** A physical child path and its kind, ready for a storage scan to consume. */
|
|
79
|
+
export type StorageEntry = Readonly<{
|
|
80
|
+
/** Root-relative path retaining the directory listing's actual spelling. */
|
|
81
|
+
path: StoragePath;
|
|
82
|
+
/** Entry kind from the same directory listing. */
|
|
83
|
+
kind: DirectoryEntry["kind"];
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Read a directory's complete immediate children using actual names and kinds.
|
|
87
|
+
*
|
|
88
|
+
* @param physicalDirectory - Resolved directory path relative to the host's root.
|
|
89
|
+
* @returns The children, including an empty array for an empty directory, or
|
|
90
|
+
* `null` only when the directory is absent.
|
|
91
|
+
* @throws Propagates permission, non-directory, and other I/O failures instead
|
|
92
|
+
* of reporting absence or a successful partial listing.
|
|
93
|
+
*/
|
|
94
|
+
export type ReadDirectory = (physicalDirectory: StoragePath) => Promise<readonly DirectoryEntry[] | null>;
|
|
95
|
+
/** Resolved paths for a rename which the storage host has yet to perform. */
|
|
96
|
+
export type ResolvedRename = Readonly<{
|
|
97
|
+
/** Existing source spelling. */
|
|
98
|
+
source: StoragePath;
|
|
99
|
+
/** Resolved target, retaining a requested leaf spelling change where applicable. */
|
|
100
|
+
target: StoragePath;
|
|
101
|
+
}>;
|
|
102
|
+
/**
|
|
103
|
+
* Resolve NFC-equivalent names through strict directory reads under one policy.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* Operations reject ambiguous candidates even when an exact spelling exists,
|
|
107
|
+
* propagate reader failures, and perform no filesystem mutation. Resolution
|
|
108
|
+
* does not reserve a path against concurrent changes; hosts own that lifecycle.
|
|
109
|
+
*/
|
|
110
|
+
export interface StoragePathResolver {
|
|
111
|
+
/**
|
|
112
|
+
* Resolve an existing file or directory using its physical spelling.
|
|
113
|
+
*
|
|
114
|
+
* @param path - Original path relative to the selected storage root.
|
|
115
|
+
* @returns The resolved path, or `null` when a component is absent.
|
|
116
|
+
*/
|
|
117
|
+
resolveExisting(path: FilePath): Promise<StoragePath | null>;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a write target while preserving any missing suffix as requested.
|
|
120
|
+
*
|
|
121
|
+
* @param path - Original target path relative to the selected storage root.
|
|
122
|
+
* @returns The resolved target; this operation creates no missing entries.
|
|
123
|
+
*/
|
|
124
|
+
resolveForWrite(path: FilePath): Promise<StoragePath>;
|
|
125
|
+
/**
|
|
126
|
+
* Resolve both sides of an explicit rename, including a leaf spelling change.
|
|
127
|
+
*
|
|
128
|
+
* @param source - Original source entry path; the root is not an entry.
|
|
129
|
+
* @param target - Requested target entry path; the root is not an entry.
|
|
130
|
+
* @returns Both paths, or `null` when the source is absent. The host retains
|
|
131
|
+
* responsibility for performing the rename and handling replacement.
|
|
132
|
+
*/
|
|
133
|
+
resolveRename(source: FilePath, target: FilePath): Promise<ResolvedRename | null>;
|
|
134
|
+
/**
|
|
135
|
+
* Enumerate an already resolved directory without resolving each child again.
|
|
136
|
+
*
|
|
137
|
+
* @param path - Physical directory path relative to the selected root.
|
|
138
|
+
* @returns Child paths and kinds, or `null` when the directory is absent.
|
|
139
|
+
* @throws Rejects equivalent child names as ambiguous before returning a listing.
|
|
140
|
+
*/
|
|
141
|
+
listResolvedDirectory(path: StoragePath): Promise<readonly StorageEntry[] | null>;
|
|
142
|
+
}
|
|
143
|
+
/** Dependencies and comparison policy for constructing an NFC path resolver. */
|
|
144
|
+
export type StoragePathResolverOptions = Readonly<{
|
|
145
|
+
/** Strict host enumeration; it must distinguish absence from I/O failure. */
|
|
146
|
+
readDirectory: ReadDirectory;
|
|
147
|
+
/** Policy copied at construction; construct a new resolver when settings change. */
|
|
148
|
+
policy: Readonly<{
|
|
149
|
+
/** The shared resolver accepts NFC mode only; Legacy hosts retain their existing paths. */
|
|
150
|
+
pathNormalizationVersion: 1;
|
|
151
|
+
/** Preserve letter case when true; otherwise compare using `toLowerCase()`. */
|
|
152
|
+
handleFilenameCaseSensitive: boolean;
|
|
153
|
+
}>;
|
|
154
|
+
}>;
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StoragePathResolver as ResolverContract, StoragePathResolverOptions } from "./pathTypes.js";
|
|
2
|
+
export type { DirectoryEntry, ReadDirectory, ResolvedRename, StorageEntry, StoragePathResolver, StoragePathResolverOptions, } from "./pathTypes.js";
|
|
3
|
+
export { AmbiguousNormalisedPathError } from "./AmbiguousNormalisedPathError.js";
|
|
4
|
+
/**
|
|
5
|
+
* Bind strict directory reads to a copied NFC resolution policy.
|
|
6
|
+
*
|
|
7
|
+
* @param options - Host enumeration and explicit NFC/case settings.
|
|
8
|
+
* @returns A resolver which reads current listings without retaining a directory cache.
|
|
9
|
+
* @throws Rejects a path version other than `1` or a non-boolean case setting.
|
|
10
|
+
* @remarks Construction performs no I/O. Resolution performs no mutation or
|
|
11
|
+
* atomic reservation; construct a new resolver when its comparison settings change.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createStoragePathResolver(options: StoragePathResolverOptions): ResolverContract;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StoragePathResolver } from "./StoragePathResolver.js";
|
|
2
|
+
import { AmbiguousNormalisedPathError } from "./AmbiguousNormalisedPathError.js";
|
|
3
|
+
function createStoragePathResolver(options) {
|
|
4
|
+
return new StoragePathResolver(options);
|
|
5
|
+
}
|
|
6
|
+
export {
|
|
7
|
+
AmbiguousNormalisedPathError,
|
|
8
|
+
createStoragePathResolver
|
|
9
|
+
};
|
|
@@ -169,7 +169,8 @@ class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicator {
|
|
|
169
169
|
value.enableCompression,
|
|
170
170
|
value.pathNormalizationVersion,
|
|
171
171
|
value.readChunksOnline,
|
|
172
|
-
value.useRequestAPI
|
|
172
|
+
value.useRequestAPI,
|
|
173
|
+
value.useCouchDBCacheControlHeadersOnly === true
|
|
173
174
|
]);
|
|
174
175
|
const binding = identity(snapshot);
|
|
175
176
|
let connection;
|
|
@@ -721,7 +722,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
721
722
|
if (cancellationRequested()) {
|
|
722
723
|
return false;
|
|
723
724
|
}
|
|
724
|
-
if (await this.ensurePBKDF2Salt(setting, showResult,
|
|
725
|
+
if (await this.ensurePBKDF2Salt(setting, showResult, false) === false || cancellationRequested()) {
|
|
725
726
|
return false;
|
|
726
727
|
}
|
|
727
728
|
if (this.controller) {
|
|
@@ -1442,7 +1443,8 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1442
1443
|
{
|
|
1443
1444
|
...connectionOptions,
|
|
1444
1445
|
encryptionAlgorithm: settings.E2EEAlgorithm,
|
|
1445
|
-
encryptInternalMetadata: usesEncryptedInternalMetadata(settings)
|
|
1446
|
+
encryptInternalMetadata: usesEncryptedInternalMetadata(settings),
|
|
1447
|
+
useCouchDBCacheControlHeadersOnly: settings.useCouchDBCacheControlHeadersOnly === true
|
|
1446
1448
|
}
|
|
1447
1449
|
);
|
|
1448
1450
|
}
|
|
@@ -242,6 +242,7 @@ export declare class TrysteroReplicator {
|
|
|
242
242
|
jwtSub?: string;
|
|
243
243
|
jwtExpDuration?: number;
|
|
244
244
|
useRequestAPI?: boolean;
|
|
245
|
+
useCouchDBCacheControlHeadersOnly?: boolean;
|
|
245
246
|
accessKey?: string;
|
|
246
247
|
secretKey?: string;
|
|
247
248
|
bucket?: string;
|
|
@@ -432,6 +433,7 @@ export declare class TrysteroReplicator {
|
|
|
432
433
|
jwtSub?: string;
|
|
433
434
|
jwtExpDuration?: number;
|
|
434
435
|
useRequestAPI?: boolean;
|
|
436
|
+
useCouchDBCacheControlHeadersOnly?: boolean;
|
|
435
437
|
accessKey?: string;
|
|
436
438
|
secretKey?: string;
|
|
437
439
|
bucket?: string;
|
|
@@ -222,7 +222,8 @@ Please enable them from the settings screen after setup is complete.`,
|
|
|
222
222
|
settings.useRequestAPI,
|
|
223
223
|
async () => {
|
|
224
224
|
throw new Error("A version document does not require decryption.");
|
|
225
|
-
}
|
|
225
|
+
},
|
|
226
|
+
{ useCouchDBCacheControlHeadersOnly: settings.useCouchDBCacheControlHeadersOnly === true }
|
|
226
227
|
);
|
|
227
228
|
if (typeof connection === "string") throw new Error(connection);
|
|
228
229
|
try {
|
|
@@ -12,13 +12,13 @@ export declare class FileProcessingService<T extends ServiceContext = ServiceCon
|
|
|
12
12
|
* Dispatch a legacy optional notification, without confirming persistence.
|
|
13
13
|
* i.e., hidden files synchronisation or customisation sync.
|
|
14
14
|
*/
|
|
15
|
-
readonly processOptionalFileEvent: import("../lib/HandlerUtils.js").BooleanMultipleHandlerFunction<(path: import("../../
|
|
15
|
+
readonly processOptionalFileEvent: import("../lib/HandlerUtils.js").BooleanMultipleHandlerFunction<(path: import("../../storage.js").FilePath) => Promise<boolean>>;
|
|
16
16
|
/**
|
|
17
17
|
* Complete optional file operations through the first applicable handler.
|
|
18
18
|
* Hosts migrate all optional providers together. Once any typed provider is
|
|
19
19
|
* registered, dispatch uses this contract exclusively.
|
|
20
20
|
*/
|
|
21
|
-
readonly processOptionalFileEventWithResult?: import("../lib/HandlerUtils.js").FirstHandledFunction<(path: import("../../
|
|
21
|
+
readonly processOptionalFileEventWithResult?: import("../lib/HandlerUtils.js").FirstHandledFunction<(path: import("../../storage.js").FilePath, options?: import("./IService.js").OptionalFileEventOptions) => Promise<import("../lib/HandlerUtils.js").HandlerOutcome<void, string>>>;
|
|
22
22
|
/**
|
|
23
23
|
* Commit any pending file events that have been queued for processing.
|
|
24
24
|
*/
|
|
@@ -8,6 +8,8 @@ export interface RemoteConnectionOpenOptions {
|
|
|
8
8
|
readonly encryptionAlgorithm?: E2EEAlgorithm;
|
|
9
9
|
/** Apply HKDF Metadata encryption to obfuscated internal entries on writes. */
|
|
10
10
|
readonly encryptInternalMetadata?: boolean;
|
|
11
|
+
/** Use HTTP headers alone for cache control; an absent value is false. */
|
|
12
|
+
readonly useCouchDBCacheControlHeadersOnly?: boolean;
|
|
11
13
|
/**
|
|
12
14
|
* Cancels abort-capable requests made through the connection when the
|
|
13
15
|
* owning operation is cancelled.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CouchDBCredentials, type EntryDoc } from "../../common/types.js";
|
|
2
2
|
import type { IRemoteService } from "./IService.js";
|
|
3
3
|
import { ServiceBase, type ServiceContext } from "./ServiceBase.js";
|
|
4
4
|
import { type LOG_LEVEL } from "../../common/types.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { E2EEAlgorithms } from "../../common/types.js";
|
|
1
2
|
import { ServiceBase } from "./ServiceBase.js";
|
|
2
3
|
import {
|
|
3
4
|
LOG_LEVEL_DEBUG,
|
|
@@ -12,6 +13,7 @@ import { AuthorizationHeaderGenerator } from "../../replication/httplib.js";
|
|
|
12
13
|
import { UnresolvedErrorManager } from "./UnresolvedErrorManager.js";
|
|
13
14
|
import { createInstanceLogFunction, MARK_LOG_NETWORK_ERROR } from "../lib/logUtils.js";
|
|
14
15
|
import { runWithTrackedPhysicalRequest } from "../lib/remoteActivity.js";
|
|
16
|
+
import { createCouchDBHttpCache } from "../lib/couchdbHttpCache.js";
|
|
15
17
|
import { LiveSyncError } from "../../common/LSError.js";
|
|
16
18
|
function createRemoteConnectionScope(ownerSignal) {
|
|
17
19
|
const controller = new AbortController();
|
|
@@ -188,6 +190,12 @@ class RemoteService extends ServiceBase {
|
|
|
188
190
|
return "Network is offline";
|
|
189
191
|
}
|
|
190
192
|
const connectionScope = createRemoteConnectionScope(connectionOptions.signal);
|
|
193
|
+
const encryptionAlgorithm = connectionOptions.encryptionAlgorithm ?? this._settingService.currentSettings().E2EEAlgorithm;
|
|
194
|
+
const prepareCachedRequest = createCouchDBHttpCache(
|
|
195
|
+
uri,
|
|
196
|
+
typeof passphrase === "string" && passphrase !== "false" && encryptionAlgorithm !== E2EEAlgorithms.ForceV1 ? getPBKDF2Salt : void 0,
|
|
197
|
+
connectionOptions.useCouchDBCacheControlHeadersOnly === true
|
|
198
|
+
);
|
|
191
199
|
const conf = {
|
|
192
200
|
adapter: "http",
|
|
193
201
|
auth: "username" in auth ? auth : void 0,
|
|
@@ -222,11 +230,12 @@ class RemoteService extends ServiceBase {
|
|
|
222
230
|
if (!("username" in auth)) {
|
|
223
231
|
headers.append("authorization", authHeader);
|
|
224
232
|
}
|
|
233
|
+
const cachedRequest = await prepareCachedRequest(requestSrc, opts);
|
|
225
234
|
try {
|
|
226
235
|
const signal = connectionScope.combine(opts?.signal);
|
|
227
236
|
const response = await this.performFetch(
|
|
228
|
-
|
|
229
|
-
{ ...opts, headers, signal, cache:
|
|
237
|
+
cachedRequest.request,
|
|
238
|
+
{ ...opts, headers, signal, cache: cachedRequest.cache },
|
|
230
239
|
useRequestAPI ? FetchMethod.native : FetchMethod.webCompat
|
|
231
240
|
);
|
|
232
241
|
return response;
|
|
@@ -243,8 +252,8 @@ class RemoteService extends ServiceBase {
|
|
|
243
252
|
"Failed to fetch by native fetch API. Trying to fetch by API to get more information."
|
|
244
253
|
);
|
|
245
254
|
const resp2 = await this.performFetch(
|
|
246
|
-
|
|
247
|
-
{ ...opts, headers, cache:
|
|
255
|
+
cachedRequest.request,
|
|
256
|
+
{ ...opts, headers, cache: cachedRequest.cache },
|
|
248
257
|
FetchMethod.native
|
|
249
258
|
);
|
|
250
259
|
if (resp2.status / 100 == 2) {
|
|
@@ -279,7 +288,6 @@ class RemoteService extends ServiceBase {
|
|
|
279
288
|
}
|
|
280
289
|
}
|
|
281
290
|
};
|
|
282
|
-
const encryptionAlgorithm = connectionOptions.encryptionAlgorithm ?? this._settingService.currentSettings().E2EEAlgorithm;
|
|
283
291
|
let db;
|
|
284
292
|
try {
|
|
285
293
|
db = new this._pouchDB(uri, conf);
|
|
@@ -4,6 +4,6 @@ export declare abstract class InjectableVaultService<T extends ServiceContext> e
|
|
|
4
4
|
}
|
|
5
5
|
export declare class InjectableVaultServiceCompat<T extends ServiceContext> extends InjectableVaultService<T> {
|
|
6
6
|
isStorageInsensitive: import("../../lib/HandlerUtils.js").HandlerFunction<() => boolean, unknown>;
|
|
7
|
-
getActiveFilePath: import("../../lib/HandlerUtils.js").HandlerFunction<() => import("../../../
|
|
7
|
+
getActiveFilePath: import("../../lib/HandlerUtils.js").HandlerFunction<() => import("../../../storage.js").FilePath | undefined, unknown>;
|
|
8
8
|
isValidPath(path: string): boolean;
|
|
9
9
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Keep encrypted HTTP responses within the Security Seed which produced them. */
|
|
2
|
+
export declare function createCouchDBHttpCache(uri: string, getPBKDF2Salt?: () => Promise<Uint8Array<ArrayBuffer>>, headersOnly?: boolean): (request: string | Request, options?: RequestInit) => Promise<{
|
|
3
|
+
request: string | Request;
|
|
4
|
+
cache: RequestCache;
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { uint8ArrayToHexString } from "../../string_and_binary/convert.js";
|
|
2
|
+
const processEpoch = Math.floor(Date.now() / 1e3);
|
|
3
|
+
function createCouchDBHttpCache(uri, getPBKDF2Salt, headersOnly = false) {
|
|
4
|
+
const databasePath = new URL(uri).pathname.replace(/\/$/, "");
|
|
5
|
+
let previousSalt;
|
|
6
|
+
let namespace;
|
|
7
|
+
const getNamespace = async () => {
|
|
8
|
+
const salt = await getPBKDF2Salt();
|
|
9
|
+
if (!previousSalt || previousSalt.length !== salt.length || previousSalt.some((byte, i) => byte !== salt[i])) {
|
|
10
|
+
previousSalt = salt.slice();
|
|
11
|
+
const epoch = new TextEncoder().encode(`${processEpoch}:`);
|
|
12
|
+
const input = new Uint8Array(epoch.length + previousSalt.length);
|
|
13
|
+
input.set(epoch);
|
|
14
|
+
input.set(previousSalt, epoch.length);
|
|
15
|
+
namespace = crypto.subtle.digest("SHA-256", input).then((digest) => uint8ArrayToHexString(new Uint8Array(digest)));
|
|
16
|
+
}
|
|
17
|
+
return namespace;
|
|
18
|
+
};
|
|
19
|
+
return async (request, options) => {
|
|
20
|
+
const method = (options?.method ?? (typeof request === "string" ? "GET" : request.method)).toUpperCase();
|
|
21
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
22
|
+
return { request, cache: options?.cache };
|
|
23
|
+
}
|
|
24
|
+
if (headersOnly) return { request, cache: "no-store" };
|
|
25
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
26
|
+
const relativePath = url.pathname.startsWith(`${databasePath}/`) ? decodeURIComponent(url.pathname.slice(databasePath.length + 1)) : "";
|
|
27
|
+
if (getPBKDF2Salt && relativePath && !relativePath.startsWith("_local/")) {
|
|
28
|
+
url.searchParams.set("_livesync_epoch", await getNamespace());
|
|
29
|
+
request = typeof request === "string" ? url.href : new Request(url.href, request);
|
|
30
|
+
}
|
|
31
|
+
return { request, cache: "no-cache" };
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
createCouchDBHttpCache
|
|
36
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Host-neutral path identity, namespace, and storage resolution contracts. */
|
|
2
|
+
export type { FilePath, DocumentPath, StoragePath, PathComparisonKey, PathComparisonSettings, DocumentPathParts, DirectoryEntry, ReadDirectory, ResolvedRename, StorageEntry, StoragePathResolver, StoragePathResolverOptions, } from "./platform/pathTypes.js";
|
|
3
|
+
export { parseFilePath, parseDocumentPath, splitDocumentPath, joinDocumentPath, getStoragePathComparisonKey, getDocumentPathComparisonKey, } from "./platform/pathComparison.js";
|
|
4
|
+
export { createStoragePathResolver, AmbiguousNormalisedPathError } from "./platform/storagePathResolution.js";
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseFilePath,
|
|
3
|
+
parseDocumentPath,
|
|
4
|
+
splitDocumentPath,
|
|
5
|
+
joinDocumentPath,
|
|
6
|
+
getStoragePathComparisonKey,
|
|
7
|
+
getDocumentPathComparisonKey
|
|
8
|
+
} from "./platform/pathComparison.js";
|
|
9
|
+
import { createStoragePathResolver, AmbiguousNormalisedPathError } from "./platform/storagePathResolution.js";
|
|
10
|
+
export {
|
|
11
|
+
AmbiguousNormalisedPathError,
|
|
12
|
+
createStoragePathResolver,
|
|
13
|
+
getDocumentPathComparisonKey,
|
|
14
|
+
getStoragePathComparisonKey,
|
|
15
|
+
joinDocumentPath,
|
|
16
|
+
parseDocumentPath,
|
|
17
|
+
parseFilePath,
|
|
18
|
+
splitDocumentPath
|
|
19
|
+
};
|