@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 +46 -2
- package/dist/cjs/index.cjs +164 -8
- 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 +164 -8
- 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
|
@@ -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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
658
|
+
const from = normalizePath(source);
|
|
659
|
+
const to = normalizePath(target);
|
|
660
|
+
const stats = await this.stats(from);
|
|
542
661
|
if (!stats) return false;
|
|
543
|
-
|
|
544
|
-
|
|
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;
|
|
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
|
@@ -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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
657
|
+
const from = normalizePath(source);
|
|
658
|
+
const to = normalizePath(target);
|
|
659
|
+
const stats = await this.stats(from);
|
|
541
660
|
if (!stats) return false;
|
|
542
|
-
|
|
543
|
-
|
|
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;
|
|
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 {
|