@statewalker/webrun-files-composite 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,6 +206,39 @@ const draft = cow(templateFiles, new MemFilesApi());
206
206
  await draft.remove("/obsolete.md"); // recorded as /.wh.obsolete.md in the writable layer
207
207
  ```
208
208
 
209
+ ### Exclusive create through a composite
210
+
211
+ ```ts
212
+ const fs = new CompositeFilesApi(nodeFiles).mount("/cache", memFiles);
213
+
214
+ if (fs.createExclusive) {
215
+ await fs.createExclusive("/repo/.git/index.lock", [lockBytes]); // nodeFiles: O_EXCL
216
+ await fs.createExclusive("/cache/job.lock", [lockBytes]); // memFiles: in-instance
217
+ }
218
+
219
+ fs.mount("/remote", httpFiles); // a backend without createExclusive...
220
+ fs.createExclusive; // ...hides it: undefined
221
+ "createExclusive" in fs; // false
222
+ ```
223
+
224
+ `CompositeFilesApi` has `createExclusive` only while every backend, the root and each mount,
225
+ has its own. A call goes to the backend that owns the path, so the exclusion reaches exactly as
226
+ far as that backend's does. Defining the method and then refusing it for some paths would make
227
+ feature detection lie, so a composite with one incapable mount has no method at all, and callers
228
+ take their fallback. A mount point, and a directory that exists only because a mount lies under
229
+ it, count as existing.
230
+
231
+ Whether the method is there is decided at each access, `in` and `typeof` included: the
232
+ composite is a proxy that asks its mounts, and `createExclusive` itself is a prototype method,
233
+ so a subclass overrides it as usual. A reference to the method taken before a `mount()` of an
234
+ incapable backend still calls, and throws for paths on that backend.
235
+
236
+ `GuardedFilesApi` and `FilteredFilesApi` have the method while what they wrap has it, checked
237
+ the same way at each access, so a guard or filter over a composite loses it when the composite
238
+ does. A guard checks it as a `write`, and a filter refuses a hidden path with
239
+ `"Path is hidden"`, as for `write`. So `readOnly()` throws. `overlay` (read-only) and `cow` do not offer it: a create in
240
+ the writable layer of `cow` could not also exclude what the base and the whiteouts say.
241
+
209
242
  ## Internals
210
243
 
211
244
  ### How the decorators stack
@@ -253,6 +286,7 @@ filter to that mount only).
253
286
  | ------------ | --------------------------------------------------- |
254
287
  | `read` | `read` |
255
288
  | `write` | `write` |
289
+ | `createExclusive` | `write` |
256
290
  | `mkdir` | `mkdir` |
257
291
  | `remove` | `remove` |
258
292
  | `list` | `list` on the path AND on each directory entry |
@@ -274,13 +308,23 @@ multiple operation lists.
274
308
  | `exists` | `false` |
275
309
  | `remove` | `false` |
276
310
  | `move` / `copy` | `false` (no side effect) |
277
- | `write` / `mkdir` | throws `"Path is hidden"` (silent drop would lose data) |
311
+ | `write` / `mkdir` / `createExclusive` | throws `"Path is hidden"` (silent drop would lose data) |
278
312
 
279
313
  ### What it refuses, and what is not atomic
280
314
 
281
315
  - The root mount (`/`) is fixed at construction; you cannot remount it.
282
316
  - Mount points are immutable — `remove()` on a mount point throws.
283
- - Cross-mount `move` is copy + delete; not atomic.
317
+ - Cross-mount `move` is copy + delete; not atomic: while it runs the content exists in both
318
+ places. If the copy succeeds but the source cannot be removed, the removal is retried once,
319
+ then `move` throws a `MoveIncompleteError` (the target holds the new content, the source still
320
+ exists) instead of returning `false`. A move within one mount is delegated, and is as atomic
321
+ as that backend's.
322
+ - `cow` `move` is copy + delete too, and not atomic: the composite view of the source is copied
323
+ into the writable layer, then the source is removed (or hidden behind a whiteout). A source
324
+ that cannot be removed is retried once, then throws `MoveIncompleteError`. An existing target
325
+ is treated as `rename(2)` treats it, like `MemFilesApi`: a file replaces a file and a directory
326
+ an empty directory; a file onto a directory, a directory onto a file or a non-empty directory,
327
+ a directory into its own subtree, or a target below a file returns `false` and changes nothing.
284
328
  - `newPathFilter()` (no args) hides nothing; entries that normalize to
285
329
  `"/"` (empty string, `"/"`) are dropped because they would otherwise hide
286
330
  the whole tree.
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region ../webrun-files/dist/esm/index.js
2
+ // ../webrun-files/dist/esm/index.js
3
3
  /**
4
4
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
5
5
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -122,6 +122,46 @@ var PathHeap = class {
122
122
  }
123
123
  };
124
124
  /**
125
+ * Thrown by a `move` done as copy-then-delete when the copy succeeded but the source
126
+ * could not be removed, even on a second attempt. The move is half done: the target
127
+ * already holds the moved content (an existing target was replaced), and the source
128
+ * still exists. A `false` would wrongly say that nothing happened, so this is thrown
129
+ * instead.
130
+ *
131
+ * @example
132
+ * try {
133
+ * await files.move("/repo/index.lock", "/repo/index");
134
+ * } catch (error) {
135
+ * if (error instanceof MoveIncompleteError) await files.remove(error.source);
136
+ * else throw error;
137
+ * }
138
+ */
139
+ var MoveIncompleteError = class extends Error {
140
+ source;
141
+ target;
142
+ name = "MoveIncompleteError";
143
+ constructor(source, target, options) {
144
+ super(`move ${source} -> ${target}: the target was replaced, but the source could not be removed`, options);
145
+ this.source = source;
146
+ this.target = target;
147
+ }
148
+ };
149
+ /**
150
+ * The second half of a copy-then-delete `move`: remove `source` after it has been copied
151
+ * to `target`. A failed removal (an error, or `false` while the source still exists) is
152
+ * retried once; should that fail too, {@link MoveIncompleteError} is thrown, carrying the
153
+ * last error as its cause. A source that is already gone counts as removed.
154
+ */
155
+ async function removeMovedSource(files, source, target) {
156
+ let cause;
157
+ for (let attempt = 0; attempt < 2; attempt++) try {
158
+ if (await files.remove(source) || !await files.exists(source)) return;
159
+ } catch (error) {
160
+ cause = error;
161
+ }
162
+ throw new MoveIncompleteError(source, target, { cause });
163
+ }
164
+ /**
125
165
  * Path manipulation utilities for virtual filesystem paths.
126
166
  * All paths use forward slashes and start with "/".
127
167
  */
@@ -162,8 +202,30 @@ function basename(path, ext) {
162
202
  if (ext && name.endsWith(ext)) return name.substring(0, name.length - ext.length);
163
203
  return name;
164
204
  }
165
- //#endregion
166
- //#region src/composite-files-api.ts
205
+ // src/follow-create-exclusive.ts
206
+ /**
207
+ * Wrap `target` so that its `createExclusive` exists only while `isAvailable()` says so,
208
+ * checked on every access: both `"createExclusive" in api` and
209
+ * `typeof api.createExclusive` then follow the backend(s) the object delegates to, even
210
+ * as those change (a `mount()` of a backend without the method, say), and a subclass
211
+ * that overrides the method is hidden or shown the same way.
212
+ *
213
+ * Constructors return the result, so the object every caller holds is the proxy; `this`
214
+ * inside methods is the proxy too, and every other property passes straight through.
215
+ */
216
+ function followCreateExclusive(target, isAvailable) {
217
+ return new Proxy(target, {
218
+ has(object, key) {
219
+ if (key === "createExclusive" && !isAvailable()) return false;
220
+ return Reflect.has(object, key);
221
+ },
222
+ get(object, key, receiver) {
223
+ if (key === "createExclusive" && !isAvailable()) return void 0;
224
+ return Reflect.get(object, key, receiver);
225
+ }
226
+ });
227
+ }
228
+ // src/composite-files-api.ts
167
229
  /**
168
230
  * Composite `FilesApi` that routes calls to one of several backends based
169
231
  * on a path prefix. Mounts are matched by **longest prefix wins**, so a
@@ -174,7 +236,7 @@ function basename(path, ext) {
174
236
  * Each backend can use a sub-directory of its own filesystem as the mount
175
237
  * root via `fsPath` (constructor `rootPath` for the implicit root mount,
176
238
  * `fsPath` argument for additional mounts). Cross-mount `move` is
177
- * implemented as copy-then-delete; there is no atomicity guarantee.
239
+ * implemented as copy-then-delete; there is no atomicity guarantee (see `move`).
178
240
  *
179
241
  * Access control and visibility filtering are intentionally **not** part of
180
242
  * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
@@ -206,6 +268,27 @@ var CompositeFilesApi = class {
206
268
  api: root,
207
269
  basePath: normalizePath(rootPath ?? "/")
208
270
  }];
271
+ return followCreateExclusive(this, () => this.mounts.every((m) => typeof m.api.createExclusive === "function"));
272
+ }
273
+ /**
274
+ * Present only while EVERY backend — the root and each mount — has its own
275
+ * `createExclusive`, so feature detection on the composite tells the truth for
276
+ * any path: `"createExclusive" in composite` and `typeof composite.createExclusive`
277
+ * are checked against the mounts at each access. Mounting a backend without it hides
278
+ * the method; it is never shown and then refused for some paths. (A reference taken
279
+ * before such a mount still calls; it throws for paths on a backend without it.)
280
+ * It is a prototype method, so a subclass overrides it as usual.
281
+ *
282
+ * A call goes to the backend owning the path, so the exclusion reaches as far
283
+ * as that backend's does. A mount point, and a directory that exists only
284
+ * because a mount lies under it, count as existing.
285
+ */
286
+ async createExclusive(path, content) {
287
+ const normalized = normalizePath(path);
288
+ if (this.mounts.some((m) => m.prefix !== "/" && isInsideOrSame(m.prefix, normalized))) return false;
289
+ const { api, resolvedPath } = this.resolve(normalized);
290
+ if (!api.createExclusive) throw new Error(`createExclusive is not supported by the backend for ${normalized}`);
291
+ return api.createExclusive(resolvedPath, content);
209
292
  }
210
293
  /**
211
294
  * Attaches a backend to handle every composite path under `path`. The
@@ -355,13 +438,27 @@ var CompositeFilesApi = class {
355
438
  const { api, resolvedPath } = this.resolve(path);
356
439
  return api.remove(resolvedPath);
357
440
  }
441
+ /**
442
+ * Within one mount the move is delegated, so it is exactly as atomic as that
443
+ * backend's `move` (and treats an existing target as that backend does).
444
+ *
445
+ * Across mounts it can only be a copy, then a delete, and is NOT atomic: while it
446
+ * runs, the content exists in both places, and a directory appears in the target
447
+ * file by file. A failed copy throws and keeps the source. If the copy succeeds but
448
+ * the source cannot be removed, the removal is retried once, then a
449
+ * `MoveIncompleteError` is thrown: the target holds the new content and the source
450
+ * still exists. False is never returned for a half-done move.
451
+ */
358
452
  async move(source, target) {
359
453
  const src = this.resolve(source);
360
454
  const tgt = this.resolve(target);
361
455
  if (src.api === tgt.api) return src.api.move(src.resolvedPath, tgt.resolvedPath);
362
- const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
363
- if (copied) await src.api.remove(src.resolvedPath);
364
- return copied;
456
+ if (!await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath)) return false;
457
+ await removeMovedSource({
458
+ remove: () => src.api.remove(src.resolvedPath),
459
+ exists: () => src.api.exists(src.resolvedPath)
460
+ }, normalizePath(source), normalizePath(target));
461
+ return true;
365
462
  }
366
463
  async copy(source, target) {
367
464
  const src = this.resolve(source);
@@ -411,14 +508,16 @@ function translateAfter(after, from, to) {
411
508
  if (comparePaths(after, `${fromPrefix}/`) < 0) return void 0;
412
509
  return SKIP;
413
510
  }
511
+ function isInsideOrSame(path, ancestor) {
512
+ return path === ancestor || isStrictlyInside(path, ancestor);
513
+ }
414
514
  function isStrictlyInside(path, ancestor) {
415
515
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
416
516
  }
417
517
  async function* fromArray(items) {
418
518
  yield* items;
419
519
  }
420
- //#endregion
421
- //#region src/cow-files-api.ts
520
+ // src/cow-files-api.ts
422
521
  /**
423
522
  * Copy-on-write view: a writable layer over a read-only `base`. The `base`
424
523
  * is **never** mutated — every change (write, delete, move) is recorded in
@@ -539,11 +638,41 @@ var CowFilesApi = class {
539
638
  }
540
639
  return true;
541
640
  }
641
+ /**
642
+ * NOT atomic: the composite view of the source is copied into the writable layer,
643
+ * then the source is removed (deleted from the writable layer and/or hidden behind
644
+ * a whiteout). While it runs the content exists in both places, and a directory
645
+ * appears in the target file by file. A failed copy throws and keeps the source. If
646
+ * the copy succeeds but the source cannot be removed, the removal is retried once,
647
+ * then a `MoveIncompleteError` is thrown: the target holds the new content and the
648
+ * source still exists. False is never returned for a half-done move.
649
+ *
650
+ * An existing target (as the composite view shows it) is treated as POSIX
651
+ * `rename(2)` treats it, like `MemFilesApi` and `NodeFilesApi`: a file replaces a
652
+ * file, and a directory replaces an empty directory. Everything else returns false
653
+ * and changes nothing: a file onto a directory, a directory onto a file or onto a
654
+ * non-empty directory, a directory into its own subtree, a target below a file, and
655
+ * a missing source. A move onto itself is a successful no-op.
656
+ */
542
657
  async move(source, target) {
543
- const stats = await this.stats(source);
658
+ const from = normalizePath(source);
659
+ const to = normalizePath(target);
660
+ const stats = await this.stats(from);
544
661
  if (!stats) return false;
545
- await this.copyInto(source, target, stats);
546
- await this.remove(source);
662
+ if (from === to) return true;
663
+ if (from === "/" || to.startsWith(`${from}/`)) return false;
664
+ for (let dir = dirname(to); dir !== "/"; dir = dirname(dir)) {
665
+ const kind = (await this.stats(dir))?.kind;
666
+ if (kind === "file") return false;
667
+ if (kind === "directory") break;
668
+ }
669
+ const targetStats = await this.stats(to);
670
+ if (targetStats) {
671
+ if (targetStats.kind !== stats.kind) return false;
672
+ if (targetStats.kind === "directory") for await (const _ of this.list(to)) return false;
673
+ }
674
+ await this.copyInto(from, to, stats);
675
+ await removeMovedSource(this, from, to);
547
676
  return true;
548
677
  }
549
678
  async copy(source, target) {
@@ -584,8 +713,7 @@ var CowFilesApi = class {
584
713
  function cow(base, writable, opts) {
585
714
  return new CowFilesApi(base, writable, opts);
586
715
  }
587
- //#endregion
588
- //#region src/glob-to-regexp.ts
716
+ // src/glob-to-regexp.ts
589
717
  /**
590
718
  * Compiles a glob pattern into a `RegExp`.
591
719
  *
@@ -664,8 +792,7 @@ function globToRegExp(glob, opts = {}) {
664
792
  if (!flags?.includes("g")) reStr = `^${reStr}$`;
665
793
  return new RegExp(reStr, flags);
666
794
  }
667
- //#endregion
668
- //#region src/filtered-files-api.ts
795
+ // src/filtered-files-api.ts
669
796
  /**
670
797
  * Builds a {@link PathFilter} that hides any path whose normalized form
671
798
  * equals one of the provided prefixes or lives under `${prefix}/`.
@@ -781,7 +908,8 @@ function newGlobPathFilter(...pathGlobs) {
781
908
  * - `remove` returns `false` (no error, nothing changed).
782
909
  * - `move` / `copy` return `false` if either endpoint is hidden.
783
910
  * - `write` / `mkdir` reject with an `Error` (since silently dropping a
784
- * write would lose data).
911
+ * write would lose data), and so does `createExclusive`, which the
912
+ * decorator has only when the source has it.
785
913
  * - `list` recursively skips entries whose paths are hidden, so iterating a
786
914
  * visible parent never reveals a hidden child.
787
915
  *
@@ -831,6 +959,17 @@ var FilteredFilesApi = class {
831
959
  constructor(source, pathFilter) {
832
960
  this.source = source;
833
961
  this.pathFilter = pathFilter;
962
+ return followCreateExclusive(this, () => typeof source.createExclusive === "function");
963
+ }
964
+ /**
965
+ * Present only while the source has it, checked at each access (`in` and `typeof`
966
+ * included), so it follows a source whose methods change, such as a
967
+ * `CompositeFilesApi` after `mount()`. The exclusion is the source's own.
968
+ */
969
+ async createExclusive(path, content) {
970
+ if (await this.isHidden(path)) throw new Error(`Path is hidden: ${path}`);
971
+ if (!this.source.createExclusive) throw new Error(`createExclusive is not supported by the source for ${path}`);
972
+ return this.source.createExclusive(path, content);
834
973
  }
835
974
  async isHidden(path) {
836
975
  return await this.pathFilter(normalizePath(path)) === false;
@@ -875,8 +1014,7 @@ var FilteredFilesApi = class {
875
1014
  return this.source.copy(source, target);
876
1015
  }
877
1016
  };
878
- //#endregion
879
- //#region src/guarded-files-api.ts
1017
+ // src/guarded-files-api.ts
880
1018
  /**
881
1019
  * `FilesApi` decorator that runs every call through an ordered list of
882
1020
  * {@link FileGuard}s. A guard fires when its `operations` set intersects the
@@ -891,6 +1029,7 @@ var FilteredFilesApi = class {
891
1029
  * | ------------- | --------------------------------------------------- |
892
1030
  * | `read` | `read` |
893
1031
  * | `write` | `write` |
1032
+ * | `createExclusive` | `write` |
894
1033
  * | `mkdir` | `mkdir` |
895
1034
  * | `remove` | `remove` |
896
1035
  * | `list` | `list` on the path AND on each directory entry |
@@ -929,6 +1068,18 @@ var GuardedFilesApi = class {
929
1068
  constructor(source, guards) {
930
1069
  this.source = source;
931
1070
  this.guards = [...guards];
1071
+ return followCreateExclusive(this, () => typeof source.createExclusive === "function");
1072
+ }
1073
+ /**
1074
+ * Present only while the source has it, checked at each access (`in` and `typeof`
1075
+ * included), so it follows a source whose methods change, such as a
1076
+ * `CompositeFilesApi` after `mount()`. A `write`-guarded pass-through, so the
1077
+ * exclusion is the source's own.
1078
+ */
1079
+ async createExclusive(path, content) {
1080
+ this.checkGuard(path, "write");
1081
+ if (!this.source.createExclusive) throw new Error(`createExclusive is not supported by the source for ${path}`);
1082
+ return this.source.createExclusive(path, content);
932
1083
  }
933
1084
  checkGuard(path, ...operations) {
934
1085
  const normalized = normalizePath(path);
@@ -982,8 +1133,7 @@ var GuardedFilesApi = class {
982
1133
  return this.source.copy(source, target);
983
1134
  }
984
1135
  };
985
- //#endregion
986
- //#region src/overlay-files-api.ts
1136
+ // src/overlay-files-api.ts
987
1137
  /**
988
1138
  * Read-only union of several `FilesApi` layers. A path is resolved
989
1139
  * **top → bottom**: the first layer that has it wins for
@@ -1068,8 +1218,7 @@ var OverlayFilesApi = class {
1068
1218
  function overlay(top, ...lower) {
1069
1219
  return new OverlayFilesApi([top, ...lower]);
1070
1220
  }
1071
- //#endregion
1072
- //#region src/read-only-files-api.ts
1221
+ // src/read-only-files-api.ts
1073
1222
  /**
1074
1223
  * Wraps a `FilesApi` in a read-only view: every mutating operation
1075
1224
  * (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
@@ -1101,7 +1250,6 @@ function readOnly(api) {
1101
1250
  message: "read-only"
1102
1251
  }]);
1103
1252
  }
1104
- //#endregion
1105
1253
  exports.CompositeFilesApi = CompositeFilesApi;
1106
1254
  exports.FilteredFilesApi = FilteredFilesApi;
1107
1255
  exports.GuardedFilesApi = GuardedFilesApi;
@@ -9,7 +9,7 @@ import type { FileInfo, FileStats, FilesApi, ListOptions, ReadOptions } from "@s
9
9
  * Each backend can use a sub-directory of its own filesystem as the mount
10
10
  * root via `fsPath` (constructor `rootPath` for the implicit root mount,
11
11
  * `fsPath` argument for additional mounts). Cross-mount `move` is
12
- * implemented as copy-then-delete; there is no atomicity guarantee.
12
+ * implemented as copy-then-delete; there is no atomicity guarantee (see `move`).
13
13
  *
14
14
  * Access control and visibility filtering are intentionally **not** part of
15
15
  * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
@@ -36,6 +36,20 @@ export declare class CompositeFilesApi implements FilesApi {
36
36
  * in the root backend. Defaults to `"/"` (no remapping).
37
37
  */
38
38
  constructor(root: FilesApi, rootPath?: string);
39
+ /**
40
+ * Present only while EVERY backend — the root and each mount — has its own
41
+ * `createExclusive`, so feature detection on the composite tells the truth for
42
+ * any path: `"createExclusive" in composite` and `typeof composite.createExclusive`
43
+ * are checked against the mounts at each access. Mounting a backend without it hides
44
+ * the method; it is never shown and then refused for some paths. (A reference taken
45
+ * before such a mount still calls; it throws for paths on a backend without it.)
46
+ * It is a prototype method, so a subclass overrides it as usual.
47
+ *
48
+ * A call goes to the backend owning the path, so the exclusion reaches as far
49
+ * as that backend's does. A mount point, and a directory that exists only
50
+ * because a mount lies under it, count as existing.
51
+ */
52
+ createExclusive?(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
39
53
  /**
40
54
  * Attaches a backend to handle every composite path under `path`. The
41
55
  * mount prefix is normalized; paths under it are resolved against the
@@ -80,6 +94,17 @@ export declare class CompositeFilesApi implements FilesApi {
80
94
  stats(path: string): Promise<FileStats | undefined>;
81
95
  exists(path: string): Promise<boolean>;
82
96
  remove(path: string): Promise<boolean>;
97
+ /**
98
+ * Within one mount the move is delegated, so it is exactly as atomic as that
99
+ * backend's `move` (and treats an existing target as that backend does).
100
+ *
101
+ * Across mounts it can only be a copy, then a delete, and is NOT atomic: while it
102
+ * runs, the content exists in both places, and a directory appears in the target
103
+ * file by file. A failed copy throws and keeps the source. If the copy succeeds but
104
+ * the source cannot be removed, the removal is retried once, then a
105
+ * `MoveIncompleteError` is thrown: the target holds the new content and the source
106
+ * still exists. False is never returned for a half-done move.
107
+ */
83
108
  move(source: string, target: string): Promise<boolean>;
84
109
  copy(source: string, target: string): Promise<boolean>;
85
110
  private crossCopy;
@@ -1 +1 @@
1
- {"version":3,"file":"composite-files-api.d.ts","sourceRoot":"","sources":["../src/composite-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AASnC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,iBAAkB,YAAW,QAAQ;IAChD,OAAO,CAAC,MAAM,CAAe;IAE7B;;;;;;;;OAQG;IACH,YAAY,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,MAAM,EAE5C;IAED;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CASxD;IAID,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,YAAY;IAKpB,yEAAyE;IACzE,OAAO,CAAC,kBAAkB;IAkB1B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAGnE;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAGf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED;;;;;;;;OAQG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAoDxE;IAED;;;OAGG;YACY,YAAY;IAiB3B,qFAAqF;IACrF,OAAO,CAAC,OAAO;IAaT,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAOxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3C;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAe3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAW3D;YAIa,SAAS;IA4BvB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;CAQ1B"}
1
+ {"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;AAgBnC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBAAa,iBAAkB,YAAW,QAAQ;IAChD,OAAO,CAAC,MAAM,CAAe;IAE7B;;;;;;;;OAQG;IACH,YAAY,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,MAAM,EAM5C;IAED;;;;;;;;;;;;OAYG;IACG,eAAe,CAAC,CACpB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAUlB;IAED;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CASxD;IAID,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,YAAY;IAKpB,yEAAyE;IACzE,OAAO,CAAC,kBAAkB;IAkB1B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAGnE;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAGf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED;;;;;;;;OAQG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAoDxE;IAED;;;OAGG;YACY,YAAY;IAiB3B,qFAAqF;IACrF,OAAO,CAAC,OAAO;IAaT,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAOxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3C;IAED;;;;;;;;;;OAUG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAW3D;YAIa,SAAS;IA4BvB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;CAQ1B"}
@@ -1 +1 @@
1
- {"version":3,"file":"cow-files-api.d.ts","sourceRoot":"","sources":["../src/cow-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,QAAQ,EAGT,MAAM,2BAA2B,CAAC;AASnC,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAgMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,QAAQ,CAEnF"}
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;AAUnC,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAgOD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,QAAQ,CAEnF"}
package/dist/esm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../webrun-files/dist/esm/index.js
1
+ // ../webrun-files/dist/esm/index.js
2
2
  /**
3
3
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
4
4
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -121,6 +121,46 @@ var PathHeap = class {
121
121
  }
122
122
  };
123
123
  /**
124
+ * Thrown by a `move` done as copy-then-delete when the copy succeeded but the source
125
+ * could not be removed, even on a second attempt. The move is half done: the target
126
+ * already holds the moved content (an existing target was replaced), and the source
127
+ * still exists. A `false` would wrongly say that nothing happened, so this is thrown
128
+ * instead.
129
+ *
130
+ * @example
131
+ * try {
132
+ * await files.move("/repo/index.lock", "/repo/index");
133
+ * } catch (error) {
134
+ * if (error instanceof MoveIncompleteError) await files.remove(error.source);
135
+ * else throw error;
136
+ * }
137
+ */
138
+ var MoveIncompleteError = class extends Error {
139
+ source;
140
+ target;
141
+ name = "MoveIncompleteError";
142
+ constructor(source, target, options) {
143
+ super(`move ${source} -> ${target}: the target was replaced, but the source could not be removed`, options);
144
+ this.source = source;
145
+ this.target = target;
146
+ }
147
+ };
148
+ /**
149
+ * The second half of a copy-then-delete `move`: remove `source` after it has been copied
150
+ * to `target`. A failed removal (an error, or `false` while the source still exists) is
151
+ * retried once; should that fail too, {@link MoveIncompleteError} is thrown, carrying the
152
+ * last error as its cause. A source that is already gone counts as removed.
153
+ */
154
+ async function removeMovedSource(files, source, target) {
155
+ let cause;
156
+ for (let attempt = 0; attempt < 2; attempt++) try {
157
+ if (await files.remove(source) || !await files.exists(source)) return;
158
+ } catch (error) {
159
+ cause = error;
160
+ }
161
+ throw new MoveIncompleteError(source, target, { cause });
162
+ }
163
+ /**
124
164
  * Path manipulation utilities for virtual filesystem paths.
125
165
  * All paths use forward slashes and start with "/".
126
166
  */
@@ -161,8 +201,30 @@ function basename(path, ext) {
161
201
  if (ext && name.endsWith(ext)) return name.substring(0, name.length - ext.length);
162
202
  return name;
163
203
  }
164
- //#endregion
165
- //#region src/composite-files-api.ts
204
+ // src/follow-create-exclusive.ts
205
+ /**
206
+ * Wrap `target` so that its `createExclusive` exists only while `isAvailable()` says so,
207
+ * checked on every access: both `"createExclusive" in api` and
208
+ * `typeof api.createExclusive` then follow the backend(s) the object delegates to, even
209
+ * as those change (a `mount()` of a backend without the method, say), and a subclass
210
+ * that overrides the method is hidden or shown the same way.
211
+ *
212
+ * Constructors return the result, so the object every caller holds is the proxy; `this`
213
+ * inside methods is the proxy too, and every other property passes straight through.
214
+ */
215
+ function followCreateExclusive(target, isAvailable) {
216
+ return new Proxy(target, {
217
+ has(object, key) {
218
+ if (key === "createExclusive" && !isAvailable()) return false;
219
+ return Reflect.has(object, key);
220
+ },
221
+ get(object, key, receiver) {
222
+ if (key === "createExclusive" && !isAvailable()) return void 0;
223
+ return Reflect.get(object, key, receiver);
224
+ }
225
+ });
226
+ }
227
+ // src/composite-files-api.ts
166
228
  /**
167
229
  * Composite `FilesApi` that routes calls to one of several backends based
168
230
  * on a path prefix. Mounts are matched by **longest prefix wins**, so a
@@ -173,7 +235,7 @@ function basename(path, ext) {
173
235
  * Each backend can use a sub-directory of its own filesystem as the mount
174
236
  * root via `fsPath` (constructor `rootPath` for the implicit root mount,
175
237
  * `fsPath` argument for additional mounts). Cross-mount `move` is
176
- * implemented as copy-then-delete; there is no atomicity guarantee.
238
+ * implemented as copy-then-delete; there is no atomicity guarantee (see `move`).
177
239
  *
178
240
  * Access control and visibility filtering are intentionally **not** part of
179
241
  * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
@@ -205,6 +267,27 @@ var CompositeFilesApi = class {
205
267
  api: root,
206
268
  basePath: normalizePath(rootPath ?? "/")
207
269
  }];
270
+ return followCreateExclusive(this, () => this.mounts.every((m) => typeof m.api.createExclusive === "function"));
271
+ }
272
+ /**
273
+ * Present only while EVERY backend — the root and each mount — has its own
274
+ * `createExclusive`, so feature detection on the composite tells the truth for
275
+ * any path: `"createExclusive" in composite` and `typeof composite.createExclusive`
276
+ * are checked against the mounts at each access. Mounting a backend without it hides
277
+ * the method; it is never shown and then refused for some paths. (A reference taken
278
+ * before such a mount still calls; it throws for paths on a backend without it.)
279
+ * It is a prototype method, so a subclass overrides it as usual.
280
+ *
281
+ * A call goes to the backend owning the path, so the exclusion reaches as far
282
+ * as that backend's does. A mount point, and a directory that exists only
283
+ * because a mount lies under it, count as existing.
284
+ */
285
+ async createExclusive(path, content) {
286
+ const normalized = normalizePath(path);
287
+ if (this.mounts.some((m) => m.prefix !== "/" && isInsideOrSame(m.prefix, normalized))) return false;
288
+ const { api, resolvedPath } = this.resolve(normalized);
289
+ if (!api.createExclusive) throw new Error(`createExclusive is not supported by the backend for ${normalized}`);
290
+ return api.createExclusive(resolvedPath, content);
208
291
  }
209
292
  /**
210
293
  * Attaches a backend to handle every composite path under `path`. The
@@ -354,13 +437,27 @@ var CompositeFilesApi = class {
354
437
  const { api, resolvedPath } = this.resolve(path);
355
438
  return api.remove(resolvedPath);
356
439
  }
440
+ /**
441
+ * Within one mount the move is delegated, so it is exactly as atomic as that
442
+ * backend's `move` (and treats an existing target as that backend does).
443
+ *
444
+ * Across mounts it can only be a copy, then a delete, and is NOT atomic: while it
445
+ * runs, the content exists in both places, and a directory appears in the target
446
+ * file by file. A failed copy throws and keeps the source. If the copy succeeds but
447
+ * the source cannot be removed, the removal is retried once, then a
448
+ * `MoveIncompleteError` is thrown: the target holds the new content and the source
449
+ * still exists. False is never returned for a half-done move.
450
+ */
357
451
  async move(source, target) {
358
452
  const src = this.resolve(source);
359
453
  const tgt = this.resolve(target);
360
454
  if (src.api === tgt.api) return src.api.move(src.resolvedPath, tgt.resolvedPath);
361
- const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
362
- if (copied) await src.api.remove(src.resolvedPath);
363
- return copied;
455
+ if (!await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath)) return false;
456
+ await removeMovedSource({
457
+ remove: () => src.api.remove(src.resolvedPath),
458
+ exists: () => src.api.exists(src.resolvedPath)
459
+ }, normalizePath(source), normalizePath(target));
460
+ return true;
364
461
  }
365
462
  async copy(source, target) {
366
463
  const src = this.resolve(source);
@@ -410,14 +507,16 @@ function translateAfter(after, from, to) {
410
507
  if (comparePaths(after, `${fromPrefix}/`) < 0) return void 0;
411
508
  return SKIP;
412
509
  }
510
+ function isInsideOrSame(path, ancestor) {
511
+ return path === ancestor || isStrictlyInside(path, ancestor);
512
+ }
413
513
  function isStrictlyInside(path, ancestor) {
414
514
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
415
515
  }
416
516
  async function* fromArray(items) {
417
517
  yield* items;
418
518
  }
419
- //#endregion
420
- //#region src/cow-files-api.ts
519
+ // src/cow-files-api.ts
421
520
  /**
422
521
  * Copy-on-write view: a writable layer over a read-only `base`. The `base`
423
522
  * is **never** mutated — every change (write, delete, move) is recorded in
@@ -538,11 +637,41 @@ var CowFilesApi = class {
538
637
  }
539
638
  return true;
540
639
  }
640
+ /**
641
+ * NOT atomic: the composite view of the source is copied into the writable layer,
642
+ * then the source is removed (deleted from the writable layer and/or hidden behind
643
+ * a whiteout). While it runs the content exists in both places, and a directory
644
+ * appears in the target file by file. A failed copy throws and keeps the source. If
645
+ * the copy succeeds but the source cannot be removed, the removal is retried once,
646
+ * then a `MoveIncompleteError` is thrown: the target holds the new content and the
647
+ * source still exists. False is never returned for a half-done move.
648
+ *
649
+ * An existing target (as the composite view shows it) is treated as POSIX
650
+ * `rename(2)` treats it, like `MemFilesApi` and `NodeFilesApi`: a file replaces a
651
+ * file, and a directory replaces an empty directory. Everything else returns false
652
+ * and changes nothing: a file onto a directory, a directory onto a file or onto a
653
+ * non-empty directory, a directory into its own subtree, a target below a file, and
654
+ * a missing source. A move onto itself is a successful no-op.
655
+ */
541
656
  async move(source, target) {
542
- const stats = await this.stats(source);
657
+ const from = normalizePath(source);
658
+ const to = normalizePath(target);
659
+ const stats = await this.stats(from);
543
660
  if (!stats) return false;
544
- await this.copyInto(source, target, stats);
545
- await this.remove(source);
661
+ if (from === to) return true;
662
+ if (from === "/" || to.startsWith(`${from}/`)) return false;
663
+ for (let dir = dirname(to); dir !== "/"; dir = dirname(dir)) {
664
+ const kind = (await this.stats(dir))?.kind;
665
+ if (kind === "file") return false;
666
+ if (kind === "directory") break;
667
+ }
668
+ const targetStats = await this.stats(to);
669
+ if (targetStats) {
670
+ if (targetStats.kind !== stats.kind) return false;
671
+ if (targetStats.kind === "directory") for await (const _ of this.list(to)) return false;
672
+ }
673
+ await this.copyInto(from, to, stats);
674
+ await removeMovedSource(this, from, to);
546
675
  return true;
547
676
  }
548
677
  async copy(source, target) {
@@ -583,8 +712,7 @@ var CowFilesApi = class {
583
712
  function cow(base, writable, opts) {
584
713
  return new CowFilesApi(base, writable, opts);
585
714
  }
586
- //#endregion
587
- //#region src/glob-to-regexp.ts
715
+ // src/glob-to-regexp.ts
588
716
  /**
589
717
  * Compiles a glob pattern into a `RegExp`.
590
718
  *
@@ -663,8 +791,7 @@ function globToRegExp(glob, opts = {}) {
663
791
  if (!flags?.includes("g")) reStr = `^${reStr}$`;
664
792
  return new RegExp(reStr, flags);
665
793
  }
666
- //#endregion
667
- //#region src/filtered-files-api.ts
794
+ // src/filtered-files-api.ts
668
795
  /**
669
796
  * Builds a {@link PathFilter} that hides any path whose normalized form
670
797
  * equals one of the provided prefixes or lives under `${prefix}/`.
@@ -780,7 +907,8 @@ function newGlobPathFilter(...pathGlobs) {
780
907
  * - `remove` returns `false` (no error, nothing changed).
781
908
  * - `move` / `copy` return `false` if either endpoint is hidden.
782
909
  * - `write` / `mkdir` reject with an `Error` (since silently dropping a
783
- * write would lose data).
910
+ * write would lose data), and so does `createExclusive`, which the
911
+ * decorator has only when the source has it.
784
912
  * - `list` recursively skips entries whose paths are hidden, so iterating a
785
913
  * visible parent never reveals a hidden child.
786
914
  *
@@ -830,6 +958,17 @@ var FilteredFilesApi = class {
830
958
  constructor(source, pathFilter) {
831
959
  this.source = source;
832
960
  this.pathFilter = pathFilter;
961
+ return followCreateExclusive(this, () => typeof source.createExclusive === "function");
962
+ }
963
+ /**
964
+ * Present only while the source has it, checked at each access (`in` and `typeof`
965
+ * included), so it follows a source whose methods change, such as a
966
+ * `CompositeFilesApi` after `mount()`. The exclusion is the source's own.
967
+ */
968
+ async createExclusive(path, content) {
969
+ if (await this.isHidden(path)) throw new Error(`Path is hidden: ${path}`);
970
+ if (!this.source.createExclusive) throw new Error(`createExclusive is not supported by the source for ${path}`);
971
+ return this.source.createExclusive(path, content);
833
972
  }
834
973
  async isHidden(path) {
835
974
  return await this.pathFilter(normalizePath(path)) === false;
@@ -874,8 +1013,7 @@ var FilteredFilesApi = class {
874
1013
  return this.source.copy(source, target);
875
1014
  }
876
1015
  };
877
- //#endregion
878
- //#region src/guarded-files-api.ts
1016
+ // src/guarded-files-api.ts
879
1017
  /**
880
1018
  * `FilesApi` decorator that runs every call through an ordered list of
881
1019
  * {@link FileGuard}s. A guard fires when its `operations` set intersects the
@@ -890,6 +1028,7 @@ var FilteredFilesApi = class {
890
1028
  * | ------------- | --------------------------------------------------- |
891
1029
  * | `read` | `read` |
892
1030
  * | `write` | `write` |
1031
+ * | `createExclusive` | `write` |
893
1032
  * | `mkdir` | `mkdir` |
894
1033
  * | `remove` | `remove` |
895
1034
  * | `list` | `list` on the path AND on each directory entry |
@@ -928,6 +1067,18 @@ var GuardedFilesApi = class {
928
1067
  constructor(source, guards) {
929
1068
  this.source = source;
930
1069
  this.guards = [...guards];
1070
+ return followCreateExclusive(this, () => typeof source.createExclusive === "function");
1071
+ }
1072
+ /**
1073
+ * Present only while the source has it, checked at each access (`in` and `typeof`
1074
+ * included), so it follows a source whose methods change, such as a
1075
+ * `CompositeFilesApi` after `mount()`. A `write`-guarded pass-through, so the
1076
+ * exclusion is the source's own.
1077
+ */
1078
+ async createExclusive(path, content) {
1079
+ this.checkGuard(path, "write");
1080
+ if (!this.source.createExclusive) throw new Error(`createExclusive is not supported by the source for ${path}`);
1081
+ return this.source.createExclusive(path, content);
931
1082
  }
932
1083
  checkGuard(path, ...operations) {
933
1084
  const normalized = normalizePath(path);
@@ -981,8 +1132,7 @@ var GuardedFilesApi = class {
981
1132
  return this.source.copy(source, target);
982
1133
  }
983
1134
  };
984
- //#endregion
985
- //#region src/overlay-files-api.ts
1135
+ // src/overlay-files-api.ts
986
1136
  /**
987
1137
  * Read-only union of several `FilesApi` layers. A path is resolved
988
1138
  * **top → bottom**: the first layer that has it wins for
@@ -1067,8 +1217,7 @@ var OverlayFilesApi = class {
1067
1217
  function overlay(top, ...lower) {
1068
1218
  return new OverlayFilesApi([top, ...lower]);
1069
1219
  }
1070
- //#endregion
1071
- //#region src/read-only-files-api.ts
1220
+ // src/read-only-files-api.ts
1072
1221
  /**
1073
1222
  * Wraps a `FilesApi` in a read-only view: every mutating operation
1074
1223
  * (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
@@ -1100,5 +1249,4 @@ function readOnly(api) {
1100
1249
  message: "read-only"
1101
1250
  }]);
1102
1251
  }
1103
- //#endregion
1104
1252
  export { CompositeFilesApi, FilteredFilesApi, GuardedFilesApi, cow, globToRegExp, newGlobPathFilter, newPathFilter, newRegexpPathFilter, overlay, readOnly };
@@ -105,7 +105,8 @@ export declare function newGlobPathFilter(...pathGlobs: string[]): PathFilter;
105
105
  * - `remove` returns `false` (no error, nothing changed).
106
106
  * - `move` / `copy` return `false` if either endpoint is hidden.
107
107
  * - `write` / `mkdir` reject with an `Error` (since silently dropping a
108
- * write would lose data).
108
+ * write would lose data), and so does `createExclusive`, which the
109
+ * decorator has only when the source has it.
109
110
  * - `list` recursively skips entries whose paths are hidden, so iterating a
110
111
  * visible parent never reveals a hidden child.
111
112
  *
@@ -153,6 +154,12 @@ export declare class FilteredFilesApi implements FilesApi {
153
154
  * path is visible. See {@link PathFilter}.
154
155
  */
155
156
  constructor(source: FilesApi, pathFilter: PathFilter);
157
+ /**
158
+ * Present only while the source has it, checked at each access (`in` and `typeof`
159
+ * included), so it follows a source whose methods change, such as a
160
+ * `CompositeFilesApi` after `mount()`. The exclusion is the source's own.
161
+ */
162
+ createExclusive?(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
156
163
  protected isHidden(path: string): Promise<boolean>;
157
164
  read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
158
165
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
@@ -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;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
+ {"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;AAKnC;;;;;;;;;;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;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,EAKnD;IAED;;;;OAIG;IACG,eAAe,CAAC,CACpB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAQlB;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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wrap `target` so that its `createExclusive` exists only while `isAvailable()` says so,
3
+ * checked on every access: both `"createExclusive" in api` and
4
+ * `typeof api.createExclusive` then follow the backend(s) the object delegates to, even
5
+ * as those change (a `mount()` of a backend without the method, say), and a subclass
6
+ * that overrides the method is hidden or shown the same way.
7
+ *
8
+ * Constructors return the result, so the object every caller holds is the proxy; `this`
9
+ * inside methods is the proxy too, and every other property passes straight through.
10
+ */
11
+ export declare function followCreateExclusive<T extends object>(target: T, isAvailable: () => boolean): T;
12
+ //# sourceMappingURL=follow-create-exclusive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow-create-exclusive.d.ts","sourceRoot":"","sources":["../src/follow-create-exclusive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,EAAE,WAAW,EAAE,MAAM,OAAO,GAAG,CAAC,CAWhG"}
@@ -14,6 +14,7 @@ import type { FileGuard } from "./types.js";
14
14
  * | ------------- | --------------------------------------------------- |
15
15
  * | `read` | `read` |
16
16
  * | `write` | `write` |
17
+ * | `createExclusive` | `write` |
17
18
  * | `mkdir` | `mkdir` |
18
19
  * | `remove` | `remove` |
19
20
  * | `list` | `list` on the path AND on each directory entry |
@@ -50,6 +51,13 @@ export declare class GuardedFilesApi implements FilesApi {
50
51
  * An empty list disables every check (the wrapper becomes a passthrough).
51
52
  */
52
53
  constructor(source: FilesApi, guards: FileGuard[]);
54
+ /**
55
+ * Present only while the source has it, checked at each access (`in` and `typeof`
56
+ * included), so it follows a source whose methods change, such as a
57
+ * `CompositeFilesApi` after `mount()`. A `write`-guarded pass-through, so the
58
+ * exclusion is the source's own.
59
+ */
60
+ createExclusive?(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
53
61
  private checkGuard;
54
62
  read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
55
63
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
@@ -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;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"}
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;AAGnC,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,YAAY,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;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,EAKhD;IAED;;;;;OAKG;IACG,eAAe,CAAC,CACpB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAMlB;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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-composite",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Composite FilesApi with mount points and access guards",
@@ -34,7 +34,7 @@
34
34
  "src"
35
35
  ],
36
36
  "dependencies": {
37
- "@statewalker/webrun-files": "^0.10.4"
37
+ "@statewalker/webrun-files": "^0.11.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^26.6.4",
@@ -42,8 +42,8 @@
42
42
  "rolldown": "^1.2.12",
43
43
  "typescript": "^7.0.2",
44
44
  "vitest": "^4.1.4 || ^5.0.3",
45
- "@statewalker/webrun-files-tests": "^3.0.0",
46
- "@statewalker/webrun-files-mem": "^0.10.4"
45
+ "@statewalker/webrun-files-mem": "^0.11.0",
46
+ "@statewalker/webrun-files-tests": "^3.0.0"
47
47
  },
48
48
  "sideEffects": false,
49
49
  "publishConfig": {
@@ -5,7 +5,14 @@ import type {
5
5
  ListOptions,
6
6
  ReadOptions,
7
7
  } from "@statewalker/webrun-files";
8
- import { comparePaths, joinPath, mergeInPathOrder, normalizePath } from "@statewalker/webrun-files";
8
+ import {
9
+ comparePaths,
10
+ joinPath,
11
+ mergeInPathOrder,
12
+ normalizePath,
13
+ removeMovedSource,
14
+ } from "@statewalker/webrun-files";
15
+ import { followCreateExclusive } from "./follow-create-exclusive.js";
9
16
 
10
17
  interface MountEntry {
11
18
  prefix: string;
@@ -23,7 +30,7 @@ interface MountEntry {
23
30
  * Each backend can use a sub-directory of its own filesystem as the mount
24
31
  * root via `fsPath` (constructor `rootPath` for the implicit root mount,
25
32
  * `fsPath` argument for additional mounts). Cross-mount `move` is
26
- * implemented as copy-then-delete; there is no atomicity guarantee.
33
+ * implemented as copy-then-delete; there is no atomicity guarantee (see `move`).
27
34
  *
28
35
  * Access control and visibility filtering are intentionally **not** part of
29
36
  * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
@@ -52,6 +59,38 @@ export class CompositeFilesApi implements FilesApi {
52
59
  */
53
60
  constructor(root: FilesApi, rootPath?: string) {
54
61
  this.mounts = [{ prefix: "/", api: root, basePath: normalizePath(rootPath ?? "/") }];
62
+ // biome-ignore lint/correctness/noConstructorReturn: the proxy shows `createExclusive` only while every backend has it
63
+ return followCreateExclusive(this, () =>
64
+ this.mounts.every((m) => typeof m.api.createExclusive === "function"),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Present only while EVERY backend — the root and each mount — has its own
70
+ * `createExclusive`, so feature detection on the composite tells the truth for
71
+ * any path: `"createExclusive" in composite` and `typeof composite.createExclusive`
72
+ * are checked against the mounts at each access. Mounting a backend without it hides
73
+ * the method; it is never shown and then refused for some paths. (A reference taken
74
+ * before such a mount still calls; it throws for paths on a backend without it.)
75
+ * It is a prototype method, so a subclass overrides it as usual.
76
+ *
77
+ * A call goes to the backend owning the path, so the exclusion reaches as far
78
+ * as that backend's does. A mount point, and a directory that exists only
79
+ * because a mount lies under it, count as existing.
80
+ */
81
+ async createExclusive?(
82
+ path: string,
83
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
84
+ ): Promise<boolean> {
85
+ const normalized = normalizePath(path);
86
+ if (this.mounts.some((m) => m.prefix !== "/" && isInsideOrSame(m.prefix, normalized))) {
87
+ return false;
88
+ }
89
+ const { api, resolvedPath } = this.resolve(normalized);
90
+ if (!api.createExclusive) {
91
+ throw new Error(`createExclusive is not supported by the backend for ${normalized}`);
92
+ }
93
+ return api.createExclusive(resolvedPath, content);
55
94
  }
56
95
 
57
96
  /**
@@ -266,6 +305,17 @@ export class CompositeFilesApi implements FilesApi {
266
305
  return api.remove(resolvedPath);
267
306
  }
268
307
 
308
+ /**
309
+ * Within one mount the move is delegated, so it is exactly as atomic as that
310
+ * backend's `move` (and treats an existing target as that backend does).
311
+ *
312
+ * Across mounts it can only be a copy, then a delete, and is NOT atomic: while it
313
+ * runs, the content exists in both places, and a directory appears in the target
314
+ * file by file. A failed copy throws and keeps the source. If the copy succeeds but
315
+ * the source cannot be removed, the removal is retried once, then a
316
+ * `MoveIncompleteError` is thrown: the target holds the new content and the source
317
+ * still exists. False is never returned for a half-done move.
318
+ */
269
319
  async move(source: string, target: string): Promise<boolean> {
270
320
  const src = this.resolve(source);
271
321
  const tgt = this.resolve(target);
@@ -277,10 +327,14 @@ export class CompositeFilesApi implements FilesApi {
277
327
 
278
328
  // Cross-mount: copy then remove
279
329
  const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
280
- if (copied) {
281
- await src.api.remove(src.resolvedPath);
282
- }
283
- return copied;
330
+ if (!copied) return false;
331
+ // Reported with the composite's paths, not the backend's.
332
+ const backend = {
333
+ remove: () => src.api.remove(src.resolvedPath),
334
+ exists: () => src.api.exists(src.resolvedPath),
335
+ };
336
+ await removeMovedSource(backend, normalizePath(source), normalizePath(target));
337
+ return true;
284
338
  }
285
339
 
286
340
  async copy(source: string, target: string): Promise<boolean> {
@@ -374,6 +428,10 @@ function translateAfter(
374
428
  return SKIP;
375
429
  }
376
430
 
431
+ function isInsideOrSame(path: string, ancestor: string): boolean {
432
+ return path === ancestor || isStrictlyInside(path, ancestor);
433
+ }
434
+
377
435
  function isStrictlyInside(path: string, ancestor: string): boolean {
378
436
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
379
437
  }
@@ -11,6 +11,7 @@ import {
11
11
  joinPath,
12
12
  listInPathOrder,
13
13
  normalizePath,
14
+ removeMovedSource,
14
15
  } from "@statewalker/webrun-files";
15
16
 
16
17
  /** Options for {@link cow}. */
@@ -190,11 +191,43 @@ class CowFilesApi implements FilesApi {
190
191
  return true;
191
192
  }
192
193
 
194
+ /**
195
+ * NOT atomic: the composite view of the source is copied into the writable layer,
196
+ * then the source is removed (deleted from the writable layer and/or hidden behind
197
+ * a whiteout). While it runs the content exists in both places, and a directory
198
+ * appears in the target file by file. A failed copy throws and keeps the source. If
199
+ * the copy succeeds but the source cannot be removed, the removal is retried once,
200
+ * then a `MoveIncompleteError` is thrown: the target holds the new content and the
201
+ * source still exists. False is never returned for a half-done move.
202
+ *
203
+ * An existing target (as the composite view shows it) is treated as POSIX
204
+ * `rename(2)` treats it, like `MemFilesApi` and `NodeFilesApi`: a file replaces a
205
+ * file, and a directory replaces an empty directory. Everything else returns false
206
+ * and changes nothing: a file onto a directory, a directory onto a file or onto a
207
+ * non-empty directory, a directory into its own subtree, a target below a file, and
208
+ * a missing source. A move onto itself is a successful no-op.
209
+ */
193
210
  async move(source: string, target: string): Promise<boolean> {
194
- const stats = await this.stats(source);
211
+ const from = normalizePath(source);
212
+ const to = normalizePath(target);
213
+ const stats = await this.stats(from);
195
214
  if (!stats) return false;
196
- await this.copyInto(source, target, stats);
197
- await this.remove(source);
215
+ if (from === to) return true;
216
+ if (from === "/" || to.startsWith(`${from}/`)) return false;
217
+ for (let dir = dirname(to); dir !== "/"; dir = dirname(dir)) {
218
+ const kind = (await this.stats(dir))?.kind;
219
+ if (kind === "file") return false;
220
+ if (kind === "directory") break;
221
+ }
222
+ const targetStats = await this.stats(to);
223
+ if (targetStats) {
224
+ if (targetStats.kind !== stats.kind) return false;
225
+ if (targetStats.kind === "directory") {
226
+ for await (const _ of this.list(to)) return false;
227
+ }
228
+ }
229
+ await this.copyInto(from, to, stats);
230
+ await removeMovedSource(this, from, to);
198
231
  return true;
199
232
  }
200
233
 
@@ -6,6 +6,7 @@ import type {
6
6
  ReadOptions,
7
7
  } from "@statewalker/webrun-files";
8
8
  import { normalizePath } from "@statewalker/webrun-files";
9
+ import { followCreateExclusive } from "./follow-create-exclusive.js";
9
10
  import { globToRegExp } from "./glob-to-regexp.js";
10
11
 
11
12
  /**
@@ -139,7 +140,8 @@ export function newGlobPathFilter(...pathGlobs: string[]): PathFilter {
139
140
  * - `remove` returns `false` (no error, nothing changed).
140
141
  * - `move` / `copy` return `false` if either endpoint is hidden.
141
142
  * - `write` / `mkdir` reject with an `Error` (since silently dropping a
142
- * write would lose data).
143
+ * write would lose data), and so does `createExclusive`, which the
144
+ * decorator has only when the source has it.
143
145
  * - `list` recursively skips entries whose paths are hidden, so iterating a
144
146
  * visible parent never reveals a hidden child.
145
147
  *
@@ -190,6 +192,26 @@ export class FilteredFilesApi implements FilesApi {
190
192
  constructor(source: FilesApi, pathFilter: PathFilter) {
191
193
  this.source = source;
192
194
  this.pathFilter = pathFilter;
195
+ // biome-ignore lint/correctness/noConstructorReturn: the proxy shows `createExclusive` only while the source has it
196
+ return followCreateExclusive(this, () => typeof source.createExclusive === "function");
197
+ }
198
+
199
+ /**
200
+ * Present only while the source has it, checked at each access (`in` and `typeof`
201
+ * included), so it follows a source whose methods change, such as a
202
+ * `CompositeFilesApi` after `mount()`. The exclusion is the source's own.
203
+ */
204
+ async createExclusive?(
205
+ path: string,
206
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
207
+ ): Promise<boolean> {
208
+ if (await this.isHidden(path)) {
209
+ throw new Error(`Path is hidden: ${path}`);
210
+ }
211
+ if (!this.source.createExclusive) {
212
+ throw new Error(`createExclusive is not supported by the source for ${path}`);
213
+ }
214
+ return this.source.createExclusive(path, content);
193
215
  }
194
216
 
195
217
  protected async isHidden(path: string): Promise<boolean> {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wrap `target` so that its `createExclusive` exists only while `isAvailable()` says so,
3
+ * checked on every access: both `"createExclusive" in api` and
4
+ * `typeof api.createExclusive` then follow the backend(s) the object delegates to, even
5
+ * as those change (a `mount()` of a backend without the method, say), and a subclass
6
+ * that overrides the method is hidden or shown the same way.
7
+ *
8
+ * Constructors return the result, so the object every caller holds is the proxy; `this`
9
+ * inside methods is the proxy too, and every other property passes straight through.
10
+ */
11
+ export function followCreateExclusive<T extends object>(target: T, isAvailable: () => boolean): T {
12
+ return new Proxy(target, {
13
+ has(object, key) {
14
+ if (key === "createExclusive" && !isAvailable()) return false;
15
+ return Reflect.has(object, key);
16
+ },
17
+ get(object, key, receiver) {
18
+ if (key === "createExclusive" && !isAvailable()) return undefined;
19
+ return Reflect.get(object, key, receiver);
20
+ },
21
+ });
22
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  ReadOptions,
7
7
  } from "@statewalker/webrun-files";
8
8
  import { normalizePath } from "@statewalker/webrun-files";
9
+ import { followCreateExclusive } from "./follow-create-exclusive.js";
9
10
  import type { FileGuard, FileOperation } from "./types.js";
10
11
 
11
12
  /**
@@ -22,6 +23,7 @@ import type { FileGuard, FileOperation } from "./types.js";
22
23
  * | ------------- | --------------------------------------------------- |
23
24
  * | `read` | `read` |
24
25
  * | `write` | `write` |
26
+ * | `createExclusive` | `write` |
25
27
  * | `mkdir` | `mkdir` |
26
28
  * | `remove` | `remove` |
27
29
  * | `list` | `list` on the path AND on each directory entry |
@@ -61,6 +63,25 @@ export class GuardedFilesApi implements FilesApi {
61
63
  constructor(source: FilesApi, guards: FileGuard[]) {
62
64
  this.source = source;
63
65
  this.guards = [...guards];
66
+ // biome-ignore lint/correctness/noConstructorReturn: the proxy shows `createExclusive` only while the source has it
67
+ return followCreateExclusive(this, () => typeof source.createExclusive === "function");
68
+ }
69
+
70
+ /**
71
+ * Present only while the source has it, checked at each access (`in` and `typeof`
72
+ * included), so it follows a source whose methods change, such as a
73
+ * `CompositeFilesApi` after `mount()`. A `write`-guarded pass-through, so the
74
+ * exclusion is the source's own.
75
+ */
76
+ async createExclusive?(
77
+ path: string,
78
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
79
+ ): Promise<boolean> {
80
+ this.checkGuard(path, "write");
81
+ if (!this.source.createExclusive) {
82
+ throw new Error(`createExclusive is not supported by the source for ${path}`);
83
+ }
84
+ return this.source.createExclusive(path, content);
64
85
  }
65
86
 
66
87
  private checkGuard(path: string, ...operations: FileOperation[]): void {