@statewalker/webrun-files-composite 0.8.1 → 0.10.0
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 +36 -7
- package/dist/cjs/index.cjs +199 -66
- package/dist/composite-files-api.d.ts +16 -1
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/cow-files-api.d.ts.map +1 -1
- package/dist/esm/index.js +199 -66
- package/dist/overlay-files-api.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/composite-files-api.ts +122 -46
- package/src/cow-files-api.ts +11 -13
- package/src/overlay-files-api.ts +5 -13
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
A small toolkit of `FilesApi` decorators that lets you build a layered
|
|
6
6
|
virtual filesystem on top of any existing `FilesApi` implementation. It
|
|
7
|
-
ships three
|
|
7
|
+
ships three decorator classes and three layering helpers:
|
|
8
8
|
|
|
9
9
|
- **`CompositeFilesApi`** — mounts multiple `FilesApi` backends at different
|
|
10
10
|
composite-namespace prefixes (longest-prefix wins), with optional
|
|
@@ -13,6 +13,19 @@ ships three building blocks:
|
|
|
13
13
|
per-operation policies that can deny access by throwing.
|
|
14
14
|
- **`FilteredFilesApi`** — hides selected paths so that the wrapped API
|
|
15
15
|
behaves as if they did not exist.
|
|
16
|
+
- **`readOnly(api)`** — a read-only view: every mutating call (`write`,
|
|
17
|
+
`mkdir`, `remove`, `move`, `copy`) throws `"read-only: <path>"`.
|
|
18
|
+
- **`overlay(top, ...lower)`** — a read-only union of layers: reads resolve
|
|
19
|
+
top → bottom, `list` merges and dedupes with the upper layer winning, and
|
|
20
|
+
every mutation throws.
|
|
21
|
+
- **`cow(base, writable, opts?)`** — copy-on-write: all changes go to
|
|
22
|
+
`writable` and `base` is never mutated; deletions persist as whiteout
|
|
23
|
+
(`.wh.<name>`) and opaque (`.wh..opq`) marker files, whose names
|
|
24
|
+
`CowOptions` can change.
|
|
25
|
+
|
|
26
|
+
`globToRegExp(glob, { extended?, globstar?, flags? })` is exported too, with
|
|
27
|
+
the types `PathFilter`, `FileGuard`, `FileOperation`, `CowOptions` and
|
|
28
|
+
`GlobToRegExpOptions`.
|
|
16
29
|
|
|
17
30
|
## Why it exists
|
|
18
31
|
|
|
@@ -26,7 +39,9 @@ This package keeps each backend pure and pushes composition / access /
|
|
|
26
39
|
visibility into orthogonal decorators that can be stacked in any order. The
|
|
27
40
|
three concerns are deliberately split across separate classes:
|
|
28
41
|
|
|
29
|
-
- **mounting** is structural and never
|
|
42
|
+
- **mounting** is structural and never denies access — `CompositeFilesApi`.
|
|
43
|
+
(It throws only on invalid mount operations: mounting at `/`, removing a
|
|
44
|
+
mount point.)
|
|
30
45
|
- **access control** must throw to stay safe by default — `GuardedFilesApi`.
|
|
31
46
|
- **visibility** must silently lie to keep the consumer model simple —
|
|
32
47
|
`FilteredFilesApi`.
|
|
@@ -152,15 +167,18 @@ const guarded = new GuardedFilesApi(fs, [
|
|
|
152
167
|
```
|
|
153
168
|
|
|
154
169
|
Guards are evaluated in the order they were passed. The first denying
|
|
155
|
-
guard throws `Error("<message>: <normalized-path>")
|
|
170
|
+
guard throws `Error("<message>: <normalized-path>")`, where `message`
|
|
171
|
+
defaults to `"Access denied"`. A guard that lists
|
|
156
172
|
`"read"` also fires on the source side of `move`/`copy` and on every
|
|
157
173
|
`exists` call; one that lists `"write"` fires on the target side of
|
|
158
174
|
`move`/`copy`; one that lists `"list"` also fires on `stats`.
|
|
159
175
|
|
|
160
176
|
### Cross-mount move/copy
|
|
161
177
|
|
|
162
|
-
|
|
163
|
-
a recursive copy and (for `move`)
|
|
178
|
+
When source and target resolve to different backend instances,
|
|
179
|
+
`CompositeFilesApi` performs a recursive copy and (for `move`) then removes the
|
|
180
|
+
source; when both resolve to the same backend instance — even through two
|
|
181
|
+
different mounts — the call is delegated to that backend's own `move`/`copy`. Use guards/filters
|
|
164
182
|
to gate these flows by composite path:
|
|
165
183
|
|
|
166
184
|
```ts
|
|
@@ -190,12 +208,23 @@ filter to that mount only).
|
|
|
190
208
|
|
|
191
209
|
### CompositeFilesApi — path resolution
|
|
192
210
|
|
|
193
|
-
1. Input paths are normalized (
|
|
211
|
+
1. Input paths are normalized (leading `/`, no trailing `/`, repeated slashes
|
|
212
|
+
and `.` segments collapsed; `..` is kept as a segment, and backslashes are
|
|
213
|
+
not converted).
|
|
194
214
|
2. The mount with the **longest matching prefix** wins. The implicit `/`
|
|
195
215
|
mount set by the constructor is always last.
|
|
196
216
|
3. The matched prefix is stripped and the mount's `fsPath` is prepended.
|
|
197
217
|
4. Mount points themselves appear as synthetic directories in `list()` and
|
|
198
|
-
`stats()`, and `remove()` on a mount point throws.
|
|
218
|
+
`stats()`, and `remove()` on a mount point throws. In a listing, a mount
|
|
219
|
+
point replaces any entry the parent backend has at the same path.
|
|
220
|
+
5. `list()` merges the owning backend's listing (minus the subtrees of mounts
|
|
221
|
+
inside the listed directory), each such mount's listing (recursive listings
|
|
222
|
+
include nested mounts), and the mount points, into one path-ordered stream.
|
|
223
|
+
`{ after }` is translated into each backend's own paths, and a mount whose
|
|
224
|
+
whole subtree sorts at or before it is not listed at all.
|
|
225
|
+
|
|
226
|
+
`overlay` and `cow` list their merged directories in the same path order, and
|
|
227
|
+
`FilteredFilesApi` and `GuardedFilesApi` pass `after` through unchanged.
|
|
199
228
|
|
|
200
229
|
### GuardedFilesApi — effective operation matrix
|
|
201
230
|
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,6 +1,127 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
//#region ../webrun-files/dist/esm/index.js
|
|
3
3
|
/**
|
|
4
|
+
* The order every `FilesApi.list()` yields: paths compared by Unicode code
|
|
5
|
+
* point, which is the same as comparing their UTF-8 bytes — the order of
|
|
6
|
+
* SQLite's `BINARY` collation and of S3 key listings.
|
|
7
|
+
*
|
|
8
|
+
* Not JavaScript's `<`, which compares UTF-16 code units and so puts a
|
|
9
|
+
* character above U+FFFF (a surrogate pair) before U+E000–U+FFFF.
|
|
10
|
+
*/
|
|
11
|
+
function comparePaths(a, b) {
|
|
12
|
+
const length = Math.min(a.length, b.length);
|
|
13
|
+
for (let i = 0; i < length; i++) {
|
|
14
|
+
const x = a.charCodeAt(i);
|
|
15
|
+
const y = b.charCodeAt(i);
|
|
16
|
+
if (x === y) continue;
|
|
17
|
+
const xSurrogate = x >= 55296 && x <= 57343;
|
|
18
|
+
if (xSurrogate !== (y >= 55296 && y <= 57343)) return xSurrogate ? 1 : -1;
|
|
19
|
+
return x - y;
|
|
20
|
+
}
|
|
21
|
+
return a.length - b.length;
|
|
22
|
+
}
|
|
23
|
+
/** Whether a listing resuming after `after` can still yield anything under `dir`. */
|
|
24
|
+
function subtreeMayFollow(dir, after) {
|
|
25
|
+
return after === void 0 || comparePaths(after, `${dir === "/" ? "" : dir}0`) < 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* List a tree in path order for backends that read one directory at a time.
|
|
29
|
+
* `children(dir)` returns a directory's direct entries in any order.
|
|
30
|
+
*
|
|
31
|
+
* Pending entries sit in a min-heap keyed by path. A directory's children all
|
|
32
|
+
* sort after it, so pushing them when the directory is taken never yields out
|
|
33
|
+
* of order. Children are read only when the listing reaches their directory,
|
|
34
|
+
* and never for a directory whose whole subtree lies at or before `after`.
|
|
35
|
+
*/
|
|
36
|
+
async function* listInPathOrder(dir, children, options = {}) {
|
|
37
|
+
const { recursive = false, after } = options;
|
|
38
|
+
const heap = new PathHeap((entry) => entry.path);
|
|
39
|
+
if (!subtreeMayFollow(dir, after)) return;
|
|
40
|
+
for (const entry of await children(dir)) heap.push(entry);
|
|
41
|
+
for (let entry = heap.pop(); entry; entry = heap.pop()) {
|
|
42
|
+
if (after === void 0 || comparePaths(entry.path, after) > 0) yield entry;
|
|
43
|
+
if (recursive && entry.kind === "directory" && subtreeMayFollow(entry.path, after)) for (const child of await children(entry.path)) heap.push(child);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Merge streams that are each already in path order into one. When several
|
|
48
|
+
* streams yield the same path, the entry from the earliest stream wins and
|
|
49
|
+
* the others are dropped. At most one entry per stream is pulled ahead, and
|
|
50
|
+
* every stream is closed when the consumer stops.
|
|
51
|
+
*/
|
|
52
|
+
async function* mergeInPathOrder(streams) {
|
|
53
|
+
const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
|
|
54
|
+
const heads = [];
|
|
55
|
+
const done = [];
|
|
56
|
+
const pull = async (i) => {
|
|
57
|
+
const next = await iterators[i].next();
|
|
58
|
+
done[i] = next.done === true;
|
|
59
|
+
heads[i] = next.done ? void 0 : next.value;
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
await Promise.all(iterators.map((_, i) => pull(i)));
|
|
63
|
+
for (;;) {
|
|
64
|
+
let min = -1;
|
|
65
|
+
for (let i = 0; i < heads.length; i++) {
|
|
66
|
+
const head = heads[i];
|
|
67
|
+
if (!head) continue;
|
|
68
|
+
const current = min === -1 ? void 0 : heads[min];
|
|
69
|
+
if (!current || comparePaths(head.path, current.path) < 0) min = i;
|
|
70
|
+
}
|
|
71
|
+
if (min === -1) return;
|
|
72
|
+
const entry = heads[min];
|
|
73
|
+
for (let i = min + 1; i < heads.length; i++) while (heads[i] && heads[i]?.path === entry.path) await pull(i);
|
|
74
|
+
heads[min] = void 0;
|
|
75
|
+
yield entry;
|
|
76
|
+
await pull(min);
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
await Promise.all(iterators.map(async (iterator, i) => {
|
|
80
|
+
if (!done[i]) await iterator.return?.();
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** A binary min-heap ordered by {@link comparePaths}. */
|
|
85
|
+
var PathHeap = class {
|
|
86
|
+
#items = [];
|
|
87
|
+
#key;
|
|
88
|
+
constructor(key) {
|
|
89
|
+
this.#key = key;
|
|
90
|
+
}
|
|
91
|
+
push(item) {
|
|
92
|
+
const items = this.#items;
|
|
93
|
+
items.push(item);
|
|
94
|
+
for (let i = items.length - 1; i > 0;) {
|
|
95
|
+
const parent = i - 1 >> 1;
|
|
96
|
+
if (this.#less(items[parent], items[i])) break;
|
|
97
|
+
[items[parent], items[i]] = [items[i], items[parent]];
|
|
98
|
+
i = parent;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
pop() {
|
|
102
|
+
const items = this.#items;
|
|
103
|
+
const top = items[0];
|
|
104
|
+
const last = items.pop();
|
|
105
|
+
if (items.length > 0 && last !== void 0) {
|
|
106
|
+
items[0] = last;
|
|
107
|
+
for (let i = 0;;) {
|
|
108
|
+
const left = 2 * i + 1;
|
|
109
|
+
const right = left + 1;
|
|
110
|
+
let smallest = i;
|
|
111
|
+
if (left < items.length && this.#less(items[left], items[smallest])) smallest = left;
|
|
112
|
+
if (right < items.length && this.#less(items[right], items[smallest])) smallest = right;
|
|
113
|
+
if (smallest === i) break;
|
|
114
|
+
[items[smallest], items[i]] = [items[i], items[smallest]];
|
|
115
|
+
i = smallest;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return top;
|
|
119
|
+
}
|
|
120
|
+
#less(a, b) {
|
|
121
|
+
return comparePaths(this.#key(a), this.#key(b)) < 0;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
4
125
|
* Path manipulation utilities for virtual filesystem paths.
|
|
5
126
|
* All paths use forward slashes and start with "/".
|
|
6
127
|
*/
|
|
@@ -162,56 +283,60 @@ var CompositeFilesApi = class {
|
|
|
162
283
|
const { api, resolvedPath } = this.resolve(path);
|
|
163
284
|
return api.mkdir(resolvedPath);
|
|
164
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Merges, in `comparePaths` order: a synthetic directory entry for each
|
|
288
|
+
* mount inside `path`; the owning backend's listing with those mounts'
|
|
289
|
+
* subtrees removed; and, when recursive, each inner mount's own listing
|
|
290
|
+
* with its nested mounts removed. Each backend's listing is already ordered
|
|
291
|
+
* and remapping a subtree's prefix keeps that order, so a k-way merge is
|
|
292
|
+
* enough. `after` is translated into each backend's namespace, and a mount
|
|
293
|
+
* whose whole subtree lies at or before it is not listed.
|
|
294
|
+
*/
|
|
165
295
|
async *list(path, options) {
|
|
166
296
|
const normalized = normalizePath(path);
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
for (const mountPrefix of childMounts) {
|
|
206
|
-
const mountName = mountPrefix.split("/").pop() ?? "";
|
|
207
|
-
if (!yieldedNames.has(mountName)) yield {
|
|
208
|
-
name: mountName,
|
|
209
|
-
path: mountPrefix,
|
|
210
|
-
kind: "directory"
|
|
211
|
-
};
|
|
212
|
-
}
|
|
297
|
+
const recursive = options?.recursive ?? false;
|
|
298
|
+
const after = options?.after;
|
|
299
|
+
const owner = this.ownerOf(normalized);
|
|
300
|
+
const { resolvedPath } = this.resolve(normalized);
|
|
301
|
+
const inner = this.mounts.filter((m) => m.prefix !== "/" && isStrictlyInside(m.prefix, normalized));
|
|
302
|
+
const listed = recursive ? inner : this.childMountPrefixes(normalized).map((prefix) => inner.find((m) => m.prefix === prefix));
|
|
303
|
+
const streams = [];
|
|
304
|
+
streams.push(fromArray(listed.map((m) => ({
|
|
305
|
+
kind: "directory",
|
|
306
|
+
name: m.prefix.slice(m.prefix.lastIndexOf("/") + 1),
|
|
307
|
+
path: m.prefix
|
|
308
|
+
})).sort((x, y) => comparePaths(x.path, y.path))));
|
|
309
|
+
streams.push(this.remappedList(owner.api, resolvedPath, normalized, recursive, after, inner.map((m) => m.prefix)));
|
|
310
|
+
if (recursive) for (const m of inner) {
|
|
311
|
+
const nested = inner.filter((n) => isStrictlyInside(n.prefix, m.prefix)).map((n) => n.prefix);
|
|
312
|
+
streams.push(this.remappedList(m.api, m.basePath, m.prefix, true, after, nested));
|
|
313
|
+
}
|
|
314
|
+
for await (const entry of mergeInPathOrder(streams)) if (after === void 0 || comparePaths(entry.path, after) > 0) yield entry;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* One backend's listing of `backendDir`, with paths moved to `compositeDir`
|
|
318
|
+
* and entries at or under any of `excluded` dropped.
|
|
319
|
+
*/
|
|
320
|
+
async *remappedList(api, backendDir, compositeDir, recursive, after, excluded) {
|
|
321
|
+
const translated = translateAfter(after, compositeDir, backendDir);
|
|
322
|
+
if (translated === SKIP) return;
|
|
323
|
+
for await (const entry of api.list(backendDir, {
|
|
324
|
+
recursive,
|
|
325
|
+
after: translated
|
|
326
|
+
})) {
|
|
327
|
+
const compositePath = this.remapPath(compositeDir, backendDir, entry.path);
|
|
328
|
+
if (this.isUnderChildMount(compositePath, excluded)) continue;
|
|
329
|
+
yield {
|
|
330
|
+
...entry,
|
|
331
|
+
path: compositePath
|
|
332
|
+
};
|
|
213
333
|
}
|
|
214
334
|
}
|
|
335
|
+
/** The mount that owns `normalized` — the longest matching prefix, else the root. */
|
|
336
|
+
ownerOf(normalized) {
|
|
337
|
+
for (const mount of this.mounts) if (mount.prefix === "/" || normalized === mount.prefix || normalized.startsWith(`${mount.prefix}/`)) return mount;
|
|
338
|
+
return this.mounts[this.mounts.length - 1];
|
|
339
|
+
}
|
|
215
340
|
async stats(path) {
|
|
216
341
|
const normalized = normalizePath(path);
|
|
217
342
|
if (this.isMountPoint(normalized)) return { kind: "directory" };
|
|
@@ -244,12 +369,6 @@ var CompositeFilesApi = class {
|
|
|
244
369
|
if (src.api === tgt.api) return src.api.copy(src.resolvedPath, tgt.resolvedPath);
|
|
245
370
|
return this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
246
371
|
}
|
|
247
|
-
stripBasePath(path, basePath) {
|
|
248
|
-
if (basePath === "/") return path;
|
|
249
|
-
if (path === basePath) return "/";
|
|
250
|
-
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
251
|
-
return path;
|
|
252
|
-
}
|
|
253
372
|
async crossCopy(srcApi, srcPath, tgtApi, tgtPath) {
|
|
254
373
|
const srcStats = await srcApi.stats(srcPath);
|
|
255
374
|
if (!srcStats) return false;
|
|
@@ -276,6 +395,28 @@ var CompositeFilesApi = class {
|
|
|
276
395
|
return false;
|
|
277
396
|
}
|
|
278
397
|
};
|
|
398
|
+
const SKIP = Symbol("skip");
|
|
399
|
+
/**
|
|
400
|
+
* `after`, given in the `from` subtree's namespace, in the `to` subtree's
|
|
401
|
+
* namespace — or {@link SKIP} when the whole `from` subtree lies at or before
|
|
402
|
+
* it. Raw prefix replacement, never normalisation: normalising `"/a/"` to
|
|
403
|
+
* `"/a"` would change which entries sort after it. Under-pruning is harmless
|
|
404
|
+
* (the caller filters again); over-pruning is what this must never do.
|
|
405
|
+
*/
|
|
406
|
+
function translateAfter(after, from, to) {
|
|
407
|
+
if (after === void 0) return void 0;
|
|
408
|
+
const fromPrefix = from === "/" ? "" : from;
|
|
409
|
+
const toPrefix = to === "/" ? "" : to;
|
|
410
|
+
if (after.startsWith(`${fromPrefix}/`)) return `${toPrefix}${after.slice(fromPrefix.length)}`;
|
|
411
|
+
if (comparePaths(after, `${fromPrefix}/`) < 0) return void 0;
|
|
412
|
+
return SKIP;
|
|
413
|
+
}
|
|
414
|
+
function isStrictlyInside(path, ancestor) {
|
|
415
|
+
return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
|
|
416
|
+
}
|
|
417
|
+
async function* fromArray(items) {
|
|
418
|
+
yield* items;
|
|
419
|
+
}
|
|
279
420
|
//#endregion
|
|
280
421
|
//#region src/cow-files-api.ts
|
|
281
422
|
/**
|
|
@@ -368,15 +509,11 @@ var CowFilesApi = class {
|
|
|
368
509
|
async exists(path) {
|
|
369
510
|
return await this.resolveLayer(path) !== "absent";
|
|
370
511
|
}
|
|
512
|
+
/** Merged children, walked in `comparePaths` order. */
|
|
371
513
|
async *list(path, options) {
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
async *listDir(dir, recursive) {
|
|
514
|
+
const dir = normalizePath(path);
|
|
375
515
|
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
376
|
-
|
|
377
|
-
yield entry;
|
|
378
|
-
if (recursive && entry.kind === "directory") yield* this.listDir(entry.path, true);
|
|
379
|
-
}
|
|
516
|
+
yield* listInPathOrder(dir, (d) => this.mergeChildren(d), options);
|
|
380
517
|
}
|
|
381
518
|
/** Direct children of `dir`: writable entries win, base entries fill in. */
|
|
382
519
|
async mergeChildren(dir) {
|
|
@@ -880,15 +1017,11 @@ var OverlayFilesApi = class {
|
|
|
880
1017
|
for (const layer of this.layers) if (await layer.exists(path)) return true;
|
|
881
1018
|
return false;
|
|
882
1019
|
}
|
|
1020
|
+
/** Merged children, walked in `comparePaths` order. */
|
|
883
1021
|
async *list(path, options) {
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
async *listDir(dir, recursive) {
|
|
1022
|
+
const dir = normalizePath(path);
|
|
887
1023
|
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
888
|
-
|
|
889
|
-
yield entry;
|
|
890
|
-
if (recursive && entry.kind === "directory") yield* this.listDir(entry.path, true);
|
|
891
|
-
}
|
|
1024
|
+
yield* listInPathOrder(dir, (d) => this.mergeChildren(d), options);
|
|
892
1025
|
}
|
|
893
1026
|
/** Direct children of `dir`, deduped by name with the topmost layer winning. */
|
|
894
1027
|
async mergeChildren(dir) {
|
|
@@ -60,13 +60,28 @@ export declare class CompositeFilesApi implements FilesApi {
|
|
|
60
60
|
read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
|
|
61
61
|
write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
62
62
|
mkdir(path: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Merges, in `comparePaths` order: a synthetic directory entry for each
|
|
65
|
+
* mount inside `path`; the owning backend's listing with those mounts'
|
|
66
|
+
* subtrees removed; and, when recursive, each inner mount's own listing
|
|
67
|
+
* with its nested mounts removed. Each backend's listing is already ordered
|
|
68
|
+
* and remapping a subtree's prefix keeps that order, so a k-way merge is
|
|
69
|
+
* enough. `after` is translated into each backend's namespace, and a mount
|
|
70
|
+
* whose whole subtree lies at or before it is not listed.
|
|
71
|
+
*/
|
|
63
72
|
list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
|
|
73
|
+
/**
|
|
74
|
+
* One backend's listing of `backendDir`, with paths moved to `compositeDir`
|
|
75
|
+
* and entries at or under any of `excluded` dropped.
|
|
76
|
+
*/
|
|
77
|
+
private remappedList;
|
|
78
|
+
/** The mount that owns `normalized` — the longest matching prefix, else the root. */
|
|
79
|
+
private ownerOf;
|
|
64
80
|
stats(path: string): Promise<FileStats | undefined>;
|
|
65
81
|
exists(path: string): Promise<boolean>;
|
|
66
82
|
remove(path: string): Promise<boolean>;
|
|
67
83
|
move(source: string, target: string): Promise<boolean>;
|
|
68
84
|
copy(source: string, target: string): Promise<boolean>;
|
|
69
|
-
private stripBasePath;
|
|
70
85
|
private crossCopy;
|
|
71
86
|
private remapPath;
|
|
72
87
|
private isUnderChildMount;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"composite-files-api.d.ts","sourceRoot":"","sources":["../src/composite-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AASnC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,iBAAkB,YAAW,QAAQ;IAChD,OAAO,CAAC,MAAM,CAAe;IAE7B;;;;;;;;OAQG;IACH,YAAY,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,MAAM,EAE5C;IAED;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CASxD;IAID,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,YAAY;IAKpB,yEAAyE;IACzE,OAAO,CAAC,kBAAkB;IAkB1B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAGnE;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAGf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;
|
|
1
|
+
{"version":3,"file":"composite-files-api.d.ts","sourceRoot":"","sources":["../src/composite-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AASnC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,iBAAkB,YAAW,QAAQ;IAChD,OAAO,CAAC,MAAM,CAAe;IAE7B;;;;;;;;OAQG;IACH,YAAY,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,MAAM,EAE5C;IAED;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CASxD;IAID,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,YAAY;IAKpB,yEAAyE;IACzE,OAAO,CAAC,kBAAkB;IAkB1B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAGnE;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAGf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED;;;;;;;;OAQG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAoDxE;IAED;;;OAGG;YACY,YAAY;IAiB3B,qFAAqF;IACrF,OAAO,CAAC,OAAO;IAaT,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAOxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3C;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAe3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAW3D;YAIa,SAAS;IA4BvB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;CAQ1B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cow-files-api.d.ts","sourceRoot":"","sources":["../src/cow-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,QAAQ,EAGT,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"cow-files-api.d.ts","sourceRoot":"","sources":["../src/cow-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,QAAQ,EAGT,MAAM,2BAA2B,CAAC;AASnC,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAgMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,QAAQ,CAEnF"}
|
package/dist/esm/index.js
CHANGED
|
@@ -1,5 +1,126 @@
|
|
|
1
1
|
//#region ../webrun-files/dist/esm/index.js
|
|
2
2
|
/**
|
|
3
|
+
* The order every `FilesApi.list()` yields: paths compared by Unicode code
|
|
4
|
+
* point, which is the same as comparing their UTF-8 bytes — the order of
|
|
5
|
+
* SQLite's `BINARY` collation and of S3 key listings.
|
|
6
|
+
*
|
|
7
|
+
* Not JavaScript's `<`, which compares UTF-16 code units and so puts a
|
|
8
|
+
* character above U+FFFF (a surrogate pair) before U+E000–U+FFFF.
|
|
9
|
+
*/
|
|
10
|
+
function comparePaths(a, b) {
|
|
11
|
+
const length = Math.min(a.length, b.length);
|
|
12
|
+
for (let i = 0; i < length; i++) {
|
|
13
|
+
const x = a.charCodeAt(i);
|
|
14
|
+
const y = b.charCodeAt(i);
|
|
15
|
+
if (x === y) continue;
|
|
16
|
+
const xSurrogate = x >= 55296 && x <= 57343;
|
|
17
|
+
if (xSurrogate !== (y >= 55296 && y <= 57343)) return xSurrogate ? 1 : -1;
|
|
18
|
+
return x - y;
|
|
19
|
+
}
|
|
20
|
+
return a.length - b.length;
|
|
21
|
+
}
|
|
22
|
+
/** Whether a listing resuming after `after` can still yield anything under `dir`. */
|
|
23
|
+
function subtreeMayFollow(dir, after) {
|
|
24
|
+
return after === void 0 || comparePaths(after, `${dir === "/" ? "" : dir}0`) < 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* List a tree in path order for backends that read one directory at a time.
|
|
28
|
+
* `children(dir)` returns a directory's direct entries in any order.
|
|
29
|
+
*
|
|
30
|
+
* Pending entries sit in a min-heap keyed by path. A directory's children all
|
|
31
|
+
* sort after it, so pushing them when the directory is taken never yields out
|
|
32
|
+
* of order. Children are read only when the listing reaches their directory,
|
|
33
|
+
* and never for a directory whose whole subtree lies at or before `after`.
|
|
34
|
+
*/
|
|
35
|
+
async function* listInPathOrder(dir, children, options = {}) {
|
|
36
|
+
const { recursive = false, after } = options;
|
|
37
|
+
const heap = new PathHeap((entry) => entry.path);
|
|
38
|
+
if (!subtreeMayFollow(dir, after)) return;
|
|
39
|
+
for (const entry of await children(dir)) heap.push(entry);
|
|
40
|
+
for (let entry = heap.pop(); entry; entry = heap.pop()) {
|
|
41
|
+
if (after === void 0 || comparePaths(entry.path, after) > 0) yield entry;
|
|
42
|
+
if (recursive && entry.kind === "directory" && subtreeMayFollow(entry.path, after)) for (const child of await children(entry.path)) heap.push(child);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Merge streams that are each already in path order into one. When several
|
|
47
|
+
* streams yield the same path, the entry from the earliest stream wins and
|
|
48
|
+
* the others are dropped. At most one entry per stream is pulled ahead, and
|
|
49
|
+
* every stream is closed when the consumer stops.
|
|
50
|
+
*/
|
|
51
|
+
async function* mergeInPathOrder(streams) {
|
|
52
|
+
const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
|
|
53
|
+
const heads = [];
|
|
54
|
+
const done = [];
|
|
55
|
+
const pull = async (i) => {
|
|
56
|
+
const next = await iterators[i].next();
|
|
57
|
+
done[i] = next.done === true;
|
|
58
|
+
heads[i] = next.done ? void 0 : next.value;
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
await Promise.all(iterators.map((_, i) => pull(i)));
|
|
62
|
+
for (;;) {
|
|
63
|
+
let min = -1;
|
|
64
|
+
for (let i = 0; i < heads.length; i++) {
|
|
65
|
+
const head = heads[i];
|
|
66
|
+
if (!head) continue;
|
|
67
|
+
const current = min === -1 ? void 0 : heads[min];
|
|
68
|
+
if (!current || comparePaths(head.path, current.path) < 0) min = i;
|
|
69
|
+
}
|
|
70
|
+
if (min === -1) return;
|
|
71
|
+
const entry = heads[min];
|
|
72
|
+
for (let i = min + 1; i < heads.length; i++) while (heads[i] && heads[i]?.path === entry.path) await pull(i);
|
|
73
|
+
heads[min] = void 0;
|
|
74
|
+
yield entry;
|
|
75
|
+
await pull(min);
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
await Promise.all(iterators.map(async (iterator, i) => {
|
|
79
|
+
if (!done[i]) await iterator.return?.();
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** A binary min-heap ordered by {@link comparePaths}. */
|
|
84
|
+
var PathHeap = class {
|
|
85
|
+
#items = [];
|
|
86
|
+
#key;
|
|
87
|
+
constructor(key) {
|
|
88
|
+
this.#key = key;
|
|
89
|
+
}
|
|
90
|
+
push(item) {
|
|
91
|
+
const items = this.#items;
|
|
92
|
+
items.push(item);
|
|
93
|
+
for (let i = items.length - 1; i > 0;) {
|
|
94
|
+
const parent = i - 1 >> 1;
|
|
95
|
+
if (this.#less(items[parent], items[i])) break;
|
|
96
|
+
[items[parent], items[i]] = [items[i], items[parent]];
|
|
97
|
+
i = parent;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
pop() {
|
|
101
|
+
const items = this.#items;
|
|
102
|
+
const top = items[0];
|
|
103
|
+
const last = items.pop();
|
|
104
|
+
if (items.length > 0 && last !== void 0) {
|
|
105
|
+
items[0] = last;
|
|
106
|
+
for (let i = 0;;) {
|
|
107
|
+
const left = 2 * i + 1;
|
|
108
|
+
const right = left + 1;
|
|
109
|
+
let smallest = i;
|
|
110
|
+
if (left < items.length && this.#less(items[left], items[smallest])) smallest = left;
|
|
111
|
+
if (right < items.length && this.#less(items[right], items[smallest])) smallest = right;
|
|
112
|
+
if (smallest === i) break;
|
|
113
|
+
[items[smallest], items[i]] = [items[i], items[smallest]];
|
|
114
|
+
i = smallest;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return top;
|
|
118
|
+
}
|
|
119
|
+
#less(a, b) {
|
|
120
|
+
return comparePaths(this.#key(a), this.#key(b)) < 0;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
3
124
|
* Path manipulation utilities for virtual filesystem paths.
|
|
4
125
|
* All paths use forward slashes and start with "/".
|
|
5
126
|
*/
|
|
@@ -161,56 +282,60 @@ var CompositeFilesApi = class {
|
|
|
161
282
|
const { api, resolvedPath } = this.resolve(path);
|
|
162
283
|
return api.mkdir(resolvedPath);
|
|
163
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Merges, in `comparePaths` order: a synthetic directory entry for each
|
|
287
|
+
* mount inside `path`; the owning backend's listing with those mounts'
|
|
288
|
+
* subtrees removed; and, when recursive, each inner mount's own listing
|
|
289
|
+
* with its nested mounts removed. Each backend's listing is already ordered
|
|
290
|
+
* and remapping a subtree's prefix keeps that order, so a k-way merge is
|
|
291
|
+
* enough. `after` is translated into each backend's namespace, and a mount
|
|
292
|
+
* whose whole subtree lies at or before it is not listed.
|
|
293
|
+
*/
|
|
164
294
|
async *list(path, options) {
|
|
165
295
|
const normalized = normalizePath(path);
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
for (const mountPrefix of childMounts) {
|
|
205
|
-
const mountName = mountPrefix.split("/").pop() ?? "";
|
|
206
|
-
if (!yieldedNames.has(mountName)) yield {
|
|
207
|
-
name: mountName,
|
|
208
|
-
path: mountPrefix,
|
|
209
|
-
kind: "directory"
|
|
210
|
-
};
|
|
211
|
-
}
|
|
296
|
+
const recursive = options?.recursive ?? false;
|
|
297
|
+
const after = options?.after;
|
|
298
|
+
const owner = this.ownerOf(normalized);
|
|
299
|
+
const { resolvedPath } = this.resolve(normalized);
|
|
300
|
+
const inner = this.mounts.filter((m) => m.prefix !== "/" && isStrictlyInside(m.prefix, normalized));
|
|
301
|
+
const listed = recursive ? inner : this.childMountPrefixes(normalized).map((prefix) => inner.find((m) => m.prefix === prefix));
|
|
302
|
+
const streams = [];
|
|
303
|
+
streams.push(fromArray(listed.map((m) => ({
|
|
304
|
+
kind: "directory",
|
|
305
|
+
name: m.prefix.slice(m.prefix.lastIndexOf("/") + 1),
|
|
306
|
+
path: m.prefix
|
|
307
|
+
})).sort((x, y) => comparePaths(x.path, y.path))));
|
|
308
|
+
streams.push(this.remappedList(owner.api, resolvedPath, normalized, recursive, after, inner.map((m) => m.prefix)));
|
|
309
|
+
if (recursive) for (const m of inner) {
|
|
310
|
+
const nested = inner.filter((n) => isStrictlyInside(n.prefix, m.prefix)).map((n) => n.prefix);
|
|
311
|
+
streams.push(this.remappedList(m.api, m.basePath, m.prefix, true, after, nested));
|
|
312
|
+
}
|
|
313
|
+
for await (const entry of mergeInPathOrder(streams)) if (after === void 0 || comparePaths(entry.path, after) > 0) yield entry;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* One backend's listing of `backendDir`, with paths moved to `compositeDir`
|
|
317
|
+
* and entries at or under any of `excluded` dropped.
|
|
318
|
+
*/
|
|
319
|
+
async *remappedList(api, backendDir, compositeDir, recursive, after, excluded) {
|
|
320
|
+
const translated = translateAfter(after, compositeDir, backendDir);
|
|
321
|
+
if (translated === SKIP) return;
|
|
322
|
+
for await (const entry of api.list(backendDir, {
|
|
323
|
+
recursive,
|
|
324
|
+
after: translated
|
|
325
|
+
})) {
|
|
326
|
+
const compositePath = this.remapPath(compositeDir, backendDir, entry.path);
|
|
327
|
+
if (this.isUnderChildMount(compositePath, excluded)) continue;
|
|
328
|
+
yield {
|
|
329
|
+
...entry,
|
|
330
|
+
path: compositePath
|
|
331
|
+
};
|
|
212
332
|
}
|
|
213
333
|
}
|
|
334
|
+
/** The mount that owns `normalized` — the longest matching prefix, else the root. */
|
|
335
|
+
ownerOf(normalized) {
|
|
336
|
+
for (const mount of this.mounts) if (mount.prefix === "/" || normalized === mount.prefix || normalized.startsWith(`${mount.prefix}/`)) return mount;
|
|
337
|
+
return this.mounts[this.mounts.length - 1];
|
|
338
|
+
}
|
|
214
339
|
async stats(path) {
|
|
215
340
|
const normalized = normalizePath(path);
|
|
216
341
|
if (this.isMountPoint(normalized)) return { kind: "directory" };
|
|
@@ -243,12 +368,6 @@ var CompositeFilesApi = class {
|
|
|
243
368
|
if (src.api === tgt.api) return src.api.copy(src.resolvedPath, tgt.resolvedPath);
|
|
244
369
|
return this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
|
|
245
370
|
}
|
|
246
|
-
stripBasePath(path, basePath) {
|
|
247
|
-
if (basePath === "/") return path;
|
|
248
|
-
if (path === basePath) return "/";
|
|
249
|
-
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
250
|
-
return path;
|
|
251
|
-
}
|
|
252
371
|
async crossCopy(srcApi, srcPath, tgtApi, tgtPath) {
|
|
253
372
|
const srcStats = await srcApi.stats(srcPath);
|
|
254
373
|
if (!srcStats) return false;
|
|
@@ -275,6 +394,28 @@ var CompositeFilesApi = class {
|
|
|
275
394
|
return false;
|
|
276
395
|
}
|
|
277
396
|
};
|
|
397
|
+
const SKIP = Symbol("skip");
|
|
398
|
+
/**
|
|
399
|
+
* `after`, given in the `from` subtree's namespace, in the `to` subtree's
|
|
400
|
+
* namespace — or {@link SKIP} when the whole `from` subtree lies at or before
|
|
401
|
+
* it. Raw prefix replacement, never normalisation: normalising `"/a/"` to
|
|
402
|
+
* `"/a"` would change which entries sort after it. Under-pruning is harmless
|
|
403
|
+
* (the caller filters again); over-pruning is what this must never do.
|
|
404
|
+
*/
|
|
405
|
+
function translateAfter(after, from, to) {
|
|
406
|
+
if (after === void 0) return void 0;
|
|
407
|
+
const fromPrefix = from === "/" ? "" : from;
|
|
408
|
+
const toPrefix = to === "/" ? "" : to;
|
|
409
|
+
if (after.startsWith(`${fromPrefix}/`)) return `${toPrefix}${after.slice(fromPrefix.length)}`;
|
|
410
|
+
if (comparePaths(after, `${fromPrefix}/`) < 0) return void 0;
|
|
411
|
+
return SKIP;
|
|
412
|
+
}
|
|
413
|
+
function isStrictlyInside(path, ancestor) {
|
|
414
|
+
return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
|
|
415
|
+
}
|
|
416
|
+
async function* fromArray(items) {
|
|
417
|
+
yield* items;
|
|
418
|
+
}
|
|
278
419
|
//#endregion
|
|
279
420
|
//#region src/cow-files-api.ts
|
|
280
421
|
/**
|
|
@@ -367,15 +508,11 @@ var CowFilesApi = class {
|
|
|
367
508
|
async exists(path) {
|
|
368
509
|
return await this.resolveLayer(path) !== "absent";
|
|
369
510
|
}
|
|
511
|
+
/** Merged children, walked in `comparePaths` order. */
|
|
370
512
|
async *list(path, options) {
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
async *listDir(dir, recursive) {
|
|
513
|
+
const dir = normalizePath(path);
|
|
374
514
|
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
375
|
-
|
|
376
|
-
yield entry;
|
|
377
|
-
if (recursive && entry.kind === "directory") yield* this.listDir(entry.path, true);
|
|
378
|
-
}
|
|
515
|
+
yield* listInPathOrder(dir, (d) => this.mergeChildren(d), options);
|
|
379
516
|
}
|
|
380
517
|
/** Direct children of `dir`: writable entries win, base entries fill in. */
|
|
381
518
|
async mergeChildren(dir) {
|
|
@@ -879,15 +1016,11 @@ var OverlayFilesApi = class {
|
|
|
879
1016
|
for (const layer of this.layers) if (await layer.exists(path)) return true;
|
|
880
1017
|
return false;
|
|
881
1018
|
}
|
|
1019
|
+
/** Merged children, walked in `comparePaths` order. */
|
|
882
1020
|
async *list(path, options) {
|
|
883
|
-
|
|
884
|
-
}
|
|
885
|
-
async *listDir(dir, recursive) {
|
|
1021
|
+
const dir = normalizePath(path);
|
|
886
1022
|
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
887
|
-
|
|
888
|
-
yield entry;
|
|
889
|
-
if (recursive && entry.kind === "directory") yield* this.listDir(entry.path, true);
|
|
890
|
-
}
|
|
1023
|
+
yield* listInPathOrder(dir, (d) => this.mergeChildren(d), options);
|
|
891
1024
|
}
|
|
892
1025
|
/** Direct children of `dir`, deduped by name with the topmost layer winning. */
|
|
893
1026
|
async mergeChildren(dir) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"overlay-files-api.d.ts","sourceRoot":"","sources":["../src/overlay-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,QAAQ,EAGT,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"overlay-files-api.d.ts","sourceRoot":"","sources":["../src/overlay-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,QAAQ,EAGT,MAAM,2BAA2B,CAAC;AAuFnC;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAErE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-files-composite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Composite FilesApi with mount points and access guards",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"src"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@statewalker/webrun-files": "0.
|
|
32
|
+
"@statewalker/webrun-files": "0.10.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^26.2.0",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"rolldown": "^1.2.4",
|
|
38
38
|
"typescript": "^7.0.2",
|
|
39
39
|
"vitest": "^4.1.10",
|
|
40
|
-
"@statewalker/webrun-files-mem": "0.
|
|
41
|
-
"@statewalker/webrun-files-tests": "
|
|
40
|
+
"@statewalker/webrun-files-mem": "0.10.0",
|
|
41
|
+
"@statewalker/webrun-files-tests": "3.0.0"
|
|
42
42
|
},
|
|
43
43
|
"sideEffects": false,
|
|
44
44
|
"publishConfig": {
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ListOptions,
|
|
6
6
|
ReadOptions,
|
|
7
7
|
} from "@statewalker/webrun-files";
|
|
8
|
-
import { joinPath, normalizePath } from "@statewalker/webrun-files";
|
|
8
|
+
import { comparePaths, joinPath, mergeInPathOrder, normalizePath } from "@statewalker/webrun-files";
|
|
9
9
|
|
|
10
10
|
interface MountEntry {
|
|
11
11
|
prefix: string;
|
|
@@ -141,50 +141,102 @@ export class CompositeFilesApi implements FilesApi {
|
|
|
141
141
|
return api.mkdir(resolvedPath);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Merges, in `comparePaths` order: a synthetic directory entry for each
|
|
146
|
+
* mount inside `path`; the owning backend's listing with those mounts'
|
|
147
|
+
* subtrees removed; and, when recursive, each inner mount's own listing
|
|
148
|
+
* with its nested mounts removed. Each backend's listing is already ordered
|
|
149
|
+
* and remapping a subtree's prefix keeps that order, so a k-way merge is
|
|
150
|
+
* enough. `after` is translated into each backend's namespace, and a mount
|
|
151
|
+
* whose whole subtree lies at or before it is not listed.
|
|
152
|
+
*/
|
|
144
153
|
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
145
154
|
const normalized = normalizePath(path);
|
|
146
|
-
const
|
|
155
|
+
const recursive = options?.recursive ?? false;
|
|
156
|
+
const after = options?.after;
|
|
157
|
+
const owner = this.ownerOf(normalized);
|
|
158
|
+
const { resolvedPath } = this.resolve(normalized);
|
|
147
159
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
160
|
+
const inner = this.mounts.filter(
|
|
161
|
+
(m) => m.prefix !== "/" && isStrictlyInside(m.prefix, normalized),
|
|
162
|
+
);
|
|
163
|
+
const listed = recursive
|
|
164
|
+
? inner
|
|
165
|
+
: this.childMountPrefixes(normalized).map(
|
|
166
|
+
(prefix) => inner.find((m) => m.prefix === prefix) as MountEntry,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const streams: AsyncIterable<FileInfo>[] = [];
|
|
170
|
+
// Synthetic mount points come first, so they win over a same-named backend
|
|
171
|
+
// entry. Mounts are kept longest-prefix first, so this stream is sorted.
|
|
172
|
+
streams.push(
|
|
173
|
+
fromArray(
|
|
174
|
+
listed
|
|
175
|
+
.map((m) => ({
|
|
176
|
+
kind: "directory" as const,
|
|
177
|
+
name: m.prefix.slice(m.prefix.lastIndexOf("/") + 1),
|
|
178
|
+
path: m.prefix,
|
|
179
|
+
}))
|
|
180
|
+
.sort((x, y) => comparePaths(x.path, y.path)),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
streams.push(
|
|
184
|
+
this.remappedList(
|
|
185
|
+
owner.api,
|
|
186
|
+
resolvedPath,
|
|
187
|
+
normalized,
|
|
188
|
+
recursive,
|
|
189
|
+
after,
|
|
190
|
+
inner.map((m) => m.prefix),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
if (recursive) {
|
|
194
|
+
for (const m of inner) {
|
|
195
|
+
const nested = inner
|
|
196
|
+
.filter((n) => isStrictlyInside(n.prefix, m.prefix))
|
|
197
|
+
.map((n) => n.prefix);
|
|
198
|
+
streams.push(this.remappedList(m.api, m.basePath, m.prefix, true, after, nested));
|
|
179
199
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for await (const entry of mergeInPathOrder(streams)) {
|
|
203
|
+
if (after === undefined || comparePaths(entry.path, after) > 0) yield entry;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One backend's listing of `backendDir`, with paths moved to `compositeDir`
|
|
209
|
+
* and entries at or under any of `excluded` dropped.
|
|
210
|
+
*/
|
|
211
|
+
private async *remappedList(
|
|
212
|
+
api: FilesApi,
|
|
213
|
+
backendDir: string,
|
|
214
|
+
compositeDir: string,
|
|
215
|
+
recursive: boolean,
|
|
216
|
+
after: string | undefined,
|
|
217
|
+
excluded: string[],
|
|
218
|
+
): AsyncIterable<FileInfo> {
|
|
219
|
+
const translated = translateAfter(after, compositeDir, backendDir);
|
|
220
|
+
if (translated === SKIP) return;
|
|
221
|
+
for await (const entry of api.list(backendDir, { recursive, after: translated })) {
|
|
222
|
+
const compositePath = this.remapPath(compositeDir, backendDir, entry.path);
|
|
223
|
+
if (this.isUnderChildMount(compositePath, excluded)) continue;
|
|
224
|
+
yield { ...entry, path: compositePath };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The mount that owns `normalized` — the longest matching prefix, else the root. */
|
|
229
|
+
private ownerOf(normalized: string): MountEntry {
|
|
230
|
+
for (const mount of this.mounts) {
|
|
231
|
+
if (
|
|
232
|
+
mount.prefix === "/" ||
|
|
233
|
+
normalized === mount.prefix ||
|
|
234
|
+
normalized.startsWith(`${mount.prefix}/`)
|
|
235
|
+
) {
|
|
236
|
+
return mount;
|
|
186
237
|
}
|
|
187
238
|
}
|
|
239
|
+
return this.mounts[this.mounts.length - 1];
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
async stats(path: string): Promise<FileStats | undefined> {
|
|
@@ -246,13 +298,6 @@ export class CompositeFilesApi implements FilesApi {
|
|
|
246
298
|
|
|
247
299
|
// --- Helpers ---
|
|
248
300
|
|
|
249
|
-
private stripBasePath(path: string, basePath: string): string {
|
|
250
|
-
if (basePath === "/") return path;
|
|
251
|
-
if (path === basePath) return "/";
|
|
252
|
-
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
|
253
|
-
return path;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
301
|
private async crossCopy(
|
|
257
302
|
srcApi: FilesApi,
|
|
258
303
|
srcPath: string,
|
|
@@ -305,3 +350,34 @@ export class CompositeFilesApi implements FilesApi {
|
|
|
305
350
|
return false;
|
|
306
351
|
}
|
|
307
352
|
}
|
|
353
|
+
|
|
354
|
+
const SKIP = Symbol("skip");
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* `after`, given in the `from` subtree's namespace, in the `to` subtree's
|
|
358
|
+
* namespace — or {@link SKIP} when the whole `from` subtree lies at or before
|
|
359
|
+
* it. Raw prefix replacement, never normalisation: normalising `"/a/"` to
|
|
360
|
+
* `"/a"` would change which entries sort after it. Under-pruning is harmless
|
|
361
|
+
* (the caller filters again); over-pruning is what this must never do.
|
|
362
|
+
*/
|
|
363
|
+
function translateAfter(
|
|
364
|
+
after: string | undefined,
|
|
365
|
+
from: string,
|
|
366
|
+
to: string,
|
|
367
|
+
): string | undefined | typeof SKIP {
|
|
368
|
+
if (after === undefined) return undefined;
|
|
369
|
+
const fromPrefix = from === "/" ? "" : from;
|
|
370
|
+
const toPrefix = to === "/" ? "" : to;
|
|
371
|
+
if (after.startsWith(`${fromPrefix}/`)) return `${toPrefix}${after.slice(fromPrefix.length)}`;
|
|
372
|
+
// Every descendant of `from` lies between `from + "/"` and `from + "0"`.
|
|
373
|
+
if (comparePaths(after, `${fromPrefix}/`) < 0) return undefined;
|
|
374
|
+
return SKIP;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isStrictlyInside(path: string, ancestor: string): boolean {
|
|
378
|
+
return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function* fromArray<T>(items: T[]): AsyncIterable<T> {
|
|
382
|
+
yield* items;
|
|
383
|
+
}
|
package/src/cow-files-api.ts
CHANGED
|
@@ -5,7 +5,13 @@ import type {
|
|
|
5
5
|
ListOptions,
|
|
6
6
|
ReadOptions,
|
|
7
7
|
} from "@statewalker/webrun-files";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
basename,
|
|
10
|
+
dirname,
|
|
11
|
+
joinPath,
|
|
12
|
+
listInPathOrder,
|
|
13
|
+
normalizePath,
|
|
14
|
+
} from "@statewalker/webrun-files";
|
|
9
15
|
|
|
10
16
|
/** Options for {@link cow}. */
|
|
11
17
|
export interface CowOptions {
|
|
@@ -142,19 +148,11 @@ class CowFilesApi implements FilesApi {
|
|
|
142
148
|
return (await this.resolveLayer(path)) !== "absent";
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
/** Merged children, walked in `comparePaths` order. */
|
|
145
152
|
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
private async *listDir(dir: string, recursive: boolean): AsyncIterable<FileInfo> {
|
|
150
|
-
const stats = await this.stats(dir);
|
|
151
|
-
if (stats?.kind !== "directory") return;
|
|
152
|
-
for (const entry of await this.mergeChildren(dir)) {
|
|
153
|
-
yield entry;
|
|
154
|
-
if (recursive && entry.kind === "directory") {
|
|
155
|
-
yield* this.listDir(entry.path, true);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
153
|
+
const dir = normalizePath(path);
|
|
154
|
+
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
155
|
+
yield* listInPathOrder(dir, (d) => this.mergeChildren(d), options);
|
|
158
156
|
}
|
|
159
157
|
|
|
160
158
|
/** Direct children of `dir`: writable entries win, base entries fill in. */
|
package/src/overlay-files-api.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ListOptions,
|
|
6
6
|
ReadOptions,
|
|
7
7
|
} from "@statewalker/webrun-files";
|
|
8
|
-
import { normalizePath } from "@statewalker/webrun-files";
|
|
8
|
+
import { listInPathOrder, normalizePath } from "@statewalker/webrun-files";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Read-only union of several `FilesApi` layers. A path is resolved
|
|
@@ -51,19 +51,11 @@ class OverlayFilesApi implements FilesApi {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Merged children, walked in `comparePaths` order. */
|
|
54
55
|
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
private async *listDir(dir: string, recursive: boolean): AsyncIterable<FileInfo> {
|
|
59
|
-
const stats = await this.stats(dir);
|
|
60
|
-
if (stats?.kind !== "directory") return;
|
|
61
|
-
for (const entry of await this.mergeChildren(dir)) {
|
|
62
|
-
yield entry;
|
|
63
|
-
if (recursive && entry.kind === "directory") {
|
|
64
|
-
yield* this.listDir(entry.path, true);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
56
|
+
const dir = normalizePath(path);
|
|
57
|
+
if ((await this.stats(dir))?.kind !== "directory") return;
|
|
58
|
+
yield* listInPathOrder(dir, (d) => this.mergeChildren(d), options);
|
|
67
59
|
}
|
|
68
60
|
|
|
69
61
|
/** Direct children of `dir`, deduped by name with the topmost layer winning. */
|