@statewalker/webrun-files-composite 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026 statewalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -22,6 +22,25 @@ function normalizePath(filePath) {
22
22
  function joinPath(...segments) {
23
23
  return normalizePath(segments.join("/"));
24
24
  }
25
+ /**
26
+ * Returns the directory portion of a path.
27
+ */
28
+ function dirname(path) {
29
+ const normalized = normalizePath(path);
30
+ const lastSlash = normalized.lastIndexOf("/");
31
+ if (lastSlash <= 0) return "/";
32
+ return normalized.substring(0, lastSlash);
33
+ }
34
+ /**
35
+ * Returns the filename portion of a path.
36
+ */
37
+ function basename(path, ext) {
38
+ const normalized = normalizePath(path);
39
+ const lastSlash = normalized.lastIndexOf("/");
40
+ const name = lastSlash === -1 ? normalized : normalized.substring(lastSlash + 1);
41
+ if (ext && name.endsWith(ext)) return name.substring(0, name.length - ext.length);
42
+ return name;
43
+ }
25
44
  //#endregion
26
45
  //#region src/composite-files-api.ts
27
46
  /**
@@ -258,6 +277,177 @@ var CompositeFilesApi = class {
258
277
  }
259
278
  };
260
279
  //#endregion
280
+ //#region src/cow-files-api.ts
281
+ /**
282
+ * Copy-on-write view: a writable layer over a read-only `base`. The `base`
283
+ * is **never** mutated — every change (write, delete, move) is recorded in
284
+ * the `writable` layer, using marker files to record deletions.
285
+ *
286
+ * Resolution precedence for any path:
287
+ * 1. a covering whiteout / opaque marker ⇒ the path is absent;
288
+ * 2. otherwise the `writable` layer, if it has the path;
289
+ * 3. otherwise fall through to `base`.
290
+ *
291
+ * Deletions are persisted as marker files inside the writable layer, so they
292
+ * survive across process restarts and work over any `FilesApi` backend.
293
+ * Markers are hidden from `read` / `list` / `stats` / `exists`.
294
+ *
295
+ * Use {@link cow} to construct one.
296
+ */
297
+ var CowFilesApi = class {
298
+ base;
299
+ writable;
300
+ whiteoutPrefix;
301
+ opaqueName;
302
+ constructor(base, writable, opts) {
303
+ this.base = base;
304
+ this.writable = writable;
305
+ this.whiteoutPrefix = opts?.whiteoutPrefix ?? ".wh.";
306
+ this.opaqueName = opts?.opaqueName ?? ".wh..opq";
307
+ }
308
+ whiteoutPath(path) {
309
+ const p = normalizePath(path);
310
+ return joinPath(dirname(p), `${this.whiteoutPrefix}${basename(p)}`);
311
+ }
312
+ opaquePath(dir) {
313
+ return joinPath(normalizePath(dir), this.opaqueName);
314
+ }
315
+ isMarker(name) {
316
+ return name.startsWith(this.whiteoutPrefix) || name === this.opaqueName;
317
+ }
318
+ *ancestorsInclusive(dir) {
319
+ let cur = normalizePath(dir);
320
+ yield cur;
321
+ while (cur !== "/") {
322
+ cur = dirname(cur);
323
+ yield cur;
324
+ }
325
+ }
326
+ async hasDirectWhiteout(path) {
327
+ return this.writable.exists(this.whiteoutPath(path));
328
+ }
329
+ /** True when an opaque marker on `dir` or any ancestor hides base content. */
330
+ async isBaseContentHidden(dir) {
331
+ for (const ancestor of this.ancestorsInclusive(dir)) if (await this.writable.exists(this.opaquePath(ancestor))) return true;
332
+ return false;
333
+ }
334
+ /** True when the base version of `path` is hidden by a marker. */
335
+ async isBaseHidden(path) {
336
+ if (await this.hasDirectWhiteout(path)) return true;
337
+ return this.isBaseContentHidden(dirname(normalizePath(path)));
338
+ }
339
+ async resolveLayer(path) {
340
+ if (await this.writable.exists(path)) return "writable";
341
+ if (await this.isBaseHidden(path)) return "absent";
342
+ if (await this.base.exists(path)) return "base";
343
+ return "absent";
344
+ }
345
+ /** Removes a covering whiteout marker so a write/mkdir resurrects the path. */
346
+ async clearWhiteout(path) {
347
+ const marker = this.whiteoutPath(path);
348
+ if (await this.writable.exists(marker)) await this.writable.remove(marker);
349
+ }
350
+ async *read(path, options) {
351
+ const layer = await this.resolveLayer(path);
352
+ if (layer === "writable") yield* this.writable.read(path, options);
353
+ else if (layer === "base") yield* this.base.read(path, options);
354
+ }
355
+ async write(path, content) {
356
+ await this.clearWhiteout(path);
357
+ await this.writable.write(path, content);
358
+ }
359
+ async mkdir(path) {
360
+ await this.clearWhiteout(path);
361
+ await this.writable.mkdir(path);
362
+ }
363
+ async stats(path) {
364
+ const layer = await this.resolveLayer(path);
365
+ if (layer === "writable") return this.writable.stats(path);
366
+ if (layer === "base") return this.base.stats(path);
367
+ }
368
+ async exists(path) {
369
+ return await this.resolveLayer(path) !== "absent";
370
+ }
371
+ async *list(path, options) {
372
+ yield* this.listDir(normalizePath(path), options?.recursive ?? false);
373
+ }
374
+ async *listDir(dir, recursive) {
375
+ 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
+ }
380
+ }
381
+ /** Direct children of `dir`: writable entries win, base entries fill in. */
382
+ async mergeChildren(dir) {
383
+ const merged = /* @__PURE__ */ new Map();
384
+ if ((await this.writable.stats(dir))?.kind === "directory") for await (const entry of this.writable.list(dir)) {
385
+ if (this.isMarker(entry.name)) continue;
386
+ merged.set(entry.name, entry);
387
+ }
388
+ if (!await this.isBaseContentHidden(dir) && (await this.base.stats(dir))?.kind === "directory") for await (const entry of this.base.list(dir)) {
389
+ if (merged.has(entry.name)) continue;
390
+ if (await this.hasDirectWhiteout(entry.path)) continue;
391
+ merged.set(entry.name, entry);
392
+ }
393
+ return [...merged.values()];
394
+ }
395
+ async remove(path) {
396
+ if (await this.resolveLayer(path) === "absent") return false;
397
+ if (await this.writable.exists(path)) await this.writable.remove(path);
398
+ const baseStats = await this.base.stats(path);
399
+ if (baseStats) {
400
+ const marker = baseStats.kind === "directory" ? this.opaquePath(path) : this.whiteoutPath(path);
401
+ await this.writable.write(marker, []);
402
+ }
403
+ return true;
404
+ }
405
+ async move(source, target) {
406
+ const stats = await this.stats(source);
407
+ if (!stats) return false;
408
+ await this.copyInto(source, target, stats);
409
+ await this.remove(source);
410
+ return true;
411
+ }
412
+ async copy(source, target) {
413
+ const stats = await this.stats(source);
414
+ if (!stats) return false;
415
+ await this.copyInto(source, target, stats);
416
+ return true;
417
+ }
418
+ /** Copies the composite view of `src` into the writable layer at `tgt`. */
419
+ async copyInto(src, tgt, stats) {
420
+ if (stats.kind === "file") {
421
+ await this.write(tgt, this.read(src));
422
+ return;
423
+ }
424
+ await this.mkdir(tgt);
425
+ for await (const entry of this.list(src)) await this.copyInto(entry.path, joinPath(tgt, entry.name), entry);
426
+ }
427
+ };
428
+ /**
429
+ * Builds a copy-on-write `FilesApi`: a `writable` layer over a read-only
430
+ * `base`. Reads fall through to `base`; every write goes to `writable`;
431
+ * `base` is never mutated. Deletions are persisted as marker files in
432
+ * `writable` (a per-path whiteout for files, one opaque marker for a deleted
433
+ * base directory), so they survive over any backend and across restarts.
434
+ *
435
+ * @param base The read-only lower layer. Never mutated.
436
+ * @param writable The upper layer that captures all changes and markers.
437
+ * @param opts Marker naming overrides (see {@link CowOptions}).
438
+ * @returns A read/write `FilesApi` composing the two layers.
439
+ *
440
+ * @example
441
+ * ```ts
442
+ * const fs = cow(releaseFiles, new MemFilesApi());
443
+ * await fs.write("/a.txt", data); // captured in the writable layer
444
+ * await fs.remove("/base-only"); // whiteout marker; base untouched
445
+ * ```
446
+ */
447
+ function cow(base, writable, opts) {
448
+ return new CowFilesApi(base, writable, opts);
449
+ }
450
+ //#endregion
261
451
  //#region src/glob-to-regexp.ts
