@statewalker/webrun-files-composite 0.10.5 → 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.
@@ -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,6 +202,29 @@ 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
  }
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
+ }
165
228
  // src/composite-files-api.ts
166
229
  /**
167
230
  * Composite `FilesApi` that routes calls to one of several backends based
@@ -173,7 +236,7 @@ function basename(path, ext) {
173
236
  * Each backend can use a sub-directory of its own filesystem as the mount
174
237
  * root via `fsPath` (constructor `rootPath` for the implicit root mount,
175
238
  * `fsPath` argument for additional mounts). Cross-mount `move` is
176
- * implemented as copy-then-delete; there is no atomicity guarantee.
239
+ * implemented as copy-then-delete; there is no atomicity guarantee (see `move`).
177
240
  *
178
241
  * Access control and visibility filtering are intentionally **not** part of
179
242
  * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
@@ -205,6 +268,27 @@ var CompositeFilesApi = class {
205
268
  api: root,
206
269
  basePath: normalizePath(rootPath ?? "/")
207
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);
208
292
  }
209
293
  /**
210
294
  * Attaches a backend to handle every composite path under `path`. The
@@ -354,13 +438,27 @@ var CompositeFilesApi = class {
354
438
  const { api, resolvedPath } = this.resolve(path);
355
439
  return api.remove(resolvedPath);
356
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
+ */
357
452
  async move(source, target) {
358
453
  const src = this.resolve(source);
359
454
  const tgt = this.resolve(target);
360
455
  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;
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;
364
462
  }
365
463
  async copy(source, target) {
366
464
  const src = this.resolve(source);
@@ -410,6 +508,9 @@ function translateAfter(after, from, to) {
410
508
  if (comparePaths(after, `${fromPrefix}/`) < 0) return void 0;
411
509
  return SKIP;
412
510
  }
511
+ function isInsideOrSame(path, ancestor) {
512
+ return path === ancestor || isStrictlyInside(path, ancestor);
513
+ }
413
514
  function isStrictlyInside(path, ancestor) {
414
515
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
415
516
  }
@@ -537,11 +638,41 @@ var CowFilesApi = class {
537
638
  }
538
639
  return true;
539
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
+ */
540
657
  async move(source, target) {
541
- const stats = await this.stats(source);
658
+ const from = normalizePath(source);
659
+ const to = normalizePath(target);
660
+ const stats = await this.stats(from);
542
661
  if (!stats) return false;
543
- await this.copyInto(source, target, stats);
544
- 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);
545
676
  return true;
546
677
  }
