@poe-platform/safe-fs 0.1.807 → 0.1.809

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.
Files changed (41) hide show
  1. package/README.md +28 -1
  2. package/dist/safe-fs/contracts/filesystem.d.ts +20 -1
  3. package/dist/safe-fs/core.d.ts +3 -1
  4. package/dist/safe-fs/core.js +3 -1
  5. package/dist/safe-fs/fs/capabilities.d.ts +1 -1
  6. package/dist/safe-fs/fs/capabilities.js +9 -5
  7. package/dist/safe-fs/fs/devices/index.d.ts +3 -2
  8. package/dist/safe-fs/fs/devices/index.js +53 -9
  9. package/dist/safe-fs/fs/memory/atomic-view.d.ts +1 -0
  10. package/dist/safe-fs/fs/memory/index.d.ts +6 -3
  11. package/dist/safe-fs/fs/memory/index.js +101 -13
  12. package/dist/safe-fs/fs/mount/index.d.ts +4 -2
  13. package/dist/safe-fs/fs/mount/index.js +169 -10
  14. package/dist/safe-fs/fs/overlay/index.d.ts +3 -0
  15. package/dist/safe-fs/fs/overlay/index.js +177 -59
  16. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +1 -0
  17. package/dist/safe-fs/fs/overlay/memory-publication.js +25 -0
  18. package/dist/safe-fs/fs/quota/index.js +9 -4
  19. package/dist/safe-fs/fs/read-file-stream.d.ts +7 -0
  20. package/dist/safe-fs/fs/read-file-stream.js +103 -0
  21. package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
  22. package/dist/safe-fs/fs/readonly/index.js +10 -0
  23. package/dist/safe-fs/fs/real/index.d.ts +1 -0
  24. package/dist/safe-fs/fs/real/index.js +29 -0
  25. package/dist/safe-fs/fs/s3/filesystem.d.ts +2 -1
  26. package/dist/safe-fs/fs/s3/filesystem.js +55 -16
  27. package/dist/safe-fs/fs/scoped.js +13 -5
  28. package/dist/safe-fs/index.d.ts +1 -0
  29. package/dist/safe-fs/index.js +1 -0
  30. package/dist/safe-fs/runtime-core.d.ts +1 -0
  31. package/dist/safe-fs/runtime-core.js +1 -0
  32. package/dist/safe-fs/storage.d.ts +57 -0
  33. package/dist/safe-fs/storage.js +364 -0
  34. package/dist/safe-fs/testing/object-io-metrics.js +1 -0
  35. package/dist/xml-ast/errors.d.ts +4 -0
  36. package/dist/xml-ast/errors.js +7 -0
  37. package/dist/xml-ast/index.d.ts +2 -4
  38. package/dist/xml-ast/index.js +3 -7
  39. package/dist/xml-ast/stream.d.ts +31 -0
  40. package/dist/xml-ast/stream.js +179 -0
  41. package/package.json +10 -6
package/README.md CHANGED
@@ -4,6 +4,17 @@ 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
+ `IntegerTable.entries()` replays records in unsigned key order with a fixed
14
+ 16-level node stack and batches of at most 128 records. It accepts zero-filled
15
+ caller storage with `allocate`, `read` and `write`; mutation during traversal
16
+ is rejected. Branch pointers use a separate fixed 128-entry cache.
17
+
7
18
  ## Quickstart
8
19
 
9
20
  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 +90,11 @@ cause. Preserve cancellation/control exceptions separately. Cross-family
79
90
  | Change the namespace | `mkdir`, `rm`, `rename`, `copyFile` |
80
91
  | Remove an empty directory | Optional `rmdir`; never a recursive-delete fallback |
81
92
  | Links and metadata | Optional `readlink`, `symlink`, `link`, `chmod`, `utimes`, `truncate` |
93
+ | 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
94
  | Stream bytes | Optional `readStream`, `writeStream`, using async iterables of byte chunks |
83
95
  | 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
96
  | Publish immutable objects atomically | Optional `publishFileConditional` with opaque identity/version stats and authoritative compare-and-publish |
97
+ | 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
98
  | Compare backing entries | Optional `compareEntry`, returning `same`, `distinct`, or `unknown` |
86
99
 
