@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 +46 -2
- package/dist/cjs/index.cjs +172 -24
- package/dist/composite-files-api.d.ts +26 -1
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/cow-files-api.d.ts.map +1 -1
- package/dist/esm/index.js +172 -24
- package/dist/filtered-files-api.d.ts +8 -1
- package/dist/filtered-files-api.d.ts.map +1 -1
- package/dist/follow-create-exclusive.d.ts +12 -0
- package/dist/follow-create-exclusive.d.ts.map +1 -0
- package/dist/guarded-files-api.d.ts +8 -0
- package/dist/guarded-files-api.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/composite-files-api.ts +64 -6
- package/src/cow-files-api.ts +36 -3
- package/src/filtered-files-api.ts +23 -1
- package/src/follow-create-exclusive.ts +22 -0
- package/src/guarded-files-api.ts +21 -0
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`
|
|
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.
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
|
658
|
+
const from = normalizePath(source);
|
|
659
|
+
const to = normalizePath(target);
|
|
660
|
+
const stats = await this.stats(from);
|
|
544
661
|
if (!stats) return false;
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
657
|
+
const from = normalizePath(source);
|
|
658
|
+
const to = normalizePath(target);
|
|
659
|
+
const stats = await this.stats(from);
|
|
543
660
|
if (!stats) return false;
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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;
|
|
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.
|
|
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.
|
|
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-
|
|
46
|
-
"@statewalker/webrun-files-
|
|
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 {
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
}
|
package/src/cow-files-api.ts
CHANGED
|
@@ -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
|
|
211
|
+
const from = normalizePath(source);
|
|
212
|
+
const to = normalizePath(target);
|
|
213
|
+
const stats = await this.stats(from);
|
|
195
214
|
if (!stats) return false;
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}
|
package/src/guarded-files-api.ts
CHANGED
|
@@ -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 {
|