547
678
  async copy(source, target) {
@@ -777,7 +908,8 @@ function newGlobPathFilter(...pathGlobs) {
777
908
  * - `remove` returns `false` (no error, nothing changed).
778
909
  * - `move` / `copy` return `false` if either endpoint is hidden.
779
910
  * - `write` / `mkdir` reject with an `Error` (since silently dropping a
780
- * write would lose data).
911
+ * write would lose data), and so does `createExclusive`, which the
912
+ * decorator has only when the source has it.
781
913
  * - `list` recursively skips entries whose paths are hidden, so iterating a
782
914
  * visible parent never reveals a hidden child.
783
915
  *
@@ -827,6 +959,17 @@ var FilteredFilesApi = class {
827
959
  constructor(source, pathFilter) {
828
960
  this.source = source;
829
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);
830
973
  }
831
974
  async isHidden(path) {
832
975
  return await this.pathFilter(normalizePath(path)) === false;
@@ -886,6 +1029,7 @@ var FilteredFilesApi = class {
886
1029
  * | ------------- | --------------------------------------------------- |
887
1030
  * | `read` | `read` |
888
1031
  * | `write` | `write` |
1032
+ * | `createExclusive` | `write` |
889
1033
  * | `mkdir` | `mkdir` |
890
1034
  * | `remove` | `remove` |
891
1035
  * | `list` | `list` on the path AND on each directory entry |
@@ -924,6 +1068,18 @@ var GuardedFilesApi = class {
924
1068
  constructor(source, guards) {
925
1069
  this.source = source;
926
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);
927
1083
  }
928
1084
  checkGuard(path, ...operations) {
929
1085
  const normalized = normalizePath(path);
@@ -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
@@ -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,6 +201,29 @@ 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
  }
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
+ }
164
227
  // src/composite-files-api.ts
165
228
  /**
166
229
  * Composite `FilesApi` that routes calls to one of several backends based
@@ -172,7 +235,7 @@ function basename(path, ext) {
172
235
  * Each backend can use a sub-directory of its own filesystem as the mount
173
236
  * root via `fsPath` (constructor `rootPath` for the implicit root mount,
174
237
  * `fsPath` argument for additional mounts). Cross-mount `move` is
175
- * implemented as copy-then-delete; there is no atomicity guarantee.
238
+ * implemented as copy-then-delete; there is no atomicity guarantee (see `move`).
176
239
  *
177
240
  * Access control and visibility filtering are intentionally **not** part of
178
241
  * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
@@ -204,6 +267,27 @@ var CompositeFilesApi = class {
204
267
  api: root,
205
268
  basePath: normalizePath(rootPath ?? "/")
206
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);
207
291
  }
208
292
  /**
209
293
  * Attaches a backend to handle every composite path under `path`. The
@@ -353,13 +437,27 @@ var CompositeFilesApi = class {
353
437
  const { api, resolvedPath } = this.resolve(path);
354
438
  return api.remove(resolvedPath);
355
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
+ */
356
451
  async move(source, target) {
357
452
  const src = this.resolve(source);
358
453
  const tgt = this.resolve(target);
359
454
  if (src.api === tgt.api) return src.api.move(src.resolvedPath, tgt.resolvedPath);
360
- const copied = await this.crossCopy(src.api, src.resolvedPath, tgt.api, tgt.resolvedPath);
361
- if (copied) await src.api.remove(src.resolvedPath);
362
- 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;
363
461
  }
364
462
  async copy(source, target) {
365
463
  const src = this.resolve(source);
@@ -409,6 +507,9 @@ function translateAfter(after, from, to) {
409
507
  if (comparePaths(after, `${fromPrefix}/`) < 0) return void 0;
410
508
  return SKIP;
411
509
  }
510
+ function isInsideOrSame(path, ancestor) {
511
+ return path === ancestor || isStrictlyInside(path, ancestor);
512
+ }
412
513
  function isStrictlyInside(path, ancestor) {
413
514
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
414
515
  }
@@ -536,11 +637,41 @@ var CowFilesApi = class {
536
637
  }
537
638
  return true;
538
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
+ */
539
656
  async move(source, target) {
540
- const stats = await this.stats(source);
657
+ const from = normalizePath(source);
658
+ const to = normalizePath(target);
659
+ const stats = await this.stats(from);
541
660
  if (!stats) return false;
542
- await this.copyInto(source, target, stats);
543
- 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);
544
675
  return true;
545
676
  }
546
677
  async copy(source, target) {
@@ -776,7 +907,8 @@ function newGlobPathFilter(...pathGlobs) {
776
907
  * - `remove` returns `false` (no error, nothing changed).
777
908
  * - `move` / `copy` return `false` if either endpoint is hidden.
778
909
  * - `write` / `mkdir` reject with an `Error` (since silently dropping a
779
- * write would lose data).
910
+ * write would lose data), and so does `createExclusive`, which the
911
+ * decorator has only when the source has it.
780
912
  * - `list` recursively skips entries whose paths are hidden, so iterating a
781
913
  * visible parent never reveals a hidden child.
782
914
  *
@@ -826,6 +958,17 @@ var FilteredFilesApi = class {
826
958
  constructor(source, pathFilter) {
827
959
  this.source = source;
828
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);
829
972
  }
830
973
  async isHidden(path) {
831
974
  return await this.pathFilter(normalizePath(path)) === false;
@@ -885,6 +1028,7 @@ var FilteredFilesApi = class {
885
1028
  * | ------------- | --------------------------------------------------- |
886
1029
  * | `read` | `read` |
887
1030
  * | `write` | `write` |
1031
+ * | `createExclusive` | `write` |
888
1032
  * | `mkdir` | `mkdir` |
889
1033
  * | `remove` | `remove` |
890
1034
  * | `list` | `list` on the path AND on each directory entry |
@@ -923,6 +1067,18 @@ var GuardedFilesApi = class {
923
1067
  constructor(source, guards) {
924
1068
  this.source = source;
925
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);
926
1082
  }
927
1083
  checkGuard(path, ...operations) {
928
1084
  const normalized = normalizePath(path);
@@ -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.5",
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.5"
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.5"
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 {