262
452
  /**
263
453
  * Compiles a glob pattern into a `RegExp`.
@@ -656,10 +846,136 @@ var GuardedFilesApi = class {
656
846
  }
657
847
  };
658
848
  //#endregion
849
+ //#region src/overlay-files-api.ts
850
+ /**
851
+ * Read-only union of several `FilesApi` layers. A path is resolved
852
+ * **top → bottom**: the first layer that has it wins for
853
+ * `read` / `stats` / `exists`, and its entry (including `kind`) wins any
854
+ * name clash in `list`. Listings merge and dedupe across every layer.
855
+ *
856
+ * The union never writes — every mutating call throws. Use
857
+ * {@link overlay} to construct one.
858
+ */
859
+ var OverlayFilesApi = class {
860
+ layers;
861
+ constructor(layers) {
862
+ this.layers = layers;
863
+ }
864
+ deny(op, path) {
865
+ throw new Error(`overlay is read-only (${op}): ${normalizePath(path)}`);
866
+ }
867
+ async *read(path, options) {
868
+ for (const layer of this.layers) if (await layer.exists(path)) {
869
+ yield* layer.read(path, options);
870
+ return;
871
+ }
872
+ }
873
+ async stats(path) {
874
+ for (const layer of this.layers) {
875
+ const stats = await layer.stats(path);
876
+ if (stats) return stats;
877
+ }
878
+ }
879
+ async exists(path) {
880
+ for (const layer of this.layers) if (await layer.exists(path)) return true;
881
+ return false;
882
+ }
883
+ async *list(path, options) {
884
+ yield* this.listDir(normalizePath(path), options?.recursive ?? false);
885
+ }
886
+ async *listDir(dir, recursive) {
887
+ 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
+ }
892
+ }
893
+ /** Direct children of `dir`, deduped by name with the topmost layer winning. */
894
+ async mergeChildren(dir) {
895
+ const merged = /* @__PURE__ */ new Map();
896
+ for (const layer of this.layers) {
897
+ if ((await layer.stats(dir))?.kind !== "directory") continue;
898
+ for await (const entry of layer.list(dir)) if (!merged.has(entry.name)) merged.set(entry.name, entry);
899
+ }
900
+ return [...merged.values()];
901
+ }
902
+ async write(path) {
903
+ this.deny("write", path);
904
+ }
905
+ async mkdir(path) {
906
+ this.deny("mkdir", path);
907
+ }
908
+ async remove(path) {
909
+ return this.deny("remove", path);
910
+ }
911
+ async move(source) {
912
+ return this.deny("move", source);
913
+ }
914
+ async copy(source) {
915
+ return this.deny("copy", source);
916
+ }
917
+ };
918
+ /**
919
+ * Builds a **read-only** union view over `top` and any number of `lower`
920
+ * layers. Reads resolve top → bottom (first layer that has the path wins);
921
+ * `list` merges and dedupes across all layers with `top` winning a clash
922
+ * (including a file-vs-directory `kind` clash). Every write is denied.
923
+ *
924
+ * @param top The highest-priority layer; its entries shadow the rest.
925
+ * @param lower Additional layers, consulted in order after `top`.
926
+ * @returns A read-only `FilesApi` union.
927
+ *
928
+ * @example
929
+ * ```ts
930
+ * const view = overlay(userFiles, defaultFiles);
931
+ * await view.read("/config.json"); // userFiles if present, else defaultFiles
932
+ * await view.write("/x", data); // throws (read-only)
933
+ * ```
934
+ */
935
+ function overlay(top, ...lower) {
936
+ return new OverlayFilesApi([top, ...lower]);
937
+ }
938
+ //#endregion
939
+ //#region src/read-only-files-api.ts
940
+ /**
941
+ * Wraps a `FilesApi` in a read-only view: every mutating operation
942
+ * (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
943
+ * (`read`, `list`, `stats`, `exists`) pass straight through to `api`.
944
+ *
945
+ * Implemented as a {@link GuardedFilesApi} with a single deny-all guard on
946
+ * the mutating operations, so `move`/`copy` are blocked on either endpoint.
947
+ *
948
+ * @param api The underlying `FilesApi` to expose read-only.
949
+ * @returns A `FilesApi` that never mutates `api`.
950
+ *
951
+ * @example
952
+ * ```ts
953
+ * const ro = readOnly(sourceFiles);
954
+ * await ro.read("/a.txt"); // ok
955
+ * await ro.write("/a.txt", data); // throws "read-only: /a.txt"
956
+ * ```
957
+ */
958
+ function readOnly(api) {
959
+ return new GuardedFilesApi(api, [{
960
+ operations: [
961
+ "write",
962
+ "mkdir",
963
+ "remove",
964
+ "move",
965
+ "copy"
966
+ ],
967
+ check: () => false,
968
+ message: "read-only"
969
+ }]);
970
+ }
971
+ //#endregion
659
972
  exports.CompositeFilesApi = CompositeFilesApi;
