@poe-platform/safe-fs 0.1.807 → 0.1.808
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 +19 -0
- package/dist/safe-fs/contracts/filesystem.d.ts +5 -0
- package/dist/safe-fs/core.d.ts +2 -0
- package/dist/safe-fs/core.js +2 -0
- package/dist/safe-fs/fs/capabilities.js +2 -2
- package/dist/safe-fs/fs/devices/index.d.ts +1 -0
- package/dist/safe-fs/fs/devices/index.js +40 -2
- package/dist/safe-fs/fs/memory/atomic-view.d.ts +1 -0
- package/dist/safe-fs/fs/memory/index.d.ts +4 -1
- package/dist/safe-fs/fs/memory/index.js +56 -9
- package/dist/safe-fs/fs/mount/index.d.ts +3 -1
- package/dist/safe-fs/fs/mount/index.js +154 -3
- package/dist/safe-fs/fs/overlay/index.d.ts +2 -0
- package/dist/safe-fs/fs/overlay/index.js +167 -59
- package/dist/safe-fs/fs/overlay/memory-publication.d.ts +1 -0
- package/dist/safe-fs/fs/overlay/memory-publication.js +5 -0
- package/dist/safe-fs/fs/quota/index.js +9 -4
- package/dist/safe-fs/fs/read-file-stream.d.ts +7 -0
- package/dist/safe-fs/fs/read-file-stream.js +103 -0
- package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
- package/dist/safe-fs/fs/readonly/index.js +10 -0
- package/dist/safe-fs/fs/real/index.d.ts +1 -0
- package/dist/safe-fs/fs/real/index.js +27 -0
- package/dist/safe-fs/fs/s3/filesystem.d.ts +2 -1
- package/dist/safe-fs/fs/s3/filesystem.js +55 -16
- package/dist/safe-fs/fs/scoped.js +10 -4
- package/dist/safe-fs/index.d.ts +1 -0
- package/dist/safe-fs/index.js +1 -0
- package/dist/safe-fs/runtime-core.d.ts +1 -0
- package/dist/safe-fs/runtime-core.js +1 -0
- package/dist/safe-fs/storage.d.ts +49 -0
- package/dist/safe-fs/storage.js +265 -0
- package/dist/safe-fs/testing/object-io-metrics.js +1 -0
- package/package.json +9 -6
package/README.md
CHANGED
|
@@ -4,6 +4,13 @@ An asynchronous filesystem interface for memory, rooted host directories, S3, an
|
|
|
4
4
|
|
|
5
5
|
Under `workerd` and `browser` conditions, the root export includes portable filesystems, `S3FileSystem`, and `MockS3Client`. Supply an S3 transport to use object storage without Node globals.
|
|
6
6
|
|
|
7
|
+
The `storage` subpath exposes `PagedStorage` and `IntegerTable` for temporary
|
|
8
|
+
working data. Supply `{fs, cwd, env, signal}` and an optional page-cache count
|
|
9
|
+
(default 64 pages of 16 KiB). Scratch data uses retained positioned handles in
|
|
10
|
+
`env.TMPDIR` or `cwd`, with conditional unlink and cleanup through `close()`.
|
|
11
|
+
Providers must support those capabilities; there is no host fallback. Use an
|
|
12
|
+
external backend for large data because memory filesystems retain spilled bytes.
|
|
13
|
+
|
|
7
14
|
## Quickstart
|
|
8
15
|
|
|
9
16
|
Install the standalone package; it does not depend on SafeJS, Safe Bash, or the `poe-code` CLI. These examples use TypeScript in a Node ESM application.
|
|
@@ -79,9 +86,11 @@ cause. Preserve cancellation/control exceptions separately. Cross-family
|
|
|
79
86
|
| Change the namespace | `mkdir`, `rm`, `rename`, `copyFile` |
|
|
80
87
|
| Remove an empty directory | Optional `rmdir`; never a recursive-delete fallback |
|
|
81
88
|
| Links and metadata | Optional `readlink`, `symlink`, `link`, `chmod`, `utimes`, `truncate` |
|
|
89
|
+
| Enumerate directories lazily | Optional `iterateDirectory`; memory, native and paged S3 backends, with mount/overlay/readonly/quota/device/scoped delegation; backend order, no sorted-array fallback |
|
|
82
90
|
| Stream bytes | Optional `readStream`, `writeStream`, using async iterables of byte chunks |
|
|
83
91
|
| Retain an open file | Optional `open` with positioned I/O and synchronization; memory and real backends support `noFollow: true` to atomically refuse a final symlink |
|
|
84
92
|
| Publish immutable objects atomically | Optional `publishFileConditional` with opaque identity/version stats and authoritative compare-and-publish |
|
|
93
|
+
| Publish a checked source set | Optional `publishStagedFileSet` publishes a staged file and retires sibling entries in one backend commit; mount views require all entries on the same supporting backend |
|
|
85
94
|
| Compare backing entries | Optional `compareEntry`, returning `same`, `distinct`, or `unknown` |
|
|
86
95
|
|
|
87
96
|
All required methods must exist, but a backend may reject an operation with `FsError`, such as `EROFS` for a write or `ENOTSUP` for unsupported semantics. Check optional methods and `capabilities` rather than assuming every backend behaves like a local disk. Errors expose `code` and may include `syscall`, `path`, `dest`, and `cause`.
|
|
@@ -295,6 +304,16 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
|
|
|
295
304
|
|
|
296
305
|
`access` takes a separate mode bitmask from `ACCESS_MODES`. `chmod` takes a mode, `utimes` takes millisecond timestamps, and `truncate` takes a byte length (default 0). For conditional chmod, check `capabilitiesFor(path, { conditionalChmod: true })` (or `capabilities`) and require `conditionalChmod: true`; supply `parent`, `expected`, and complete root-to-parent `ancestors` together. An optional mutation-free `commitGuard` must return literal `true` synchronously. Memory validates at its metadata commit; mount and supported Memory overlays preserve wrapper ancestry. Real uses its existing externally isolated host-tree boundary and does not prevent races with other processes. Backend limits still apply. Node-shaped bridge methods translate their own options rather than accepting these raw option objects; see the [bridge signatures](src/bridge/filesystem.ts).
|
|
297
306
|
|
|
307
|
+
Overlay copy-up, copy, stream replacement/append and resize transfer retained ranges in 64 KiB chunks through the injected upper's stream writer. Resize streams the retained prefix and bounded zero chunks. Stream writes stage input before publication; cancellation or transfer failure leaves the destination unchanged and cleans staging. Stock Memory overlays transfer staged storage into existing upper inodes, preserving hardlinks without an additional whole-file buffer. `readFile` and `writeFile` remain buffering convenience APIs; use `readStream`/`writeStream` for large payloads. `maxBufferBytes` retains its existing optional total-byte admission semantics, not a working-memory guarantee.
|
|
308
|
+
|
|
309
|
+
A streaming overlay needs an upper with `atomicRename`, `streamingWrite`, retained reads (`retainedRead` plus `openReadFile`), exclusive create, directory mutation, permissions and timestamps. Both layers must provide retained, bounded range reads with stable file and directory identities. For example, inject these host-authorized adapters with `createOverlayFileSystem({ upper: externalSpool, lower: retainedSource })`, then use `overlay.writeStream(path, source)`. The upper must await each consumed chunk, keep transport queues bounded, and preserve ownership until publication. Supply bounded chunks at the input boundary; the overlay cannot undo a payload-wide allocation already made by its caller. Whole-file-only uppers explicitly reject transfer operations with `ENOTSUP`; they do not silently collect streams.
|
|
310
|
+
|
|
311
|
+
For Workers, `externalSpool` must store payloads outside isolate RAM and satisfy that contract, including atomic rename and retained identity. Memory and the S3 mock store payloads in RAM and are not external spools. Stock S3/WebDAV do not provide the atomic rename/retained-read contract needed for an overlay upper; setting a capability flag does not add it. A host must inject a qualified external adapter. On Node, rooted Real adapters provide the streaming overlay configuration under their documented external-tree isolation requirement. Mount and read-only wrappers forward the selected backend's capabilities; quota streaming additionally requires a real streaming-append route. S3's buffered append convenience path reports `streamingAppend: false`.
|
|
312
|
+
|
|
313
|
+
The reusable metadata-only fixture in [`tests/fixtures/bounded-filesystem.ts`](tests/fixtures/bounded-filesystem.ts) generates file ranges and consumes slow writes without storing payload bytes. [`tests/overlay-bounded.test.ts`](tests/overlay-bounded.test.ts) checks fixed transfer sizes, source backpressure, cancellation/error cleanup, byte results, and Memory publication allocation/identity. These deterministic conformance checks do not qualify a deployed Worker backend; follow the [Worker QA plan](../../docs/plans/safe-fs-bounded-overlay-qa.md) for runtime measurements.
|
|
314
|
+
|
|
315
|
+
`readFileStream(fs, path, options)` reads through the caller's native stream or retained range handle without calling `readFile`. It accepts the stream range/window options above, defaults to a 64 KiB read window, and closes owned readers on completion, failure or cancellation. `skipStream: true` selects retained ranges after an already-attempted stream reports `ENOTSUP`. Chunks are borrowed until the iterator advances. Native adapters must honor the requested window and backpressure. Whole-file-only backends receive `ENOTSUP`; use a streaming or retained-range backend for sequential Worker operations. This helper bounds its I/O window, not the consuming command's parser or output capture.
|
|
316
|
+
|
|
298
317
|
`collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxBytes` and `maxMemoryBytes` accept explicit `Infinity`. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`. Collection limits are disabled by default on Node, browser, and Worker targets. Each call owns its budget; concurrent collections do not consume one another’s allowance. The returned view may retain geometric spare capacity. Configured budgets cover collection, not caller-retained results, transport buffering, archive decoding, strings, or the rest of the runtime; use streaming APIs and limit concurrent workloads for larger inputs.
|
|
299
318
|
|
|
300
319
|
<details>
|
|
@@ -352,6 +352,11 @@ export interface FileSystem {
|
|
|
352
352
|
lstat(path: string, options?: FsOptions): Promise<FileStat>;
|
|
353
353
|
compareEntry?(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
|
|
354
354
|
readdir(path: string, options?: ReadDirectoryOptions): Promise<DirectoryEntry[]>;
|
|
355
|
+
/** Optional bounded, lazy directory enumeration in backend-defined order.
|
|
356
|
+
* No complete listing/sort fallback. Consumers must retire the iterator on
|
|
357
|
+
* early exit. Namespace changes may reject or follow the backend's live view;
|
|
358
|
+
* this is not a tree snapshot or a retained path-authority grant. */
|
|
359
|
+
iterateDirectory?(path: string, options?: FsOptions): AsyncIterable<DirectoryEntry>;
|
|
355
360
|
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
356
361
|
rm(path: string, options?: RemoveOptions): Promise<void>;
|
|
357
362
|
/** Atomic final-entry removal; every directory is refused, including raced replacements. */
|
package/dist/safe-fs/core.d.ts
CHANGED
|
@@ -17,3 +17,5 @@ export * from "./fs/s3/http/index.js";
|
|
|
17
17
|
export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
|
|
18
18
|
export type { FileSystemConfig, FileSystemAdapterDescriptor, FileSystemAdapterRegistry } from "./config.js";
|
|
19
19
|
export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
|
|
20
|
+
export { readFileStream } from "./fs/read-file-stream.js";
|
|
21
|
+
export { PagedStorage, IntegerTable, type PagedStorageContext } from "./storage.js";
|
package/dist/safe-fs/core.js
CHANGED
|
@@ -15,3 +15,5 @@ export * from "./fs/s3/index.js";
|
|
|
15
15
|
export * from "./fs/s3/http/index.js";
|
|
16
16
|
export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
|
|
17
17
|
export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
|
|
18
|
+
export { readFileStream } from "./fs/read-file-stream.js";
|
|
19
|
+
export { PagedStorage, IntegerTable } from "./storage.js";
|
|
@@ -141,8 +141,8 @@ export function readOnlyCapabilities(capabilities) {
|
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
export function quotaCapabilities(capabilities) {
|
|
144
|
-
const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
|
|
145
|
-
const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
|
|
144
|
+
const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, capabilities.streamingAppend, !capabilities.readOnly);
|
|
145
|
+
const streamingAppend = requireCapabilities(capabilities.append, capabilities.streamingAppend, !capabilities.readOnly);
|
|
146
146
|
const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
|
|
147
147
|
return Object.freeze({ ...rest, synchronousStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
148
148
|
...(streamingWrite === undefined ? {} : { streamingWrite }),
|
|
@@ -25,6 +25,7 @@ export declare class DeviceFileSystem implements FileSystem {
|
|
|
25
25
|
writeFile(path: string, data: Uint8Array, options?: WriteFileOptions): Promise<void>;
|
|
26
26
|
appendFile(path: string, data: Uint8Array, options?: AppendFileOptions): Promise<void>;
|
|
27
27
|
writeStream(path: string, source: ByteSource, options?: WriteFileOptions): Promise<void>;
|
|
28
|
+
iterateDirectory(path: string, options?: FsOptions): AsyncIterable<DirectoryEntry>;
|
|
28
29
|
readdir(path: string, options?: ReadDirectoryOptions): Promise<DirectoryEntry[]>;
|
|
29
30
|
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
30
31
|
rm(path: string, options?: RemoveOptions): Promise<void>;
|
|
@@ -490,6 +490,40 @@ export class DeviceFileSystem {
|
|
|
490
490
|
throw new FsError("ENOTSUP", { path });
|
|
491
491
|
await this.#filesystem.writeStream(path, source, options);
|
|
492
492
|
}
|
|
493
|
+
async *iterateDirectory(path, options = {}) {
|
|
494
|
+
const resolved = await this.#resolve(path, options);
|
|
495
|
+
if (resolved === nullPath)
|
|
496
|
+
throw new FsError("ENOTDIR", { syscall: "iterateDirectory", path });
|
|
497
|
+
const virtual = resolved === "/" ? "dev" : resolved === deviceDirectory ? "null" : undefined;
|
|
498
|
+
if (!this.#filesystem.iterateDirectory)
|
|
499
|
+
throw new FsError("ENOTSUP", { syscall: "iterateDirectory", path });
|
|
500
|
+
let seen = false;
|
|
501
|
+
try {
|
|
502
|
+
for await (const entry of this.#filesystem.iterateDirectory(virtual ? resolved : path, options)) {
|
|
503
|
+
options.signal?.throwIfAborted();
|
|
504
|
+
if (entry.name === "." || entry.name === "..")
|
|
505
|
+
continue;
|
|
506
|
+
if (!entry.name || entry.name.includes("/") || entry.name.includes("\0"))
|
|
507
|
+
throw new FsError("EIO", { path, message: "invalid directory entry" });
|
|
508
|
+
if (entry.name === virtual) {
|
|
509
|
+
if (seen)
|
|
510
|
+
continue;
|
|
511
|
+
seen = true;
|
|
512
|
+
yield { name: virtual, type: virtual === "dev" ? "directory" : "character" };
|
|
513
|
+
}
|
|
514
|
+
else
|
|
515
|
+
yield { name: entry.name, type: entry.type };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
options.signal?.throwIfAborted();
|
|
520
|
+
if (!virtual || !isFsError(error, "ENOENT") && !isFsError(error, "ENOTDIR"))
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
options.signal?.throwIfAborted();
|
|
524
|
+
if (virtual && !seen)
|
|
525
|
+
yield { name: virtual, type: virtual === "dev" ? "directory" : "character" };
|
|
526
|
+
}
|
|
493
527
|
readdir(path, options = {}) {
|
|
494
528
|
const fast = this.#tryResolveSync(path, options);
|
|
495
529
|
if (fast !== undefined && fast !== "/") {
|
|
@@ -656,8 +690,12 @@ export class DeviceFileSystem {
|
|
|
656
690
|
const capabilities = await this.#filesystem.capabilitiesFor?.(destination, { ...options, create: options.destination === null }) ?? this.#filesystem.capabilities;
|
|
657
691
|
if (capabilities.readOnly === true)
|
|
658
692
|
throw new FsError("EROFS", { path: destination });
|
|
659
|
-
|
|
660
|
-
|
|
693
|
+
// The parent is an identity guard, not an entry being replaced. Root is a
|
|
694
|
+
// valid parent; the synthetic device directory still cannot own staging.
|
|
695
|
+
const parentPath = await this.#resolve(staging.parent.path, options, false);
|
|
696
|
+
if (parentPath === deviceDirectory || parentPath === nullPath)
|
|
697
|
+
throw new FsError("EBUSY", { path: staging.parent.path });
|
|
698
|
+
await this.#mutable(staging.directory.path, options, false);
|
|
661
699
|
const guards = [];
|
|
662
700
|
if (options.ancestors)
|
|
663
701
|
guards.push(await this.prepareDirectoryAncestry(options.ancestors, options));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FileStat, FileSystem } from "../../contracts/filesystem.js";
|
|
2
2
|
/** Internal authority: inspections and stock mutations share one JavaScript turn. */
|
|
3
3
|
export interface MemoryAtomicView {
|
|
4
|
+
replaceContents(source: string, destination: string): void;
|
|
4
5
|
stat(path: string): FileStat | undefined;
|
|
5
6
|
names(path: string): readonly string[];
|
|
6
7
|
}
|
|
@@ -49,6 +49,9 @@ type OwnedStore = {
|
|
|
49
49
|
root: DirectoryNode;
|
|
50
50
|
ledger: MemoryLedger;
|
|
51
51
|
capabilities: FileSystem["capabilities"];
|
|
52
|
+
customCapabilities?: FileSystemCapabilities;
|
|
53
|
+
identityScope?: symbol;
|
|
54
|
+
atomicView?: MemoryAtomicView;
|
|
52
55
|
intact: () => boolean;
|
|
53
56
|
};
|
|
54
57
|
export interface ConditionalMutationBinding {
|
|
@@ -59,7 +62,6 @@ export interface ConditionalMutationBinding {
|
|
|
59
62
|
}
|
|
60
63
|
export declare function bindConditionalMutation<Result>(identityScope: object | symbol | undefined, binding: ConditionalMutationBinding, action: () => Promise<Result>): Promise<Result>;
|
|
61
64
|
export declare class MemoryFileSystem implements FileSystem {
|
|
62
|
-
#private;
|
|
63
65
|
capabilitiesFor?: NonNullable<FileSystem["capabilitiesFor"]>;
|
|
64
66
|
private get identityScope();
|
|
65
67
|
get capabilities(): FileSystemCapabilities;
|
|
@@ -132,6 +134,7 @@ export declare class MemoryFileSystem implements FileSystem {
|
|
|
132
134
|
appendFile(path: string, data: Uint8Array, options?: AppendFileOptions): Promise<void>;
|
|
133
135
|
stat(path: string, options?: FsOptions): Promise<FileStat>;
|
|
134
136
|
lstat(path: string, options?: FsOptions): Promise<FileStat>;
|
|
137
|
+
iterateDirectory(path: string, options?: FsOptions): AsyncIterable<DirectoryEntry>;
|
|
135
138
|
readdir(path: string, options?: ReadDirectoryOptions): Promise<DirectoryEntry[]>;
|
|
136
139
|
tryMkdirFastSync(path: string, recursive: boolean, mode: number | undefined): boolean;
|
|
137
140
|
tryRmFastSync(path: string, recursive: boolean, force: boolean): boolean;
|
|
@@ -405,6 +405,7 @@ const ext4HtreeEof64 = (1n << 63n) - 1n;
|
|
|
405
405
|
const extractionStreamGuard = Symbol("extractionStreamGuard");
|
|
406
406
|
const ownedStats = new WeakMap();
|
|
407
407
|
const ownedStores = new WeakMap();
|
|
408
|
+
const receiverCapabilities = new WeakMap();
|
|
408
409
|
// Ensure V8 uses Tagged representation for timestamp fields so all 4 fields share one HeapNumber pointer.
|
|
409
410
|
{
|
|
410
411
|
const dummyAlloc = new MemoryAllocation(new Uint8Array(0), new MemoryLedger(normalizeMemoryFileSystemLimits({})));
|
|
@@ -601,17 +602,24 @@ function customMemoryCapabilities(filesystem) {
|
|
|
601
602
|
}
|
|
602
603
|
export class MemoryFileSystem {
|
|
603
604
|
capabilitiesFor;
|
|
604
|
-
#customCapabilities;
|
|
605
|
-
#identityScopeSym;
|
|
606
605
|
get identityScope() {
|
|
607
|
-
return (this
|
|
606
|
+
return (this._owner.identityScope ??= Symbol());
|
|
608
607
|
}
|
|
609
608
|
get capabilities() {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
609
|
+
const owner = ownedStores.get(this);
|
|
610
|
+
if (owner) {
|
|
611
|
+
if (!owner.customCapabilities) {
|
|
612
|
+
owner.customCapabilities = customMemoryCapabilities(this);
|
|
613
|
+
owner.capabilities = owner.customCapabilities;
|
|
614
|
+
}
|
|
615
|
+
return owner.customCapabilities;
|
|
616
|
+
}
|
|
617
|
+
let caps = receiverCapabilities.get(this);
|
|
618
|
+
if (!caps) {
|
|
619
|
+
caps = customMemoryCapabilities(this);
|
|
620
|
+
receiverCapabilities.set(this, caps);
|
|
613
621
|
}
|
|
614
|
-
return
|
|
622
|
+
return caps;
|
|
615
623
|
}
|
|
616
624
|
nextInode = 1;
|
|
617
625
|
ledger;
|
|
@@ -621,7 +629,6 @@ export class MemoryFileSystem {
|
|
|
621
629
|
root;
|
|
622
630
|
totalBytes = 0;
|
|
623
631
|
symlinkCount = 0;
|
|
624
|
-
#atomicView;
|
|
625
632
|
_getAtomicView() {
|
|
626
633
|
const owner = ownedStores.get(this);
|
|
627
634
|
if (!owner ||
|
|
@@ -648,7 +655,27 @@ export class MemoryFileSystem {
|
|
|
648
655
|
return undefined;
|
|
649
656
|
}
|
|
650
657
|
}
|
|
651
|
-
return
|
|
658
|
+
return owner.atomicView ??= {
|
|
659
|
+
replaceContents: (source, destination) => {
|
|
660
|
+
const from = this.entry(source, "overlayAtomicView").node;
|
|
661
|
+
const to = this.entry(destination, "overlayAtomicView").node;
|
|
662
|
+
if (from?.type !== "file" || to?.type !== "file" || from === to || from.nlink !== 1)
|
|
663
|
+
this.fail("EINVAL", "overlayAtomicView", destination);
|
|
664
|
+
this.permission(to, 2, "overlayAtomicView", destination);
|
|
665
|
+
// Exchange owned storage, preserving destination inode/hardlinks and
|
|
666
|
+
// retained allocation snapshots. Stage cleanup releases the old contents.
|
|
667
|
+
const allocation = to.allocation, length = to.byteLength;
|
|
668
|
+
to.allocation = from.allocation;
|
|
669
|
+
to.byteLength = from.byteLength;
|
|
670
|
+
from.allocation = allocation;
|
|
671
|
+
from.byteLength = length;
|
|
672
|
+
to.view = undefined;
|
|
673
|
+
from.view = undefined;
|
|
674
|
+
to.sourceRef = undefined;
|
|
675
|
+
from.sourceRef = undefined;
|
|
676
|
+
this.changed(to);
|
|
677
|
+
this.changed(from);
|
|
678
|
+
},
|
|
652
679
|
stat: (path) => {
|
|
653
680
|
this.validatePath(path, "overlayAtomicView");
|
|
654
681
|
let node = this.root;
|
|
@@ -2481,6 +2508,26 @@ export class MemoryFileSystem {
|
|
|
2481
2508
|
ownedStats.set(stat, { filesystem: this, path, root: this.root });
|
|
2482
2509
|
return stat;
|
|
2483
2510
|
}
|
|
2511
|
+
async *iterateDirectory(path, options = {}) {
|
|
2512
|
+
options.signal?.throwIfAborted();
|
|
2513
|
+
const root = this.root, node = this.resolveNode(path, "iterateDirectory", true);
|
|
2514
|
+
if (node.type !== "directory")
|
|
2515
|
+
this.fail("ENOTDIR", "iterateDirectory", path);
|
|
2516
|
+
this.permission(node, 4, "iterateDirectory", path);
|
|
2517
|
+
const revision = node.revision, ino = node.ino;
|
|
2518
|
+
const check = () => {
|
|
2519
|
+
options.signal?.throwIfAborted();
|
|
2520
|
+
if (this.root !== root || node.ino !== ino || node.revision !== revision || this.resolveNode(path, "iterateDirectory", true) !== node)
|
|
2521
|
+
this.fail("EBUSY", "iterateDirectory", path);
|
|
2522
|
+
this.permission(node, 4, "iterateDirectory", path);
|
|
2523
|
+
};
|
|
2524
|
+
node.atimeMs = Date.now();
|
|
2525
|
+
for (const [name, entry] of node.entries) {
|
|
2526
|
+
check();
|
|
2527
|
+
yield { name, type: entry.type };
|
|
2528
|
+
}
|
|
2529
|
+
check();
|
|
2530
|
+
}
|
|
2484
2531
|
async readdir(path, options = {}) {
|
|
2485
2532
|
const limit = directoryEntryLimit(options, path);
|
|
2486
2533
|
const node = this.resolveNode(path, "readdir", true);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
1
|
+
import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
2
2
|
import type { ByteSource } from "../../contracts/io.js";
|
|
3
3
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
4
4
|
export interface MountFileSystemOptions {
|
|
@@ -40,6 +40,7 @@ export declare class MountFileSystem implements FileSystem {
|
|
|
40
40
|
stat(path: string, options?: FsOptions): Promise<FileStat>;
|
|
41
41
|
lstat(path: string, options?: FsOptions): Promise<FileStat>;
|
|
42
42
|
readdir(path: string, options?: ReadDirectoryOptions): Promise<DirectoryEntry[]>;
|
|
43
|
+
iterateDirectory(path: string, options?: FsOptions): AsyncIterable<DirectoryEntry>;
|
|
43
44
|
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
44
45
|
rmdir(path: string, options?: FsOptions): Promise<void>;
|
|
45
46
|
unlink(path: string, options?: FsOptions): Promise<void>;
|
|
@@ -55,6 +56,7 @@ export declare class MountFileSystem implements FileSystem {
|
|
|
55
56
|
removeTreeConditional(path: string, options: ConditionalRemoveEntryOptions): Promise<void>;
|
|
56
57
|
createStagedFile(path: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
57
58
|
publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
|
|
59
|
+
publishStagedFileSet(staging: FileStaging, destination: string, supplied: PublishStagedFileSetOptions): Promise<FileStat>;
|
|
58
60
|
removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
|
|
59
61
|
prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
|
|
60
62
|
rename(source: string, destination: string, options?: RenameOptions): Promise<void>;
|
|
@@ -219,7 +219,7 @@ export class MountFileSystem {
|
|
|
219
219
|
&& await this.supportsDirectoryValidation(location.path.slice(0, location.path.lastIndexOf("/")) || "/", options, true)
|
|
220
220
|
: this.capabilities.conditionalChmod;
|
|
221
221
|
const { synchronousDirectoryValidation: ignoredValidation, synchronousStagingResolution: ignoredResolution, ...ordinary } = resize;
|
|
222
|
-
const withOpen = { ...ordinary,
|
|
222
|
+
const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
|
|
223
223
|
const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
|
|
224
224
|
: retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
|
|
225
225
|
if (location.synthetic)
|
|
@@ -483,7 +483,7 @@ export class MountFileSystem {
|
|
|
483
483
|
release = await this.acquireNamespace(options, [
|
|
484
484
|
"rename", "symlink", "link", "unlink", "rm", "rmdir",
|
|
485
485
|
"removeEntryConditional", "removeFileConditional", "removeTreeConditional",
|
|
486
|
-
"publishStagedFile", "removeStagedFile",
|
|
486
|
+
"publishStagedFile", "publishStagedFileSet", "removeStagedFile",
|
|
487
487
|
].includes(syscall));
|
|
488
488
|
const result = await action();
|
|
489
489
|
if (!preserveReceipt)
|
|
@@ -802,6 +802,76 @@ export class MountFileSystem {
|
|
|
802
802
|
return [...entries.values()].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
803
803
|
});
|
|
804
804
|
}
|
|
805
|
+
async *iterateDirectory(path, options = {}) {
|
|
806
|
+
try {
|
|
807
|
+
const location = await this.operation("iterateDirectory", path, options, async () => {
|
|
808
|
+
const resolved = await this.resolve(path, options);
|
|
809
|
+
if (resolved.stat?.type !== "directory")
|
|
810
|
+
fail("ENOTDIR");
|
|
811
|
+
return resolved;
|
|
812
|
+
});
|
|
813
|
+
// Only configured mount children are retained, never backend entries.
|
|
814
|
+
const mounted = new Set();
|
|
815
|
+
for (const mount of this.mounts) {
|
|
816
|
+
if (mount.path !== location.path && within(location.path, mount.path)) {
|
|
817
|
+
const suffix = mount.path.slice(location.path === "/" ? 1 : location.path.length + 1);
|
|
818
|
+
mounted.add(suffix.split("/")[0]);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const admit = async () => {
|
|
822
|
+
const current = await this.resolve(path, options);
|
|
823
|
+
if (current.path !== location.path || current.mount !== location.mount ||
|
|
824
|
+
current.local !== location.local || current.synthetic !== location.synthetic ||
|
|
825
|
+
current.stat?.type !== "directory")
|
|
826
|
+
fail("EBUSY");
|
|
827
|
+
};
|
|
828
|
+
if (!location.synthetic) {
|
|
829
|
+
const iterator = await this.operation("iterateDirectory", path, options, async () => {
|
|
830
|
+
await admit();
|
|
831
|
+
const backend = location.mount.backend;
|
|
832
|
+
if (!backend.iterateDirectory)
|
|
833
|
+
fail("ENOTSUP");
|
|
834
|
+
return backend.iterateDirectory(location.local, options)[Symbol.asyncIterator]();
|
|
835
|
+
}, undefined, true); // Transfer ownership before checking cancellation on the first pull.
|
|
836
|
+
// Admission covers each backend pull, but never a consumer pause. In
|
|
837
|
+
// particular, caller-backed traversal may publish scratch files between pulls.
|
|
838
|
+
let complete = false, failed = false;
|
|
839
|
+
try {
|
|
840
|
+
while (true) {
|
|
841
|
+
const result = await this.operation("iterateDirectory", path, options, async () => {
|
|
842
|
+
await admit();
|
|
843
|
+
return iterator.next();
|
|
844
|
+
});
|
|
845
|
+
if (result.done) {
|
|
846
|
+
complete = true;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
const { name, type } = result.value;
|
|
850
|
+
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\0"))
|
|
851
|
+
fail("EIO");
|
|
852
|
+
if (!mounted.has(name))
|
|
853
|
+
yield { name, type };
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
failed = true;
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
if (!complete)
|
|
862
|
+
await finishCleanup(() => iterator.return?.(), failed);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
for (const name of mounted) {
|
|
866
|
+
await this.operation("iterateDirectory", path, options, admit);
|
|
867
|
+
yield { name, type: "directory" };
|
|
868
|
+
}
|
|
869
|
+
options.signal?.throwIfAborted();
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
throw this.error(error, "iterateDirectory", path, options);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
805
875
|
mkdir(path, options = {}) {
|
|
806
876
|
return this.operation("mkdir", path, options, async () => {
|
|
807
877
|
const directories = new Map();
|
|
@@ -973,7 +1043,11 @@ export class MountFileSystem {
|
|
|
973
1043
|
return Object.freeze(staging);
|
|
974
1044
|
const backendCleanup = receipt.cleanup;
|
|
975
1045
|
const cleanup = createStagingCleanup(staging.directory.path, controls => this.operation("removeStagedFile", staging.directory.path, controls, () => backendCleanup.remove(controls), undefined, true), () => backendCleanup.close());
|
|
976
|
-
|
|
1046
|
+
const writer = receipt.writer;
|
|
1047
|
+
return Object.freeze({ ...staging, cleanup, ...(writer ? { writer: Object.freeze({
|
|
1048
|
+
write: (bytes, controls = {}) => this.operation("stagedWrite", staging.file.path, controls, () => writer.write(bytes, controls), undefined, true),
|
|
1049
|
+
finish: (controls = {}) => this.operation("stagedFinish", staging.file.path, controls, async () => snapshotStat(await writer.finish(controls)), undefined, true),
|
|
1050
|
+
}) } : {}) });
|
|
977
1051
|
}
|
|
978
1052
|
catch (error) {
|
|
979
1053
|
await finishCleanup(() => receipt.cleanup?.close(), true);
|
|
@@ -1065,6 +1139,83 @@ export class MountFileSystem {
|
|
|
1065
1139
|
});
|
|
1066
1140
|
}, destination, true);
|
|
1067
1141
|
}
|
|
1142
|
+
async publishStagedFileSet(staging, destination, supplied) {
|
|
1143
|
+
const entry = (value) => ({ path: value.path, stat: snapshotStat(value.stat) });
|
|
1144
|
+
staging = { parent: entry(staging.parent), directory: entry(staging.directory), file: entry(staging.file) };
|
|
1145
|
+
const options = {
|
|
1146
|
+
...supplied, parent: snapshotStat(supplied.parent),
|
|
1147
|
+
destination: supplied.destination === null ? null : snapshotStat(supplied.destination),
|
|
1148
|
+
companions: supplied.companions.map(value => ({ ...value, expected: value.expected === null ? null : snapshotStat(value.expected) })),
|
|
1149
|
+
...(supplied.ancestors === undefined ? {} : { ancestors: snapshotDirectoryAncestry(supplied.ancestors) }),
|
|
1150
|
+
};
|
|
1151
|
+
return this.operation("publishStagedFileSet", staging.file.path, options, async () => {
|
|
1152
|
+
validatePath(destination);
|
|
1153
|
+
if (normalizePath(globalPath(destination)) !== destination || destination === "/")
|
|
1154
|
+
fail("EINVAL");
|
|
1155
|
+
const parent = destination.slice(0, destination.lastIndexOf("/")) || "/";
|
|
1156
|
+
const paths = new Set([destination]);
|
|
1157
|
+
for (const companion of options.companions) {
|
|
1158
|
+
validatePath(companion.path);
|
|
1159
|
+
if (normalizePath(globalPath(companion.path)) !== companion.path || paths.has(companion.path) ||
|
|
1160
|
+
(companion.path.slice(0, companion.path.lastIndexOf("/")) || "/") !== parent)
|
|
1161
|
+
fail("EINVAL");
|
|
1162
|
+
paths.add(companion.path);
|
|
1163
|
+
}
|
|
1164
|
+
const ancestors = options.ancestors;
|
|
1165
|
+
if (ancestors && ancestors.at(-1)?.path !== parent)
|
|
1166
|
+
fail("EINVAL");
|
|
1167
|
+
const guard = ancestors ? await this.prepareAncestry(ancestors, options) : undefined;
|
|
1168
|
+
const local = await this.localStaging(staging, options);
|
|
1169
|
+
const target = await this.resolve(destination, options, { followFinal: false, entry: true, allowMissing: true });
|
|
1170
|
+
if (this.protected(target.path))
|
|
1171
|
+
fail("EBUSY");
|
|
1172
|
+
if (local.mount !== target.mount)
|
|
1173
|
+
fail("EXDEV");
|
|
1174
|
+
if (ancestors && target.path !== destination)
|
|
1175
|
+
fail("EAGAIN");
|
|
1176
|
+
this.mutable(target);
|
|
1177
|
+
const backend = local.mount.backend;
|
|
1178
|
+
if (!backend.publishStagedFileSet)
|
|
1179
|
+
fail("ENOTSUP");
|
|
1180
|
+
await requireOwnedMutation(backend, local.staging.directory.path, "atomicFileStaging", options);
|
|
1181
|
+
await requireOwnedMutation(backend, target.local, "atomicFileStaging", options, options.destination === null);
|
|
1182
|
+
const companions = [];
|
|
1183
|
+
for (const companion of options.companions) {
|
|
1184
|
+
const resolved = await this.resolve(companion.path, options, { followFinal: false, entry: true, allowMissing: true });
|
|
1185
|
+
if (this.protected(resolved.path))
|
|
1186
|
+
fail("EBUSY");
|
|
1187
|
+
if (resolved.mount !== target.mount)
|
|
1188
|
+
fail("EXDEV");
|
|
1189
|
+
this.mutable(resolved);
|
|
1190
|
+
companions.push({ ...companion, path: resolved.local });
|
|
1191
|
+
}
|
|
1192
|
+
let localAncestors;
|
|
1193
|
+
if (ancestors) {
|
|
1194
|
+
const first = ancestors.findIndex(value => value.path === target.mount.path);
|
|
1195
|
+
if (first < 0)
|
|
1196
|
+
fail("EINVAL");
|
|
1197
|
+
localAncestors = ancestors.slice(first).map(value => ({
|
|
1198
|
+
path: target.mount.path === "/" ? value.path : value.path.slice(target.mount.path.length) || "/", stat: value.stat,
|
|
1199
|
+
}));
|
|
1200
|
+
}
|
|
1201
|
+
const callerGuard = options.commitGuard;
|
|
1202
|
+
if (guard || callerGuard !== undefined)
|
|
1203
|
+
await requireOwnedMutation(backend, target.local, "guardedStagingPublication", options, options.destination === null);
|
|
1204
|
+
// One backend performs all version checks and the indivisible commit.
|
|
1205
|
+
// The namespace lock covers admission through its committed receipt.
|
|
1206
|
+
return snapshotStat(await backend.publishStagedFileSet(local.staging, target.local, {
|
|
1207
|
+
...options, companions,
|
|
1208
|
+
...(localAncestors === undefined ? {} : { ancestors: localAncestors }),
|
|
1209
|
+
...(guard || callerGuard !== undefined ? { commitGuard: () => {
|
|
1210
|
+
if (callerGuard !== undefined)
|
|
1211
|
+
runStagingGuard(callerGuard);
|
|
1212
|
+
if (guard)
|
|
1213
|
+
runStagingGuard(guard);
|
|
1214
|
+
return true;
|
|
1215
|
+
} } : {}),
|
|
1216
|
+
}));
|
|
1217
|
+
}, destination, true);
|
|
1218
|
+
}
|
|
1068
1219
|
removeStagedFile(staging, options = {}) {
|
|
1069
1220
|
return this.operation("removeStagedFile", staging.directory.path, options, async () => {
|
|
1070
1221
|
const local = await this.localStaging(staging, options);
|
|
@@ -64,6 +64,7 @@ export declare class OverlayFileSystem implements FileSystem {
|
|
|
64
64
|
lstat(path: string, options?: FsOptions): Promise<FileStat>;
|
|
65
65
|
compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<import("../../contracts/filesystem.js").EntryComparison>;
|
|
66
66
|
readdir(path: string, options?: ReadDirectoryOptions): Promise<DirectoryEntry[]>;
|
|
67
|
+
iterateDirectory(path: string, options?: FsOptions): AsyncIterable<DirectoryEntry>;
|
|
67
68
|
realpath(path: string, options?: FsOptions): Promise<string>;
|
|
68
69
|
access(path: string, requestedMode?: number, options?: FsOptions): Promise<void>;
|
|
69
70
|
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
@@ -82,6 +83,7 @@ export declare class OverlayFileSystem implements FileSystem {
|
|
|
82
83
|
readStream(path: string, options?: ReadStreamOptions): ByteSource;
|
|
83
84
|
private bounded;
|
|
84
85
|
private streamToUpper;
|
|
86
|
+
private entryBytes;
|
|
85
87
|
writeStream(path: string, source: ByteSource, options?: WriteFileOptions): Promise<void>;
|
|
86
88
|
}
|
|
87
89
|
export declare function createOverlayFileSystem(options: OverlayFileSystemOptions): OverlayFileSystem;
|