87
100
  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`.
@@ -124,7 +137,11 @@ For temporary staging that must be cleaned up after its parent moves, adapters
124
137
  advertising `retainedStagingCleanup` accept `createStagedFile(..., { parent,
125
138
  retainCleanup: true })`. Use the returned `staging.cleanup.remove()` and always
126
139
  call `staging.cleanup.close()` in `finally`. Memory and its supported wrappers
127
- retain only the owned staging entries; replacement entries remain protected.
140
+ retain only the owned staging entries; replacement entries remain protected. When
141
+ `atomicStagedFileMutation` is available, `publishStagedFile(..., { preserveIdentity: true,
142
+ parent, destination })` updates an existing file without breaking hardlink aliases.
143
+ Memory and its supported wrappers adopt staged bytes and timestamps while keeping
144
+ the destination identity and mode; unsupported adapters reject this option.
128
145
  Overlays backed by stock Memory filesystems support conditional file mutations,
129
146
  directory preparation, and confined staging. Publication may omit `ancestors`
130
147
  when the destination shares the staging parent; the overlay checks the ancestry
@@ -295,6 +312,16 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
295
312
 
296
313
  `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
314
 
315
+ 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.
316
+
317
+ 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.
318
+
319
+ 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`.
320
+
321
+ 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.
322
+
323
+ `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.
324
+
298
325
  `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
326
 
300
327
  <details>
@@ -76,6 +76,8 @@ export interface FileSystemCapabilities {
76
76
  /** Owned staging serialized within a trusted host; requires external tree isolation. */
77
77
  readonly trustedOwnedStaging?: boolean;
78
78
  readonly atomicFileStaging?: boolean;
79
+ /** Staged payload publication can atomically preserve an existing inode. */
80
+ readonly atomicStagedFileMutation?: boolean;
79
81
  /** Creates a cleanup handle bound to owned staging entries, independent of ancestor paths. */
80
82
  readonly retainedStagingCleanup?: boolean;
81
83
  readonly retainedStagingWrite?: boolean;
@@ -85,6 +87,8 @@ export interface FileSystemCapabilities {
85
87
  readonly synchronousDirectoryValidation?: boolean;
86
88
  /** Captures a followed destination path with a synchronous commit validator. */
87
89
  readonly synchronousStagingResolution?: boolean;
90
+ /** Also binds the final symlink and its target in the synchronous validator. */
91
+ readonly synchronousFollowedStagingResolution?: boolean;
88
92
  /** Invokes commitGuard in the same synchronous section as staged replacement. */
89
93
  readonly guardedStagingPublication?: boolean;
90
94
  readonly atomicFilePublication?: boolean;
@@ -127,6 +131,7 @@ export interface CapabilityQueryOptions extends OpenReadFileOptions {
127
131
  * Omission retains the ordinary per-target query and its acquisition intent. */
128
132
  readonly stagingAncestry?: boolean;
129
133
  readonly stagingResolution?: boolean;
134
+ readonly followFinalSymlink?: boolean;
130
135
  readonly create?: boolean;
131
136
  readonly creation?: OpenFileOptions["creation"];
132
137
  }
@@ -231,6 +236,10 @@ export interface FileStagingEntry {
231
236
  export interface FileResolutionStep extends FileStagingEntry {
232
237
  readonly linkTarget?: string;
233
238
  }
239
+ export interface PrepareStagingResolutionOptions extends FsOptions {
240
+ /** Requires synchronousFollowedStagingResolution; omission preserves final-entry resolution. */
241
+ readonly followFinalSymlink?: boolean;
242
+ }
234
243
  export interface FileStagingResolution {
235
244
  readonly path: string;
236
245
  readonly parent: FileStat;
@@ -277,6 +286,11 @@ export interface CreateStagedFileOptions extends FsOptions {
277
286
  readonly mtimeMs?: number;
278
287
  }
279
288
  export interface PublishStagedFileOptions extends FsOptions {
289
+ /** Requires atomicStagedFileMutation and an existing regular destination.
290
+ * Atomically adopts staged bytes and timestamps while preserving destination
291
+ * identity, links and mode. Consumes the staged pathname as ordinary publication.
292
+ * Unsupported hosts must reject before effects, never silently rename. */
293
+ readonly preserveIdentity?: boolean;
280
294
  readonly ancestors?: readonly FileStagingEntry[];
281
295
  /** Trusted, mutation-free validation. Must return literal true synchronously;
282
296
  * a promise is not acceptance. Requires guardedStagingPublication. */
@@ -337,7 +351,7 @@ export interface FileSystem {
337
351
  * The returned guard checks current identities/search access synchronously;
338
352
  * preparation is not validation, and the guard's result is not an async lease. */
339
353
  prepareDirectoryAncestry?(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
340
- prepareStagingResolution?(path: string, options?: FsOptions): Promise<FileStagingResolution>;
354
+ prepareStagingResolution?(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
341
355
  removeStagedFile?(staging: FileStaging, options?: FsOptions): Promise<void>;
342
356
  openReadFile?(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
343
357
  openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
@@ -352,6 +366,11 @@ export interface FileSystem {
352
366
  lstat(path: string, options?: FsOptions): Promise<FileStat>;
353
367
  compareEntry?(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
354
368
  readdir(path: string, options?: ReadDirectoryOptions): Promise<DirectoryEntry[]>;
369
+ /** Optional bounded, lazy directory enumeration in backend-defined order.
370
+ * No complete listing/sort fallback. Consumers must retire the iterator on
371
+ * early exit. Namespace changes may reject or follow the backend's live view;
372
+ * this is not a tree snapshot or a retained path-authority grant. */
373
+ iterateDirectory?(path: string, options?: FsOptions): AsyncIterable<DirectoryEntry>;
355
374
  mkdir(path: string, options?: MkdirOptions): Promise<void>;
356
375
  rm(path: string, options?: RemoveOptions): Promise<void>;
357
376
  /** Atomic final-entry removal; every directory is refused, including raced replacements. */
@@ -8,7 +8,7 @@ export * from "./fs/object-publication/index.js";
8
8
  export * from "./fs/webdav/index.js";
9
9
  export * from "./bridge/index.js";
10
10
  export * from "./python/index.js";
11
- export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
11
+ export { parseXml, parseXmlSteps, parseXmlStream, XmlLimitError } from "./xml.js";
12
12
  export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits } from "./xml.js";
13
13
  export * from "./contracts/object.js";
14
14
  export { ObjectAuthority } from "./fs/object-authority.js";
@@ -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";
@@ -8,10 +8,12 @@ export * from "./fs/object-publication/index.js";
8
8
  export * from "./fs/webdav/index.js";
9
9
  export * from "./bridge/index.js";
10
10
  export * from "./python/index.js";
11
- export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
11
+ export { parseXml, parseXmlSteps, parseXmlStream, XmlLimitError } from "./xml.js";
12
12
  export * from "./contracts/object.js";
13
13
  export { ObjectAuthority } from "./fs/object-authority.js";
14
14
  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";
@@ -7,4 +7,4 @@ export declare function openRetainedResizeFile(filesystem: FileSystem, path: str
7
7
  export declare function readOnlyCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
8
8
  export declare function quotaCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
9
9
  export declare function ownedMutationCapabilities(filesystem: FileSystem, capabilities?: FileSystemCapabilities): FileSystemCapabilities;
10
- export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "retainedStagingCleanup" | "atomicFilePublication" | "atomicEntryRemoval" | "atomicEntryRemovalReceipt" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "guardedStagingPublication" | "atomicStagingAncestry" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
10
+ export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "retainedStagingCleanup" | "atomicFilePublication" | "atomicEntryRemoval" | "atomicEntryRemovalReceipt" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "atomicStagedFileMutation" | "guardedStagingPublication" | "atomicStagingAncestry" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
@@ -132,19 +132,19 @@ export function readOnlyCapabilities(capabilities) {
132
132
  ].filter(name => capabilities[name] !== undefined).map(name => [name, capabilities[name]]));
133
133
  return Object.freeze({
134
134
  ...inspection, retainedStagingCleanup: false, retainedStagingWrite: false, readOnly: true, write: false, append: false, exclusiveCreate: false,
135
- synchronousStagingResolution: false,
135
+ synchronousStagingResolution: false, synchronousFollowedStagingResolution: false,
136
136
  mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
137
137
  rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
138
138
  randomAccessWrite: false, hardlinks: false, permissions: false, conditionalChmod: false, timestamps: false,
139
- descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
139
+ descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
140
140
  atomicFilePublication: false, atomicRename: false, atomicRenameNoReplace: false, streamingWrite: false,
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
- 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,
147
+ return Object.freeze({ ...rest, synchronousStagingResolution: false, synchronousFollowedStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
148
148
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
149
149
  ...(streamingAppend === undefined ? {} : { streamingAppend }),
150
150
  });
@@ -167,6 +167,8 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
167
167
  if (capabilities.atomicFileStaging === true && (capabilities.readOnly === true
168
168
  || typeof filesystem.createStagedFile !== "function" || typeof filesystem.publishStagedFile !== "function" || typeof filesystem.removeStagedFile !== "function"))
169
169
  (unavailable ??= {}).atomicFileStaging = false;
170
+ if (capabilities.atomicStagedFileMutation === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
171
+ (unavailable ??= {}).atomicStagedFileMutation = false;
170
172
  if (capabilities.retainedStagingCleanup === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
171
173
  (unavailable ??= {}).retainedStagingCleanup = false;
172
174
  if (capabilities.retainedStagingWrite === true && (capabilities.retainedStagingCleanup !== true || unavailable?.retainedStagingCleanup === false))
@@ -177,6 +179,8 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
177
179
  (unavailable ??= {}).synchronousDirectoryValidation = false;
178
180
  if (capabilities.synchronousStagingResolution === true && (capabilities.readOnly === true || typeof filesystem.prepareStagingResolution !== "function"))
179
181
  (unavailable ??= {}).synchronousStagingResolution = false;
182
+ if (capabilities.synchronousFollowedStagingResolution === true && (capabilities.synchronousStagingResolution !== true || unavailable?.synchronousStagingResolution === false))
183
+ (unavailable ??= {}).synchronousFollowedStagingResolution = false;
180
184
  if (capabilities.guardedStagingPublication === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
181
185
  (unavailable ??= {}).guardedStagingPublication = false;
182
186
  if (capabilities.atomicDirectoryMetadata === true && (capabilities.readOnly === true || typeof filesystem.prepareDirectory !== "function"))
@@ -1,5 +1,5 @@
1
1
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
2
- import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, 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
+ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
3
3
  import type { ByteSource } from "../../contracts/io.js";
4
4
  import { type EntryLocation } from "../mount/comparison.js";
5
5
  import { pathNamespace } from "../path-namespace.js";
@@ -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>;
@@ -42,7 +43,7 @@ export declare class DeviceFileSystem implements FileSystem {
42
43
  publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
43
44
  publishStagedFileSet(receipt: FileStaging, destination: string, supplied: PublishStagedFileSetOptions): Promise<FileStat>;
44
45
  prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
45
- prepareStagingResolution(path: string, options?: FsOptions): Promise<FileStagingResolution>;
46
+ prepareStagingResolution(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
46
47
  removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
47
48
  prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
48
49
  rename(source: string, destination: string, options?: RenameOptions): Promise<void>;
@@ -13,14 +13,14 @@ import { openFileDescriptor } from "../descriptor.js";
13
13
  import { normalizePath } from "../../contracts/virtual-path.js";
14
14
  const views = new WeakMap();
15
15
  const deviceCapabilities = Object.freeze({
16
- synchronousStagingResolution: false,
16
+ synchronousStagingResolution: false, synchronousFollowedStagingResolution: false,
17
17
  readOnly: false, read: true, stat: true, realpath: true, access: true, readdir: false,
18
18
  write: true, append: true, exclusiveCreate: true, streamingRead: true, retainedRead: true,
19
19
  streamingWrite: true, streamingAppend: true, independentWriteStreams: true, copy: true, exclusiveCopy: true,
20
20
  remove: false, removeDirectory: false, recursiveRemove: false, rename: false,
21
21
  mkdir: false, recursiveMkdir: false, symlinks: false, hardlinks: false, readlink: false,
22
22
  permissions: false, timestamps: false, truncate: false, randomAccessWrite: false,
23
- open: true, retainedStagingCleanup: false, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
23
+ open: true, retainedStagingCleanup: false, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
24
24
  atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, atomicResize: false,
25
25
  });
26
26
  function globalCapabilities(filesystem) {
@@ -31,6 +31,7 @@ function globalCapabilities(filesystem) {
31
31
  synchronousDirectoryValidation: ["prepareDirectoryAncestry"], guardedStagingPublication: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
32
32
  synchronousStagingResolution: ["prepareStagingResolution"],
33
33
  trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
34
+ atomicStagedFileMutation: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
34
35
  atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicEntryRemovalReceipt: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
35
36
  streamingRead: ["readStream"], streamingWrite: ["writeStream"], retainedRead: ["openReadFile"],
36
37
  streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
@@ -178,7 +179,7 @@ export class DeviceFileSystem {
178
179
  let selected;
179
180
  const traversal = options.stagingResolution === true ? { virtual: false } : undefined;
180
181
  try {
181
- resolved = await this.#resolve(path, options, options.stagingResolution !== true && (options.create !== undefined || options.creation !== "exclusive"), options.create, traversal);
182
+ resolved = await this.#resolve(path, options, options.stagingResolution === true ? options.followFinalSymlink === true : options.create !== undefined || options.creation !== "exclusive", options.create, traversal);
182
183
  }
183
184
  catch (error) {
184
185
  options.signal?.throwIfAborted();
@@ -209,7 +210,7 @@ export class DeviceFileSystem {
209
210
  // This intent cannot bind virtual traversal. Do not infer other path
210
211
  // capabilities by resolving the shadowed entry in the backing filesystem.
211
212
  if (traversal?.virtual)
212
- return Object.freeze({ synchronousStagingResolution: false });
213
+ return Object.freeze({ synchronousStagingResolution: false, synchronousFollowedStagingResolution: false });
213
214
  if (selected === undefined) {
214
215
  const query = this.#filesystem.capabilitiesFor;
215
216
  options.signal?.throwIfAborted();
@@ -228,6 +229,7 @@ export class DeviceFileSystem {
228
229
  throw new FsError("EAGAIN", { path });
229
230
  if ([resolution.path, ...resolution.traversed.map(entry => entry.path)].some(entry => entry === deviceDirectory || entry.startsWith(`${deviceDirectory}/`))) {
230
231
  (unavailable ??= {}).synchronousStagingResolution = false;
232
+ unavailable.synchronousFollowedStagingResolution = false;
231
233
  }
232
234
  runStagingGuard(resolution.validate);
233
235
  }
@@ -236,6 +238,7 @@ export class DeviceFileSystem {
236
238
  if (!isFsError(error, "ENOTSUP"))
237
239
  throw error;
238
240
  (unavailable ??= {}).synchronousStagingResolution = false;
241
+ unavailable.synchronousFollowedStagingResolution = false;
239
242
  }
240
243
  }
241
244
  if (resolved.startsWith(`${deviceDirectory}/`)) {
@@ -243,6 +246,7 @@ export class DeviceFileSystem {
243
246
  u.atomicStagingAncestry = false;
244
247
  u.synchronousDirectoryValidation = false;
245
248
  u.synchronousStagingResolution = false;
249
+ u.synchronousFollowedStagingResolution = false;
246
250
  }
247
251
  if (typeof this.#filesystem.open !== "function" && capabilities.open !== false)
248
252
  (unavailable ??= {}).open = false;
@@ -490,6 +494,40 @@ export class DeviceFileSystem {
490
494
  throw new FsError("ENOTSUP", { path });
491
495
  await this.#filesystem.writeStream(path, source, options);
492
496
  }
497
+ async *iterateDirectory(path, options = {}) {
498
+ const resolved = await this.#resolve(path, options);
499
+ if (resolved === nullPath)
500
+ throw new FsError("ENOTDIR", { syscall: "iterateDirectory", path });
501
+ const virtual = resolved === "/" ? "dev" : resolved === deviceDirectory ? "null" : undefined;
502
+ if (!this.#filesystem.iterateDirectory)
503
+ throw new FsError("ENOTSUP", { syscall: "iterateDirectory", path });
504
+ let seen = false;
505
+ try {
506
+ for await (const entry of this.#filesystem.iterateDirectory(virtual ? resolved : path, options)) {
507
+ options.signal?.throwIfAborted();
508
+ if (entry.name === "." || entry.name === "..")
509
+ continue;
510
+ if (!entry.name || entry.name.includes("/") || entry.name.includes("\0"))
511
+ throw new FsError("EIO", { path, message: "invalid directory entry" });
512
+ if (entry.name === virtual) {
513
+ if (seen)
514
+ continue;
515
+ seen = true;
516
+ yield { name: virtual, type: virtual === "dev" ? "directory" : "character" };
517
+ }
518
+ else
519
+ yield { name: entry.name, type: entry.type };
520
+ }
521
+ }
522
+ catch (error) {
523
+ options.signal?.throwIfAborted();
524
+ if (!virtual || !isFsError(error, "ENOENT") && !isFsError(error, "ENOTDIR"))
525
+ throw error;
526
+ }
527
+ options.signal?.throwIfAborted();
528
+ if (virtual && !seen)
529
+ yield { name: virtual, type: virtual === "dev" ? "directory" : "character" };
530
+ }
493
531
  readdir(path, options = {}) {
494
532
  const fast = this.#tryResolveSync(path, options);
495
533
  if (fast !== undefined && fast !== "/") {
@@ -633,6 +671,8 @@ export class DeviceFileSystem {
633
671
  await this.#mutable(path, options, false);
634
672
  await requireOwnedMutation(this.#filesystem, staging.directory.path, "atomicFileStaging", options);
635
673
  await requireOwnedMutation(this.#filesystem, destination, "atomicFileStaging", options, options.destination === null);
674
+ if (options.preserveIdentity)
675
+ await requireOwnedMutation(this.#filesystem, destination, "atomicStagedFileMutation", options);
636
676
  if (!this.#filesystem.publishStagedFile)
637
677
  throw new FsError("ENOTSUP", { path: destination });
638
678
  if (options.commitGuard !== undefined)
@@ -656,8 +696,12 @@ export class DeviceFileSystem {
656
696
  const capabilities = await this.#filesystem.capabilitiesFor?.(destination, { ...options, create: options.destination === null }) ?? this.#filesystem.capabilities;
657
697
  if (capabilities.readOnly === true)
658
698
  throw new FsError("EROFS", { path: destination });
659
- for (const path of [staging.parent.path, staging.directory.path])
660
- await this.#mutable(path, options, false);
699
+ // The parent is an identity guard, not an entry being replaced. Root is a
700
+ // valid parent; the synthetic device directory still cannot own staging.
701
+ const parentPath = await this.#resolve(staging.parent.path, options, false);
702
+ if (parentPath === deviceDirectory || parentPath === nullPath)
703
+ throw new FsError("EBUSY", { path: staging.parent.path });
704
+ await this.#mutable(staging.directory.path, options, false);
661
705
  const guards = [];
662
706
  if (options.ancestors)
663
707
  guards.push(await this.prepareDirectoryAncestry(options.ancestors, options));
@@ -712,19 +756,19 @@ export class DeviceFileSystem {
712
756
  }
713
757
  }
714
758
  async prepareStagingResolution(path, options = {}) {
715
- const controls = options.signal === undefined ? {} : { signal: options.signal };
759
+ const controls = { ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.followFinalSymlink === true ? { followFinalSymlink: true } : {}) };
716
760
  controls.signal?.throwIfAborted();
717
761
  if (normalizePath(path) !== path || !path.startsWith("/"))
718
762
  throw new FsError("EINVAL", { path });
719
763
  try {
720
764
  const traversal = { virtual: false };
721
- const resolved = await this.#resolve(path, controls, false, undefined, traversal);
765
+ const resolved = await this.#resolve(path, controls, controls.followFinalSymlink === true, undefined, traversal);
722
766
  controls.signal?.throwIfAborted();
723
767
  if (traversal.virtual)
724
768
  throw new FsError("ENOTSUP", { path });
725
769
  const declared = ownedMutationCapabilities(this.#filesystem, await this.#filesystem.capabilitiesFor?.(path, { ...controls, stagingResolution: true }) ?? this.#filesystem.capabilities);
726
770
  controls.signal?.throwIfAborted();
727
- if (declared.synchronousStagingResolution !== true || !this.#filesystem.prepareStagingResolution)
771
+ if (declared.synchronousStagingResolution !== true || controls.followFinalSymlink === true && declared.synchronousFollowedStagingResolution !== true || !this.#filesystem.prepareStagingResolution)
728
772
  throw new FsError("ENOTSUP", { path });
729
773
  const receipt = snapshotStagingResolution(await this.#filesystem.prepareStagingResolution(path, controls));
730
774
  controls.signal?.throwIfAborted();
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
1
+ import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  import { type MemoryAtomicView } from "./atomic-view.js";
4
4
  import { MemoryAllocation, MemoryLedger } from "./ledger.js";
@@ -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;
@@ -114,7 +116,7 @@ export declare class MemoryFileSystem implements FileSystem {
114
116
  createStagedFile(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
115
117
  private retainStagingCleanup;
116
118
  prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
117
- prepareStagingResolution(path: string, options?: FsOptions): Promise<FileStagingResolution>;
119
+ prepareStagingResolution(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
118
120
  private verifyDirectoryAncestry;
119
121
  publishStagedFile(receipt: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
120
122
  publishStagedFileSet(receipt: FileStaging, destination: string, options: PublishStagedFileSetOptions): Promise<FileStat>;
@@ -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;