660
973
  exports.FilteredFilesApi = FilteredFilesApi;
661
974
  exports.GuardedFilesApi = GuardedFilesApi;
975
+ exports.cow = cow;
662
976
  exports.globToRegExp = globToRegExp;
663
977
  exports.newGlobPathFilter = newGlobPathFilter;
664
978
  exports.newPathFilter = newPathFilter;
665
979
  exports.newRegexpPathFilter = newRegexpPathFilter;
980
+ exports.overlay = overlay;
981
+ exports.readOnly = readOnly;
@@ -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;gBACS,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,MAAM;IAI7C;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAazD,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;IAK9D,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAKV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IA8CnE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IASnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAStC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAStC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAiBtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAe5D,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;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"}
@@ -0,0 +1,36 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+ /** Options for {@link cow}. */
3
+ export interface CowOptions {
4
+ /**
5
+ * Filename prefix for per-path whiteout markers, stored in the writable
6
+ * layer. A whiteout for `/dir/name` lives at `/dir/<prefix>name`.
7
+ * Defaults to `".wh."`.
8
+ */
9
+ whiteoutPrefix?: string;
10
+ /**
11
+ * Filename for the opaque-directory marker, stored as a child of a
12
+ * directory whose base subtree has been deleted. Defaults to `".wh..opq"`.
13
+ */
14
+ opaqueName?: string;
15
+ }
16
+ /**
17
+ * Builds a copy-on-write `FilesApi`: a `writable` layer over a read-only
18
+ * `base`. Reads fall through to `base`; every write goes to `writable`;
19
+ * `base` is never mutated. Deletions are persisted as marker files in
20
+ * `writable` (a per-path whiteout for files, one opaque marker for a deleted
21
+ * base directory), so they survive over any backend and across restarts.
22
+ *
23
+ * @param base The read-only lower layer. Never mutated.
24
+ * @param writable The upper layer that captures all changes and markers.
25
+ * @param opts Marker naming overrides (see {@link CowOptions}).
26
+ * @returns A read/write `FilesApi` composing the two layers.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const fs = cow(releaseFiles, new MemFilesApi());
31
+ * await fs.write("/a.txt", data); // captured in the writable layer
32
+ * await fs.remove("/base-only"); // whiteout marker; base untouched
33
+ * ```
34
+ */
35
+ export declare function cow(base: FilesApi, writable: FilesApi, opts?: CowOptions): FilesApi;
36
+ //# sourceMappingURL=cow-files-api.d.ts.map
@@ -0,0 +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"}
package/dist/esm/index.js CHANGED
@@ -21,6 +21,25 @@ function normalizePath(filePath) {
21
21
  function joinPath(...segments) {
22
22
  return normalizePath(segments.join("/"));
23
23
  }
24
+ /**
25
+ * Returns the directory portion of a path.
26
+ */
27
+ function dirname(path) {
28
+ const normalized = normalizePath(path);
29
+ const lastSlash = normalized.lastIndexOf("/");
30
+ if (lastSlash <= 0) return "/";
31
+ return normalized.substring(0, lastSlash);
32
+ }
33
+ /**
34
+ * Returns the filename portion of a path.
35
+ */
36
+ function basename(path, ext) {
37
+ const normalized = normalizePath(path);
38
+ const lastSlash = normalized.lastIndexOf("/");
39
+ const name = lastSlash === -1 ? normalized : normalized.substring(lastSlash + 1);
40
+ if (ext && name.endsWith(ext)) return name.substring(0, name.length - ext.length);
41
+ return name;
42
+ }
24
43
  //#endregion
25
44
  //#region src/composite-files-api.ts
26
45
  /**
@@ -257,6 +276,177 @@ var CompositeFilesApi = class {
257
276
  }
258
277
  };
259
278
  //#endregion
279
+ //#region src/cow-files-api.ts
280
+ /**
281
+ * Copy-on-write view: a writable layer over a read-only `base`. The `base`
282
+ * is **never** mutated — every change (write, delete, move) is recorded in
283
+ * the `writable` layer, using marker files to record deletions.
284
+ *
285
+ * Resolution precedence for any path:
286
+ * 1. a covering whiteout / opaque marker ⇒ the path is absent;
287
+ * 2. otherwise the `writable` layer, if it has the path;
288
+ * 3. otherwise fall through to `base`.
289
+ *
290
+ * Deletions are persisted as marker files inside the writable layer, so they
291
+ * survive across process restarts and work over any `FilesApi` backend.
292
+ * Markers are hidden from `read` / `list` / `stats` / `exists`.
293
+ *
294
+ * Use {@link cow} to construct one.
295
+ */
296
+ var CowFilesApi = class {
297
+ base;
298
+ writable;
299
+ whiteoutPrefix;
300
+ opaqueName;
301
+ constructor(base, writable, opts) {
302
+ this.base = base;
303
+ this.writable = writable;
304
+ this.whiteoutPrefix = opts?.whiteoutPrefix ?? ".wh.";
305
+ this.opaqueName = opts?.opaqueName ?? ".wh..opq";
306
+ }
307
+ whiteoutPath(path) {
308
+ const p = normalizePath(path);
309
+ return joinPath(dirname(p), `${this.whiteoutPrefix}${basename(p)}`);
310
+ }
311
+ opaquePath(dir) {
312
+ return joinPath(normalizePath(dir), this.opaqueName);
313
+ }
314
+ isMarker(name) {
315
+ return name.startsWith(this.whiteoutPrefix) || name === this.opaqueName;
316
+ }
317
+ *ancestorsInclusive(dir) {
318
+ let cur = normalizePath(dir);
319
+ yield cur;
320
+ while (cur !== "/") {
321
+ cur = dirname(cur);
322
+ yield cur;
323
+ }
324
+ }
325
+ async hasDirectWhiteout(path) {
326
+ return this.writable.exists(this.whiteoutPath(path));
327
+ }
328
+ /** True when an opaque marker on `dir` or any ancestor hides base content. */
329
+ async isBaseContentHidden(dir) {
330
+ for (const ancestor of this.ancestorsInclusive(dir)) if (await this.writable.exists(this.opaquePath(ancestor))) return true;
331
+ return false;
332
+ }
333
+ /** True when the base version of `path` is hidden by a marker. */
334
+ async isBaseHidden(path) {
335
+ if (await this.hasDirectWhiteout(path)) return true;
336
+ return this.isBaseContentHidden(dirname(normalizePath(path)));
337
+ }
338
+ async resolveLayer(path) {
339
+ if (await this.writable.exists(path)) return "writable";
340
+ if (await this.isBaseHidden(path)) return "absent";
341
+ if (await this.base.exists(path)) return "base";
342
+ return "absent";
343
+ }
344
+ /** Removes a covering whiteout marker so a write/mkdir resurrects the path. */
345
+ async clearWhiteout(path) {
346
+ const marker = this.whiteoutPath(path);
347
+ if (await this.writable.exists(marker)) await this.writable.remove(marker);
348
+ }
349
+ async *read(path, options) {
350
+ const layer = await this.resolveLayer(path);
351
+ if (layer === "writable") yield* this.writable.read(path, options);
352
+ else if (layer === "base") yield* this.base.read(path, options);
353
+ }
354
+ async write(path, content) {
355
+ await this.clearWhiteout(path);
356
+ await this.writable.write(path, content);
357
+ }
358
+ async mkdir(path) {
359
+ await this.clearWhiteout(path);
360
+ await this.writable.mkdir(path);
361
+ }
362
+ async stats(path) {
363
+ const layer = await this.resolveLayer(path);
364
+ if (layer === "writable") return this.writable.stats(path);
365
+ if (layer === "base") return this.base.stats(path);
366
+ }
367
+ async exists(path) {
368
+ return await this.resolveLayer(path) !== "absent";
369
+ }
370
+ async *list(path, options) {
371
+ yield* this.listDir(normalizePath(path), options?.recursive ?? false);
372
+ }
373
+ async *listDir(dir, recursive) {
374
+ 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
+ }
379
+ }
380
+ /** Direct children of `dir`: writable entries win, base entries fill in. */
381
+ async mergeChildren(dir) {
382
+ const merged = /* @__PURE__ */ new Map();
383
+ if ((await this.writable.stats(dir))?.kind === "directory") for await (const entry of this.writable.list(dir)) {
384
+ if (this.isMarker(entry.name)) continue;
385
+ merged.set(entry.name, entry);
386
+ }
387
+ if (!await this.isBaseContentHidden(dir) && (await this.base.stats(dir))?.kind === "directory") for await (const entry of this.base.list(dir)) {
388
+ if (merged.has(entry.name)) continue;
389
+ if (await this.hasDirectWhiteout(entry.path)) continue;
390
+ merged.set(entry.name, entry);
391
+ }
392
+ return [...merged.values()];
393
+ }
394
+ async remove(path) {
395
+ if (await this.resolveLayer(path) === "absent") return false;
396
+ if (await this.writable.exists(path)) await this.writable.remove(path);
397
+ const baseStats = await this.base.stats(path);
398
+ if (baseStats) {
399
+ const marker = baseStats.kind === "directory" ? this.opaquePath(path) : this.whiteoutPath(path);
400
+ await this.writable.write(marker, []);
401
+ }
402
+ return true;
403
+ }
404
+ async move(source, target) {
405
+ const stats = await this.stats(source);
406
+ if (!stats) return false;
407
+ await this.copyInto(source, target, stats);
408
+ await this.remove(source);
409
+ return true;
410
+ }
411
+ async copy(source, target) {
412
+ const stats = await this.stats(source);
413
+ if (!stats) return false;
414
+ await this.copyInto(source, target, stats);
415
+ return true;
416
+ }
417
+ /** Copies the composite view of `src` into the writable layer at `tgt`. */
418
+ async copyInto(src, tgt, stats) {
419
+ if (stats.kind === "file") {
420
+ await this.write(tgt, this.read(src));
421
+ return;
422
+ }
423
+ await this.mkdir(tgt);
424
+ for await (const entry of this.list(src)) await this.copyInto(entry.path, joinPath(tgt, entry.name), entry);
425
+ }
426
+ };
427
+ /**
428
+ * Builds a copy-on-write `FilesApi`: a `writable` layer over a read-only
429
+ * `base`. Reads fall through to `base`; every write goes to `writable`;
430
+ * `base` is never mutated. Deletions are persisted as marker files in
431
+ * `writable` (a per-path whiteout for files, one opaque marker for a deleted
432
+ * base directory), so they survive over any backend and across restarts.
433
+ *
434
+ * @param base The read-only lower layer. Never mutated.
435
+ * @param writable The upper layer that captures all changes and markers.
436
+ * @param opts Marker naming overrides (see {@link CowOptions}).
437
+ * @returns A read/write `FilesApi` composing the two layers.
438
+ *
439
+ * @example
440
+ * ```ts
441
+ * const fs = cow(releaseFiles, new MemFilesApi());
442
+ * await fs.write("/a.txt", data); // captured in the writable layer
443
+ * await fs.remove("/base-only"); // whiteout marker; base untouched
444
+ * ```
445
+ */
446
+ function cow(base, writable, opts) {
447
+ return new CowFilesApi(base, writable, opts);
448
+ }
449
+ //#endregion
260
450
  //#region src/glob-to-regexp.ts
