@statewalker/webrun-files-composite 0.9.0 → 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 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 building blocks:
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 throws — `CompositeFilesApi`.
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>")`. A guard that lists
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
- `CompositeFilesApi` resolves `move` and `copy` across mounts by performing
163
- a recursive copy and (for `move`) deleting the source. Use guards/filters
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 (forward slashes, leading `/`, no trailing `/`).
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
 
@@ -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 { api, resolvedPath } = this.resolve(path);
168
- const childMounts = this.childMountPrefixes(normalized);
169
- const yieldedNames = /* @__PURE__ */ new Set();
170
- if (options?.recursive) {
171
- for await (const entry of api.list(resolvedPath, options)) {
172
- const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
173
- if (this.isUnderChildMount(compositePath, childMounts)) continue;
174
- yieldedNames.add(entry.name);
175
- yield {
176
- ...entry,
177
- path: compositePath
178
- };
179
- }
180
- for (const mountPrefix of childMounts) {
181
- const mount = this.mounts.find((m) => m.prefix === mountPrefix);
182
- if (!mount) continue;
183
- yield {
184
- name: mountPrefix.split("/").pop() ?? "",
185
- path: mountPrefix,
186
- kind: "directory"
187
- };
188
- for await (const entry of mount.api.list(mount.basePath, { recursive: true })) {
189
- const localPath = this.stripBasePath(entry.path, mount.basePath);
190
- yield {
191
- ...entry,
192
- path: `${mountPrefix}${localPath === "/" ? "" : localPath}`
193
- };
194
- }
195
- }
196
- } else {
197
- for await (const entry of api.list(resolvedPath)) {
198
- const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
199
- yieldedNames.add(entry.name);
200
- yield {
201
- ...entry,
202
- path: compositePath
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
- yield* this.listDir(normalizePath(path), options?.recursive ?? false);
373
- }
374
- async *listDir(dir, recursive) {
514
+ const dir = normalizePath(path);
375
515
  if ((await this.stats(dir))?.kind !== "directory") return;
376
- for (const entry of await this.mergeChildren(dir)) {
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
- yield* this.listDir(normalizePath(path), options?.recursive ?? false);
885
- }
886
- async *listDir(dir, recursive) {
1022
+ const dir = normalizePath(path);
887
1023
  if ((await this.stats(dir))?.kind !== "directory") return;
888
- for (const entry of await this.mergeChildren(dir)) {
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;IAEM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CA4CxE;IAEK,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;IAID,OAAO,CAAC,aAAa;YAOP,SAAS;IA4BvB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;CAQ1B"}
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;AAGnC,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAwMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,QAAQ,CAEnF"}
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 { api, resolvedPath } = this.resolve(path);
167
- const childMounts = this.childMountPrefixes(normalized);
168
- const yieldedNames = /* @__PURE__ */ new Set();
169
- if (options?.recursive) {
170
- for await (const entry of api.list(resolvedPath, options)) {
171
- const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
172
- if (this.isUnderChildMount(compositePath, childMounts)) continue;
173
- yieldedNames.add(entry.name);
174
- yield {
175
- ...entry,
176
- path: compositePath
177
- };
178
- }
179
- for (const mountPrefix of childMounts) {
180
- const mount = this.mounts.find((m) => m.prefix === mountPrefix);
181
- if (!mount) continue;
182
- yield {
183
- name: mountPrefix.split("/").pop() ?? "",
184
- path: mountPrefix,
185
- kind: "directory"
186
- };
187
- for await (const entry of mount.api.list(mount.basePath, { recursive: true })) {
188
- const localPath = this.stripBasePath(entry.path, mount.basePath);
189
- yield {
190
- ...entry,
191
- path: `${mountPrefix}${localPath === "/" ? "" : localPath}`
192
- };
193
- }
194
- }
195
- } else {
196
- for await (const entry of api.list(resolvedPath)) {
197
- const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
198
- yieldedNames.add(entry.name);
199
- yield {
200
- ...entry,
201
- path: compositePath
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
- yield* this.listDir(normalizePath(path), options?.recursive ?? false);
372
- }
373
- async *listDir(dir, recursive) {
513
+ const dir = normalizePath(path);
374
514
  if ((await this.stats(dir))?.kind !== "directory") return;
375
- for (const entry of await this.mergeChildren(dir)) {
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
- yield* this.listDir(normalizePath(path), options?.recursive ?? false);
884
- }
885
- async *listDir(dir, recursive) {
1021
+ const dir = normalizePath(path);
886
1022
  if ((await this.stats(dir))?.kind !== "directory") return;
887
- for (const entry of await this.mergeChildren(dir)) {
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;AA+FnC;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAErE"}
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.9.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.9.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.9.0",
41
- "@statewalker/webrun-files-tests": "2.0.0"
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 { api, resolvedPath } = this.resolve(path);
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 childMounts = this.childMountPrefixes(normalized);
149
- const yieldedNames = new Set<string>();
150
-
151
- if (options?.recursive) {
152
- // Yield entries from the primary mount
153
- for await (const entry of api.list(resolvedPath, options)) {
154
- // Remap path back to composite namespace
155
- const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
156
- // Skip if this path falls under a child mount
157
- if (this.isUnderChildMount(compositePath, childMounts)) continue;
158
- yieldedNames.add(entry.name);
159
- yield { ...entry, path: compositePath };
160
- }
161
- // Recursively yield from child mounts
162
- for (const mountPrefix of childMounts) {
163
- const mount = this.mounts.find((m) => m.prefix === mountPrefix);
164
- if (!mount) continue;
165
- const mountName = mountPrefix.split("/").pop() ?? "";
166
- // Yield the mount directory entry itself
167
- yield { name: mountName, path: mountPrefix, kind: "directory" };
168
- for await (const entry of mount.api.list(mount.basePath, { recursive: true })) {
169
- const localPath = this.stripBasePath(entry.path, mount.basePath);
170
- yield { ...entry, path: `${mountPrefix}${localPath === "/" ? "" : localPath}` };
171
- }
172
- }
173
- } else {
174
- // Non-recursive: yield direct children from the primary mount
175
- for await (const entry of api.list(resolvedPath)) {
176
- const compositePath = this.remapPath(normalized, resolvedPath, entry.path);
177
- yieldedNames.add(entry.name);
178
- yield { ...entry, path: compositePath };
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
- // Add synthetic directory entries for child mounts not already present
181
- for (const mountPrefix of childMounts) {
182
- const mountName = mountPrefix.split("/").pop() ?? "";
183
- if (!yieldedNames.has(mountName)) {
184
- yield { name: mountName, path: mountPrefix, kind: "directory" };
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
+ }
@@ -5,7 +5,13 @@ import type {
5
5
  ListOptions,
6
6
  ReadOptions,
7
7
  } from "@statewalker/webrun-files";
8
- import { basename, dirname, joinPath, normalizePath } from "@statewalker/webrun-files";
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
- yield* this.listDir(normalizePath(path), options?.recursive ?? false);
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. */
@@ -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
- yield* this.listDir(normalizePath(path), options?.recursive ?? false);
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. */