@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 +21 -0
- package/dist/cjs/index.cjs +316 -0
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/cow-files-api.d.ts +36 -0
- package/dist/cow-files-api.d.ts.map +1 -0
- package/dist/esm/index.js +314 -1
- package/dist/filtered-files-api.d.ts.map +1 -1
- package/dist/guarded-files-api.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/overlay-files-api.d.ts +20 -0
- package/dist/overlay-files-api.d.ts.map +1 -0
- package/dist/read-only-files-api.d.ts +21 -0
- package/dist/read-only-files-api.d.ts.map +1 -0
- package/package.json +15 -15
- package/src/cow-files-api.ts +244 -0
- package/src/index.ts +4 -0
- package/src/overlay-files-api.ts +121 -0
- package/src/read-only-files-api.ts +30 -0
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.
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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;
|
|
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;
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
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": "
|
|
32
|
+
"@statewalker/webrun-files": "0.7.0"
|
|
38
33
|
},
|
|
39
34
|
"devDependencies": {
|
|
40
|
-
"@
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
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
|
+
}
|