261
451
  /**
262
452
  * Compiles a glob pattern into a `RegExp`.
@@ -655,4 +845,127 @@ var GuardedFilesApi = class {
655
845
  }
656
846
  };
657
847
  //#endregion
658
- export { CompositeFilesApi, FilteredFilesApi, GuardedFilesApi, globToRegExp, newGlobPathFilter, newPathFilter, newRegexpPathFilter };
848
+ //#region src/overlay-files-api.ts
849
+ /**
850
+ * Read-only union of several `FilesApi` layers. A path is resolved
851
+ * **top → bottom**: the first layer that has it wins for
852
+ * `read` / `stats` / `exists`, and its entry (including `kind`) wins any
853
+ * name clash in `list`. Listings merge and dedupe across every layer.
854
+ *
855
+ * The union never writes — every mutating call throws. Use
856
+ * {@link overlay} to construct one.
857
+ */
858
+ var OverlayFilesApi = class {
859
+ layers;
860
+ constructor(layers) {
861
+ this.layers = layers;
862
+ }
863
+ deny(op, path) {
864
+ throw new Error(`overlay is read-only (${op}): ${normalizePath(path)}`);
865
+ }
866
+ async *read(path, options) {
867
+ for (const layer of this.layers) if (await layer.exists(path)) {
868
+ yield* layer.read(path, options);
869
+ return;
870
+ }
871
+ }
872
+ async stats(path) {
873
+ for (const layer of this.layers) {
874
+ const stats = await layer.stats(path);
875
+ if (stats) return stats;
876
+ }
877
+ }
878
+ async exists(path) {
879
+ for (const layer of this.layers) if (await layer.exists(path)) return true;
880
+ return false;
881
+ }
882
+ async *list(path, options) {
883
+ yield* this.listDir(normalizePath(path), options?.recursive ?? false);
884
+ }
885
+ async *listDir(dir, recursive) {
886
+ 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
+ }
891
+ }
892
+ /** Direct children of `dir`, deduped by name with the topmost layer winning. */
893
+ async mergeChildren(dir) {
894
+ const merged = /* @__PURE__ */ new Map();
895
+ for (const layer of this.layers) {
896
+ if ((await layer.stats(dir))?.kind !== "directory") continue;
897
+ for await (const entry of layer.list(dir)) if (!merged.has(entry.name)) merged.set(entry.name, entry);
898
+ }
899
+ return [...merged.values()];
900
+ }
901
+ async write(path) {
902
+ this.deny("write", path);
903
+ }
904
+ async mkdir(path) {
905
+ this.deny("mkdir", path);
906
+ }
907
+ async remove(path) {
908
+ return this.deny("remove", path);
909
+ }
910
+ async move(source) {
911
+ return this.deny("move", source);
912
+ }
913
+ async copy(source) {
914
+ return this.deny("copy", source);
915
+ }
916
+ };
917
+ /**
918
+ * Builds a **read-only** union view over `top` and any number of `lower`
919
+ * layers. Reads resolve top → bottom (first layer that has the path wins);
920
+ * `list` merges and dedupes across all layers with `top` winning a clash
921
+ * (including a file-vs-directory `kind` clash). Every write is denied.
922
+ *
923
+ * @param top The highest-priority layer; its entries shadow the rest.
924
+ * @param lower Additional layers, consulted in order after `top`.
925
+ * @returns A read-only `FilesApi` union.
926
+ *
927
+ * @example
928
+ * ```ts
929
+ * const view = overlay(userFiles, defaultFiles);
930
+ * await view.read("/config.json"); // userFiles if present, else defaultFiles
931
+ * await view.write("/x", data); // throws (read-only)
932
+ * ```
933
+ */
934
+ function overlay(top, ...lower) {
935
+ return new OverlayFilesApi([top, ...lower]);
936
+ }
937
+ //#endregion
938
+ //#region src/read-only-files-api.ts
939
+ /**
940
+ * Wraps a `FilesApi` in a read-only view: every mutating operation
941
+ * (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
942
+ * (`read`, `list`, `stats`, `exists`) pass straight through to `api`.
943
+ *
944
+ * Implemented as a {@link GuardedFilesApi} with a single deny-all guard on
945
+ * the mutating operations, so `move`/`copy` are blocked on either endpoint.
946
+ *
947
+ * @param api The underlying `FilesApi` to expose read-only.
948
+ * @returns A `FilesApi` that never mutates `api`.
949
+ *
950
+ * @example
951
+ * ```ts
952
+ * const ro = readOnly(sourceFiles);
953
+ * await ro.read("/a.txt"); // ok
954
+ * await ro.write("/a.txt", data); // throws "read-only: /a.txt"
955
+ * ```
956
+ */
957
+ function readOnly(api) {
958
+ return new GuardedFilesApi(api, [{
959
+ operations: [
960
+ "write",
961
+ "mkdir",
962
+ "remove",
963
+ "move",
964
+ "copy"
965
+ ],
966
+ check: () => false,
967
+ message: "read-only"
968
+ }]);
969
+ }
970
+ //#endregion
971
+ export { CompositeFilesApi, FilteredFilesApi, GuardedFilesApi, cow, globToRegExp, newGlobPathFilter, newPathFilter, newRegexpPathFilter, overlay, readOnly };
@@ -1 +1 @@
1
- {"version":3,"file":"filtered-files-api.d.ts","sourceRoot":"","sources":["../src/filtered-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAInC;;;;;;;;;;GAUG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa,CAAC,GAAG,YAAY,EAAE,MAAM,EAAE,GAAG,UAAU,CAUnE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,WAAW,EAAE,MAAM,EAAE,GAAG,UAAU,CAQxE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,SAAS,EAAE,MAAM,EAAE,GAAG,UAAU,CAGpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,qBAAa,gBAAiB,YAAW,QAAQ;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAa;IAExC;;;;;;OAMG;gBACS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU;cAKpC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIjD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC;IAKrE,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAOV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IAQnE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAKnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAOtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAM7D"}
1
+ {"version":3,"file":"filtered-files-api.d.ts","sourceRoot":"","sources":["../src/filtered-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAInC;;;;;;;;;;GAUG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa,CAAC,GAAG,YAAY,EAAE,MAAM,EAAE,GAAG,UAAU,CAUnE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,WAAW,EAAE,MAAM,EAAE,GAAG,UAAU,CAQxE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,SAAS,EAAE,MAAM,EAAE,GAAG,UAAU,CAGpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,qBAAa,gBAAiB,YAAW,QAAQ;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAa;IAExC;;;;;;OAMG;IACH,YAAY,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAGnD;IAED,UAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEvD;IAEM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAG1E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAKf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvC;IAEM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAMxE;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAGxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAK3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAK3D;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"guarded-files-api.d.ts","sourceRoot":"","sources":["../src/guarded-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,YAAY,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,qBAAa,eAAgB,YAAW,QAAQ;IAC9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IAErC;;;;;;OAMG;gBACS,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE;IAKjD,OAAO,CAAC,UAAU;IAWlB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC;IAK9D,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAKV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IAUzE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAKnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKhC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAMtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAK7D"}
1
+ {"version":3,"file":"guarded-files-api.d.ts","sourceRoot":"","sources":["../src/guarded-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,YAAY,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,qBAAa,eAAgB,YAAW,QAAQ;IAC9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IAErC;;;;;;OAMG;IACH,YAAY,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,EAGhD;IAED,OAAO,CAAC,UAAU;IAWlB,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,CAQxE;IAED,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAGlD;IAED,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAGrC;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAI3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAI3D;CACF"}
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export { CompositeFilesApi } from "./composite-files-api.js";
2
+ export type { CowOptions } from "./cow-files-api.js";
3
+ export { cow } from "./cow-files-api.js";
2
4
  export type { PathFilter } from "./filtered-files-api.js";
3
5
  export { FilteredFilesApi, newGlobPathFilter, newPathFilter, newRegexpPathFilter, } from "./filtered-files-api.js";
4
6
  export type { GlobToRegExpOptions } from "./glob-to-regexp.js";
5
7
  export { globToRegExp } from "./glob-to-regexp.js";
6
8
  export { GuardedFilesApi } from "./guarded-files-api.js";
9
+ export { overlay } from "./overlay-files-api.js";
10
+ export { readOnly } from "./read-only-files-api.js";
7
11
  export type { FileGuard, FileOperation } from "./types.js";
8
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,mBAAmB,GACpB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,GAAG,EAAE,MAAM,oBAAoB,CAAC;AACzC,YAAY,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,mBAAmB,GACpB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AACpD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,20 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+ /**
3
+ * Builds a **read-only** union view over `top` and any number of `lower`
4
+ * layers. Reads resolve top → bottom (first layer that has the path wins);
5
+ * `list` merges and dedupes across all layers with `top` winning a clash
6
+ * (including a file-vs-directory `kind` clash). Every write is denied.
7
+ *
8
+ * @param top The highest-priority layer; its entries shadow the rest.
9
+ * @param lower Additional layers, consulted in order after `top`.
10
+ * @returns A read-only `FilesApi` union.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const view = overlay(userFiles, defaultFiles);
15
+ * await view.read("/config.json"); // userFiles if present, else defaultFiles
16
+ * await view.write("/x", data); // throws (read-only)
17
+ * ```
18
+ */
19
+ export declare function overlay(top: FilesApi, ...lower: FilesApi[]): FilesApi;
20
+ //# sourceMappingURL=overlay-files-api.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,21 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+ /**
3
+ * Wraps a `FilesApi` in a read-only view: every mutating operation
4
+ * (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
5
+ * (`read`, `list`, `stats`, `exists`) pass straight through to `api`.
6
+ *
7
+ * Implemented as a {@link GuardedFilesApi} with a single deny-all guard on
8
+ * the mutating operations, so `move`/`copy` are blocked on either endpoint.
9
+ *
10
+ * @param api The underlying `FilesApi` to expose read-only.
11
+ * @returns A `FilesApi` that never mutates `api`.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const ro = readOnly(sourceFiles);
16
+ * await ro.read("/a.txt"); // ok
17
+ * await ro.write("/a.txt", data); // throws "read-only: /a.txt"
18
+ * ```
19
+ */
20
+ export declare function readOnly(api: FilesApi): FilesApi;
21
+ //# sourceMappingURL=read-only-files-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-only-files-api.d.ts","sourceRoot":"","sources":["../src/read-only-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAG1D;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,QAAQ,GAAG,QAAQ,CAQhD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-composite",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Composite FilesApi with mount points and access guards",
@@ -28,25 +28,25 @@
28
28
  "dist",
29
29
  "src"
30
30
  ],
31
- "scripts": {
32
- "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
33
- "test": "vitest run",
34
- "lint": "biome lint src tests"
35
- },
36
31
  "dependencies": {
37
- "@statewalker/webrun-files": "workspace:*"
32
+ "@statewalker/webrun-files": "0.7.0"
38
33
  },
39
34
  "devDependencies": {
40
- "@statewalker/webrun-files-tests": "workspace:*",
41
- "@statewalker/webrun-files-mem": "workspace:*",
42
- "@types/node": "catalog:",
43
- "rimraf": "catalog:",
44
- "rolldown": "catalog:",
45
- "typescript": "catalog:",
46
- "vitest": "catalog:"
35
+ "@types/node": "^26.2.0",
36
+ "rimraf": "^6.1.3",
37
+ "rolldown": "^1.2.4",
38
+ "typescript": "^7.0.2",
39
+ "vitest": "^4.1.10",
40
+ "@statewalker/webrun-files-mem": "0.7.2",
41
+ "@statewalker/webrun-files-tests": "1.0.0"
47
42
  },
48
43
  "sideEffects": false,
49
44
  "publishConfig": {
50
45
  "access": "public"
46
+ },
47
+ "scripts": {
48
+ "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
49
+ "test": "vitest run",
50
+ "lint": "biome lint src tests"
51
51
  }
52
- }
52
+ }
@@ -0,0 +1,244 @@
1
+ import type {
2
+ FileInfo,
3
+ FileStats,
4
+ FilesApi,
5
+ ListOptions,
6
+ ReadOptions,
7
+ } from "@statewalker/webrun-files";
8
+ import { basename, dirname, joinPath, normalizePath } from "@statewalker/webrun-files";
9
+
10
+ /** Options for {@link cow}. */
11
+ export interface CowOptions {
12
+ /**
13
+ * Filename prefix for per-path whiteout markers, stored in the writable
14
+ * layer. A whiteout for `/dir/name` lives at `/dir/<prefix>name`.
15
+ * Defaults to `".wh."`.
16
+ */
17
+ whiteoutPrefix?: string;
18
+ /**
19
+ * Filename for the opaque-directory marker, stored as a child of a
20
+ * directory whose base subtree has been deleted. Defaults to `".wh..opq"`.
21
+ */
22
+ opaqueName?: string;
23
+ }
24
+
25
+ type Layer = "writable" | "base" | "absent";
26
+
27
+ /**
28
+ * Copy-on-write view: a writable layer over a read-only `base`. The `base`
29
+ * is **never** mutated — every change (write, delete, move) is recorded in
30
+ * the `writable` layer, using marker files to record deletions.
31
+ *
32
+ * Resolution precedence for any path:
33
+ * 1. a covering whiteout / opaque marker ⇒ the path is absent;
34
+ * 2. otherwise the `writable` layer, if it has the path;
35
+ * 3. otherwise fall through to `base`.
36
+ *
37
+ * Deletions are persisted as marker files inside the writable layer, so they
38
+ * survive across process restarts and work over any `FilesApi` backend.
39
+ * Markers are hidden from `read` / `list` / `stats` / `exists`.
40
+ *
41
+ * Use {@link cow} to construct one.
42
+ */
43
+ class CowFilesApi implements FilesApi {
44
+ private readonly base: FilesApi;
45
+ private readonly writable: FilesApi;
46
+ private readonly whiteoutPrefix: string;
47
+ private readonly opaqueName: string;
48
+
49
+ constructor(base: FilesApi, writable: FilesApi, opts?: CowOptions) {
50
+ this.base = base;
51
+ this.writable = writable;
52
+ this.whiteoutPrefix = opts?.whiteoutPrefix ?? ".wh.";
53
+ this.opaqueName = opts?.opaqueName ?? ".wh..opq";
54
+ }
55
+
56
+ // --- marker helpers ---
57
+
58
+ private whiteoutPath(path: string): string {
59
+ const p = normalizePath(path);
60
+ return joinPath(dirname(p), `${this.whiteoutPrefix}${basename(p)}`);
61
+ }
62
+
63
+ private opaquePath(dir: string): string {
64
+ return joinPath(normalizePath(dir), this.opaqueName);
65
+ }
66
+
67
+ private isMarker(name: string): boolean {
68
+ return name.startsWith(this.whiteoutPrefix) || name === this.opaqueName;
69
+ }
70
+
71
+ private *ancestorsInclusive(dir: string): Iterable<string> {
72
+ let cur = normalizePath(dir);
73
+ yield cur;
74
+ while (cur !== "/") {
75
+ cur = dirname(cur);
76
+ yield cur;
77
+ }
78
+ }
79
+
80
+ private async hasDirectWhiteout(path: string): Promise<boolean> {
81
+ return this.writable.exists(this.whiteoutPath(path));
82
+ }
83
+
84
+ /** True when an opaque marker on `dir` or any ancestor hides base content. */
85
+ private async isBaseContentHidden(dir: string): Promise<boolean> {
86
+ for (const ancestor of this.ancestorsInclusive(dir)) {
87
+ if (await this.writable.exists(this.opaquePath(ancestor))) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /** True when the base version of `path` is hidden by a marker. */
93
+ private async isBaseHidden(path: string): Promise<boolean> {
94
+ if (await this.hasDirectWhiteout(path)) return true;
95
+ return this.isBaseContentHidden(dirname(normalizePath(path)));
96
+ }
97
+
98
+ private async resolveLayer(path: string): Promise<Layer> {
99
+ if (await this.writable.exists(path)) return "writable";
100
+ if (await this.isBaseHidden(path)) return "absent";
101
+ if (await this.base.exists(path)) return "base";
102
+ return "absent";
103
+ }
104
+
105
+ /** Removes a covering whiteout marker so a write/mkdir resurrects the path. */
106
+ private async clearWhiteout(path: string): Promise<void> {
107
+ const marker = this.whiteoutPath(path);
108
+ if (await this.writable.exists(marker)) {
109
+ await this.writable.remove(marker);
110
+ }
111
+ }
112
+
113
+ // --- FilesApi ---
114
+
115
+ async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
116
+ const layer = await this.resolveLayer(path);
117
+ if (layer === "writable") yield* this.writable.read(path, options);
118
+ else if (layer === "base") yield* this.base.read(path, options);
119
+ }
120
+
121
+ async write(
122
+ path: string,
123
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
124
+ ): Promise<void> {
125
+ await this.clearWhiteout(path);
126
+ await this.writable.write(path, content);
127
+ }
128
+
129
+ async mkdir(path: string): Promise<void> {
130
+ await this.clearWhiteout(path);
131
+ await this.writable.mkdir(path);
132
+ }
133
+
134
+ async stats(path: string): Promise<FileStats | undefined> {
135
+ const layer = await this.resolveLayer(path);
136
+ if (layer === "writable") return this.writable.stats(path);
137
+ if (layer === "base") return this.base.stats(path);
138
+ return undefined;
139
+ }
140
+
141
+ async exists(path: string): Promise<boolean> {
142
+ return (await this.resolveLayer(path)) !== "absent";
143
+ }
144
+
145
+ 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
+ }
158
+ }
159
+
160
+ /** Direct children of `dir`: writable entries win, base entries fill in. */
161
+ private async mergeChildren(dir: string): Promise<FileInfo[]> {
162
+ const merged = new Map<string, FileInfo>();
163
+ if ((await this.writable.stats(dir))?.kind === "directory") {
164
+ for await (const entry of this.writable.list(dir)) {
165
+ if (this.isMarker(entry.name)) continue;
166
+ merged.set(entry.name, entry);
167
+ }
168
+ }
169
+ const baseHidden = await this.isBaseContentHidden(dir);
170
+ if (!baseHidden && (await this.base.stats(dir))?.kind === "directory") {
171
+ for await (const entry of this.base.list(dir)) {
172
+ if (merged.has(entry.name)) continue;
173
+ if (await this.hasDirectWhiteout(entry.path)) continue;
174
+ merged.set(entry.name, entry);
175
+ }
176
+ }
177
+ return [...merged.values()];
178
+ }
179
+
180
+ async remove(path: string): Promise<boolean> {
181
+ if ((await this.resolveLayer(path)) === "absent") return false;
182
+ if (await this.writable.exists(path)) {
183
+ await this.writable.remove(path);
184
+ }
185
+ // Record a whiteout only when the base still carries the path.
186
+ const baseStats = await this.base.stats(path);
187
+ if (baseStats) {
188
+ const marker =
189
+ baseStats.kind === "directory" ? this.opaquePath(path) : this.whiteoutPath(path);
190
+ await this.writable.write(marker, []);
191
+ }
192
+ return true;
193
+ }
194
+
195
+ async move(source: string, target: string): Promise<boolean> {
196
+ const stats = await this.stats(source);
197
+ if (!stats) return false;
198
+ await this.copyInto(source, target, stats);
199
+ await this.remove(source);
200
+ return true;
201
+ }
202
+
203
+ async copy(source: string, target: string): Promise<boolean> {
204
+ const stats = await this.stats(source);
205
+ if (!stats) return false;
206
+ await this.copyInto(source, target, stats);
207
+ return true;
208
+ }
209
+
210
+ /** Copies the composite view of `src` into the writable layer at `tgt`. */
211
+ private async copyInto(src: string, tgt: string, stats: FileStats): Promise<void> {
212
+ if (stats.kind === "file") {
213
+ await this.write(tgt, this.read(src));
214
+ return;
215
+ }
216
+ await this.mkdir(tgt);
217
+ for await (const entry of this.list(src)) {
218
+ await this.copyInto(entry.path, joinPath(tgt, entry.name), entry);
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Builds a copy-on-write `FilesApi`: a `writable` layer over a read-only
225
+ * `base`. Reads fall through to `base`; every write goes to `writable`;
226
+ * `base` is never mutated. Deletions are persisted as marker files in
227
+ * `writable` (a per-path whiteout for files, one opaque marker for a deleted
228
+ * base directory), so they survive over any backend and across restarts.
229
+ *
230
+ * @param base The read-only lower layer. Never mutated.
231
+ * @param writable The upper layer that captures all changes and markers.
232
+ * @param opts Marker naming overrides (see {@link CowOptions}).
233
+ * @returns A read/write `FilesApi` composing the two layers.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * const fs = cow(releaseFiles, new MemFilesApi());
238
+ * await fs.write("/a.txt", data); // captured in the writable layer
239
+ * await fs.remove("/base-only"); // whiteout marker; base untouched
240
+ * ```
241
+ */
242
+ export function cow(base: FilesApi, writable: FilesApi, opts?: CowOptions): FilesApi {
243
+ return new CowFilesApi(base, writable, opts);
244
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { CompositeFilesApi } from "./composite-files-api.js";
2
+ export type { CowOptions } from "./cow-files-api.js";
3
+ export { cow } from "./cow-files-api.js";
2
4
  export type { PathFilter } from "./filtered-files-api.js";
3
5
  export {
4
6
  FilteredFilesApi,
@@ -9,4 +11,6 @@ export {
9
11
  export type { GlobToRegExpOptions } from "./glob-to-regexp.js";
10
12
  export { globToRegExp } from "./glob-to-regexp.js";
11
13
  export { GuardedFilesApi } from "./guarded-files-api.js";
14
+ export { overlay } from "./overlay-files-api.js";
15
+ export { readOnly } from "./read-only-files-api.js";
12
16
  export type { FileGuard, FileOperation } from "./types.js";
@@ -0,0 +1,121 @@
1
+ import type {
2
+ FileInfo,
3
+ FileStats,
4
+ FilesApi,
5
+ ListOptions,
6
+ ReadOptions,
7
+ } from "@statewalker/webrun-files";
8
+ import { normalizePath } from "@statewalker/webrun-files";
9
+
10
+ /**
11
+ * Read-only union of several `FilesApi` layers. A path is resolved
12
+ * **top → bottom**: the first layer that has it wins for
13
+ * `read` / `stats` / `exists`, and its entry (including `kind`) wins any
14
+ * name clash in `list`. Listings merge and dedupe across every layer.
15
+ *
16
+ * The union never writes — every mutating call throws. Use
17
+ * {@link overlay} to construct one.
18
+ */
19
+ class OverlayFilesApi implements FilesApi {
20
+ private readonly layers: FilesApi[];
21
+
22
+ constructor(layers: FilesApi[]) {
23
+ this.layers = layers;
24
+ }
25
+
26
+ private deny(op: string, path: string): never {
27
+ throw new Error(`overlay is read-only (${op}): ${normalizePath(path)}`);
28
+ }
29
+
30
+ async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
31
+ for (const layer of this.layers) {
32
+ if (await layer.exists(path)) {
33
+ yield* layer.read(path, options);
34
+ return;
35
+ }
36
+ }
37
+ }
38
+
39
+ async stats(path: string): Promise<FileStats | undefined> {
40
+ for (const layer of this.layers) {
41
+ const stats = await layer.stats(path);
42
+ if (stats) return stats;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ async exists(path: string): Promise<boolean> {
48
+ for (const layer of this.layers) {
49
+ if (await layer.exists(path)) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ 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
+ }
67
+ }
68
+
69
+ /** Direct children of `dir`, deduped by name with the topmost layer winning. */
70
+ private async mergeChildren(dir: string): Promise<FileInfo[]> {
71
+ const merged = new Map<string, FileInfo>();
72
+ for (const layer of this.layers) {
73
+ if ((await layer.stats(dir))?.kind !== "directory") continue;
74
+ for await (const entry of layer.list(dir)) {
75
+ if (!merged.has(entry.name)) merged.set(entry.name, entry);
76
+ }
77
+ }
78
+ return [...merged.values()];
79
+ }
80
+
81
+ async write(path: string): Promise<void> {
82
+ this.deny("write", path);
83
+ }
84
+
85
+ async mkdir(path: string): Promise<void> {
86
+ this.deny("mkdir", path);
87
+ }
88
+
89
+ async remove(path: string): Promise<boolean> {
90
+ return this.deny("remove", path);
91
+ }
92
+
93
+ async move(source: string): Promise<boolean> {
94
+ return this.deny("move", source);
95
+ }
96
+
97
+ async copy(source: string): Promise<boolean> {
98
+ return this.deny("copy", source);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Builds a **read-only** union view over `top` and any number of `lower`
104
+ * layers. Reads resolve top → bottom (first layer that has the path wins);
105
+ * `list` merges and dedupes across all layers with `top` winning a clash
106
+ * (including a file-vs-directory `kind` clash). Every write is denied.
107
+ *
108
+ * @param top The highest-priority layer; its entries shadow the rest.
109
+ * @param lower Additional layers, consulted in order after `top`.
110
+ * @returns A read-only `FilesApi` union.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const view = overlay(userFiles, defaultFiles);
115
+ * await view.read("/config.json"); // userFiles if present, else defaultFiles
116
+ * await view.write("/x", data); // throws (read-only)
117
+ * ```
118
+ */
119
+ export function overlay(top: FilesApi, ...lower: FilesApi[]): FilesApi {
120
+ return new OverlayFilesApi([top, ...lower]);
121
+ }
@@ -0,0 +1,30 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+ import { GuardedFilesApi } from "./guarded-files-api.js";
3
+
4
+ /**
5
+ * Wraps a `FilesApi` in a read-only view: every mutating operation
6
+ * (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
7
+ * (`read`, `list`, `stats`, `exists`) pass straight through to `api`.
8
+ *
9
+ * Implemented as a {@link GuardedFilesApi} with a single deny-all guard on
10
+ * the mutating operations, so `move`/`copy` are blocked on either endpoint.
11
+ *
12
+ * @param api The underlying `FilesApi` to expose read-only.
13
+ * @returns A `FilesApi` that never mutates `api`.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const ro = readOnly(sourceFiles);
18
+ * await ro.read("/a.txt"); // ok
19
+ * await ro.write("/a.txt", data); // throws "read-only: /a.txt"
20
+ * ```
21
+ */
22
+ export function readOnly(api: FilesApi): FilesApi {
23
+ return new GuardedFilesApi(api, [
24
+ {
25
+ operations: ["write", "mkdir", "remove", "move", "copy"],
26
+ check: () => false,
27
+ message: "read-only",
28
+ },
29
+ ]);
30
+ }