@poe-platform/safe-fs 0.1.545 → 0.1.547

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.
@@ -11,6 +11,7 @@ export interface FileStat {
11
11
  readonly atimeMs: number;
12
12
  readonly ctimeMs: number;
13
13
  readonly birthtimeMs?: number;
14
+ readonly revision?: number;
14
15
  readonly identityScope?: object | symbol;
15
16
  readonly ino?: number;
16
17
  readonly dev?: number;
@@ -51,6 +52,9 @@ export interface FileSystemCapabilities {
51
52
  readonly permissions?: boolean;
52
53
  readonly timestamps?: boolean;
53
54
  readonly atomicRename?: boolean;
55
+ readonly atomicFileStaging?: boolean;
56
+ readonly atomicFileMutation?: boolean;
57
+ readonly atomicDirectoryMetadata?: boolean;
54
58
  readonly atomicRenameNoReplace?: boolean;
55
59
  readonly snapshotRmdir?: boolean;
56
60
  readonly streamingRead?: boolean;
@@ -141,8 +145,57 @@ export interface ReadStreamOptions extends FsOptions {
141
145
  readonly endExclusive?: number;
142
146
  readonly chunkSize?: number;
143
147
  }
148
+ export interface ConditionalWriteFileOptions extends FsOptions {
149
+ readonly parent: FileStat;
150
+ readonly expected: FileStat | null;
151
+ readonly append?: boolean;
152
+ readonly mode?: number;
153
+ }
154
+ export interface ConditionalRemoveFileOptions extends FsOptions {
155
+ readonly parent: FileStat;
156
+ readonly expected: FileStat;
157
+ }
158
+ export interface FileStagingEntry {
159
+ readonly path: string;
160
+ readonly stat: FileStat;
161
+ }
162
+ export interface FileStaging {
163
+ readonly parent: FileStagingEntry;
164
+ readonly directory: FileStagingEntry;
165
+ readonly file: FileStagingEntry;
166
+ }
167
+ export type StagedFileContent = {
168
+ readonly type: "file";
169
+ readonly data: Uint8Array;
170
+ } | {
171
+ readonly type: "symlink";
172
+ readonly target: string;
173
+ };
174
+ export interface CreateStagedFileOptions extends FsOptions {
175
+ readonly parent: FileStat;
176
+ readonly mode?: number;
177
+ readonly atimeMs?: number;
178
+ readonly mtimeMs?: number;
179
+ }
180
+ export interface PublishStagedFileOptions extends FsOptions {
181
+ readonly parent: FileStat;
182
+ readonly destination: FileStat | null;
183
+ }
184
+ export interface PrepareDirectoryOptions extends FsOptions {
185
+ readonly expected: FileStat | null;
186
+ readonly parent: FileStat;
187
+ readonly mode?: number;
188
+ readonly atimeMs?: number;
189
+ readonly mtimeMs?: number;
190
+ }
144
191
  export interface FileSystem {
192
+ writeFileConditional?(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
193
+ removeFileConditional?(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
145
194
  readonly capabilities: FileSystemCapabilities;
195
+ prepareDirectory?(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
196
+ createStagedFile?(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
197
+ publishStagedFile?(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
198
+ removeStagedFile?(staging: FileStaging, options?: FsOptions): Promise<void>;
146
199
  openReadFile?(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
147
200
  openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
148
201
  /** Missing targets reject ENOENT unless create is true; existing bytes survive refusals. */
@@ -1,4 +1,4 @@
1
- import type { FileReadHandle, FileResizeHandle, FileSystem, FileSystemCapabilities, OpenReadFileOptions, OpenResizeFileOptions } from "../contracts/filesystem.js";
1
+ import type { FsOptions, FileReadHandle, FileResizeHandle, FileSystem, FileSystemCapabilities, OpenReadFileOptions, OpenResizeFileOptions } from "../contracts/filesystem.js";
2
2
  export declare function retainedReadCapabilities(filesystem: FileSystem, capabilities?: FileSystemCapabilities): FileSystemCapabilities;
3
3
  export declare function openRetainedReadFile(filesystem: FileSystem, path: string, options: OpenReadFileOptions): Promise<FileReadHandle>;
4
4
  export declare function requireCapabilities(...values: readonly (boolean | undefined)[]): boolean | undefined;
@@ -6,3 +6,5 @@ export declare function retainedResizeCapabilities(filesystem: FileSystem, capab
6
6
  export declare function openRetainedResizeFile(filesystem: FileSystem, path: string, options: OpenResizeFileOptions): Promise<FileResizeHandle>;
7
7
  export declare function readOnlyCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
8
8
  export declare function quotaCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
9
+ export declare function ownedMutationCapabilities(filesystem: FileSystem, capabilities?: FileSystemCapabilities): FileSystemCapabilities;
10
+ export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "atomicFileMutation" | "atomicFileStaging" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
@@ -108,7 +108,7 @@ export function readOnlyCapabilities(capabilities) {
108
108
  mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
109
109
  rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
110
110
  randomAccessWrite: false, hardlinks: false, permissions: false, timestamps: false,
111
- descriptorWriteStream: false, retainedResize: false, atomicResize: false,
111
+ descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
112
112
  atomicRename: false, atomicRenameNoReplace: false, streamingWrite: false,
113
113
  });
114
114
  }
@@ -116,8 +116,26 @@ export function quotaCapabilities(capabilities) {
116
116
  const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
117
117
  const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
118
118
  const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
119
- return Object.freeze({ ...rest, descriptorWriteStream: false, atomicResize: false,
119
+ return Object.freeze({ ...rest, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
120
120
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
121
121
  ...(streamingAppend === undefined ? {} : { streamingAppend }),
122
122
  });
123
123
  }
124
+ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.capabilities) {
125
+ const unavailable = {};
126
+ if (capabilities.atomicFileMutation === true && (capabilities.readOnly === true || typeof filesystem.writeFileConditional !== "function" || typeof filesystem.removeFileConditional !== "function"))
127
+ unavailable.atomicFileMutation = false;
128
+ if (capabilities.atomicFileStaging === true && (capabilities.readOnly === true
129
+ || typeof filesystem.createStagedFile !== "function" || typeof filesystem.publishStagedFile !== "function" || typeof filesystem.removeStagedFile !== "function"))
130
+ unavailable.atomicFileStaging = false;
131
+ if (capabilities.atomicDirectoryMetadata === true && (capabilities.readOnly === true || typeof filesystem.prepareDirectory !== "function"))
132
+ unavailable.atomicDirectoryMetadata = false;
133
+ return Object.keys(unavailable).length ? { ...capabilities, ...unavailable } : capabilities;
134
+ }
135
+ export async function requireOwnedMutation(filesystem, path, capability, options, create = false) {
136
+ options.signal?.throwIfAborted();
137
+ const capabilities = ownedMutationCapabilities(filesystem, await filesystem.capabilitiesFor?.(path, create ? { ...options, create: true } : options) ?? filesystem.capabilities);
138
+ options.signal?.throwIfAborted();
139
+ if (capabilities[capability] !== true)
140
+ throw new FsError("ENOTSUP", { path, syscall: capability });
141
+ }
@@ -1,4 +1,4 @@
1
- import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
1
+ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  export declare class DeviceFileSystem implements FileSystem {
4
4
  #private;
@@ -22,6 +22,12 @@ export declare class DeviceFileSystem implements FileSystem {
22
22
  mkdir(path: string, options?: MkdirOptions): Promise<void>;
23
23
  rm(path: string, options?: RemoveOptions): Promise<void>;
24
24
  rmdir(path: string, options?: FsOptions): Promise<void>;
25
+ writeFileConditional(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
26
+ removeFileConditional(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
27
+ createStagedFile(path: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
28
+ publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
29
+ removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
30
+ prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
25
31
  rename(source: string, destination: string, options?: RenameOptions): Promise<void>;
26
32
  copyFile(source: string, destination: string, options?: CopyFileOptions): Promise<void>;
27
33
  access(path: string, mode?: number, options?: FsOptions): Promise<void>;
@@ -3,7 +3,7 @@ import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admissi
3
3
  import { compareEntries, registerEntryAuthority, registerEntryView } from "../mount/comparison.js";
4
4
  import { deviceDirectory, lexicalDevicePath, nullPath, resolveDevicePath } from "./path.js";
5
5
  import { deviceReadStream, drainDeviceFile, drainDeviceInput } from "./stream.js";
6
- import { openRetainedReadFile, openRetainedResizeFile, retainedResizeCapabilities } from "../capabilities.js";
6
+ import { openRetainedReadFile, openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabilities, requireOwnedMutation } from "../capabilities.js";
7
7
  import { pathNamespace } from "../path-namespace.js";
8
8
  const views = new WeakMap();
9
9
  const deviceCapabilities = Object.freeze({
@@ -13,11 +13,13 @@ const deviceCapabilities = Object.freeze({
13
13
  remove: false, removeDirectory: false, recursiveRemove: false, rename: false,
14
14
  mkdir: false, recursiveMkdir: false, symlinks: false, hardlinks: false, readlink: false,
15
15
  permissions: false, timestamps: false, truncate: false, randomAccessWrite: false,
16
+ atomicFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
16
17
  atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, atomicResize: false,
17
18
  });
18
19
  function globalCapabilities(filesystem) {
19
20
  const capabilities = { readOnly: false };
20
21
  const optional = {
22
+ atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
21
23
  streamingRead: ["readStream"], streamingWrite: ["writeStream"], retainedRead: ["openReadFile"],
22
24
  streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
23
25
  symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"],
@@ -128,7 +130,7 @@ export class DeviceFileSystem {
128
130
  options.signal?.throwIfAborted();
129
131
  const selected = query === undefined || query === null ? undefined : await Reflect.apply(query, this.#filesystem, [path, options]);
130
132
  options.signal?.throwIfAborted();
131
- const observed = selected ?? this.#filesystem.capabilities;
133
+ const observed = ownedMutationCapabilities(this.#filesystem, selected ?? this.#filesystem.capabilities);
132
134
  options.signal?.throwIfAborted();
133
135
  const capabilities = observed.retainedResize === true ? retainedResizeCapabilities(this.#filesystem, observed) : observed;
134
136
  const unavailable = {};
@@ -351,6 +353,50 @@ export class DeviceFileSystem {
351
353
  throw new FsError("ENOTSUP", { path });
352
354
  await this.#filesystem.rmdir(path, options);
353
355
  }
356
+ async writeFileConditional(path, data, options) {
357
+ await this.#mutable(path, options, false);
358
+ await requireOwnedMutation(this.#filesystem, path, "atomicFileMutation", options, options.expected === null);
359
+ if (!this.#filesystem.writeFileConditional)
360
+ throw new FsError("ENOTSUP", { path });
361
+ return this.#filesystem.writeFileConditional(path, data, options);
362
+ }
363
+ async removeFileConditional(path, options) {
364
+ await this.#mutable(path, options, false);
365
+ await requireOwnedMutation(this.#filesystem, path, "atomicFileMutation", options);
366
+ if (!this.#filesystem.removeFileConditional)
367
+ throw new FsError("ENOTSUP", { path });
368
+ await this.#filesystem.removeFileConditional(path, options);
369
+ }
370
+ async createStagedFile(path, name, content, options) {
371
+ await this.#mutable(path, options, false);
372
+ await requireOwnedMutation(this.#filesystem, path, "atomicFileStaging", options, true);
373
+ if (!this.#filesystem.createStagedFile)
374
+ throw new FsError("ENOTSUP", { path });
375
+ return this.#filesystem.createStagedFile(path, name, content, options);
376
+ }
377
+ async publishStagedFile(staging, destination, options) {
378
+ for (const path of [staging.directory.path, staging.file.path, destination])
379
+ await this.#mutable(path, options, false);
380
+ await requireOwnedMutation(this.#filesystem, staging.directory.path, "atomicFileStaging", options);
381
+ if (!this.#filesystem.publishStagedFile)
382
+ throw new FsError("ENOTSUP", { path: destination });
383
+ await this.#filesystem.publishStagedFile(staging, destination, options);
384
+ }
385
+ async removeStagedFile(staging, options = {}) {
386
+ for (const path of [staging.directory.path, staging.file.path])
387
+ await this.#mutable(path, options, false);
388
+ await requireOwnedMutation(this.#filesystem, staging.directory.path, "atomicFileStaging", options);
389
+ if (!this.#filesystem.removeStagedFile)
390
+ throw new FsError("ENOTSUP", { path: staging.directory.path });
391
+ await this.#filesystem.removeStagedFile(staging, options);
392
+ }
393
+ async prepareDirectory(path, options) {
394
+ await this.#mutable(path, options, false);
395
+ await requireOwnedMutation(this.#filesystem, path, "atomicDirectoryMetadata", options, options.expected === null);
396
+ if (!this.#filesystem.prepareDirectory)
397
+ throw new FsError("ENOTSUP", { path });
398
+ return this.#filesystem.prepareDirectory(path, options);
399
+ }
354
400
  async rename(source, destination, options = {}) {
355
401
  await this.#mutable(source, options, false);
356
402
  await this.#mutable(destination, options, false);
@@ -1,4 +1,4 @@
1
- import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions } from "../../contracts/filesystem.js";
1
+ import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  import { type MemoryFileSystemOptions } from "./limits.js";
4
4
  export { defaultMemoryFileSystemLimits, type MemoryFileSystemLimits, type MemoryFileSystemOptions } from "./limits.js";
@@ -33,6 +33,9 @@ export declare class MemoryFileSystem implements FileSystem {
33
33
  permissions: true;
34
34
  timestamps: true;
35
35
  atomicRename: true;
36
+ atomicFileStaging: true;
37
+ atomicFileMutation: true;
38
+ atomicDirectoryMetadata: true;
36
39
  streamingRead: true;
37
40
  retainedRead: true;
38
41
  readonly retainedResize: boolean;
@@ -70,6 +73,14 @@ export declare class MemoryFileSystem implements FileSystem {
70
73
  private writeData;
71
74
  private writeAt;
72
75
  readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;
76
+ private expectEntry;
77
+ private stagingLocations;
78
+ createStagedFile(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
79
+ publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
80
+ removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
81
+ prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
82
+ writeFileConditional(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
83
+ removeFileConditional(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
73
84
  writeFile(path: string, data: Uint8Array, options?: WriteFileOptions): Promise<void>;
74
85
  appendFile(path: string, data: Uint8Array, options?: AppendFileOptions): Promise<void>;
75
86
  stat(path: string, options?: FsOptions): Promise<FileStat>;
@@ -66,6 +66,8 @@ export class MemoryFileSystem {
66
66
  permissions: true,
67
67
  timestamps: true,
68
68
  atomicRename: true,
69
+ atomicFileStaging: true, atomicFileMutation: true,
70
+ atomicDirectoryMetadata: true,
69
71
  streamingRead: true,
70
72
  retainedRead: true,
71
73
  get retainedResize() { return stockRetainedResize(filesystem); },
@@ -115,7 +117,7 @@ export class MemoryFileSystem {
115
117
  metadata(mode) {
116
118
  const now = Date.now();
117
119
  return {
118
- mode, ino: this.nextInode++, nlink: 1, references: 0,
120
+ mode, ino: this.nextInode++, nlink: 1, references: 0, revision: 0,
119
121
  atimeMs: now, mtimeMs: now, ctimeMs: now, birthtimeMs: now,
120
122
  };
121
123
  }
@@ -178,6 +180,7 @@ export class MemoryFileSystem {
178
180
  this.fail("EINVAL", syscall, path);
179
181
  }
180
182
  changed(node) {
183
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
181
184
  node.mtimeMs = node.ctimeMs = Date.now();
182
185
  }
183
186
  resolve(path, syscall, options = {}) {
@@ -259,6 +262,7 @@ export class MemoryFileSystem {
259
262
  snapshot(node) {
260
263
  return {
261
264
  type: node.type,
265
+ ...(Number.isSafeInteger(node.revision) ? { revision: node.revision } : {}),
262
266
  preferredIoBlockSize: 4096,
263
267
  size: node.type === "file" ? node.data.byteLength
264
268
  : node.type === "symlink" ? new TextEncoder().encode(node.target).byteLength : 0,
@@ -405,6 +409,180 @@ export class MemoryFileSystem {
405
409
  node.atimeMs = Date.now();
406
410
  return new Uint8Array(node.data);
407
411
  }
412
+ expectEntry(node, expected, path, unchanged = true) {
413
+ if (expected === null) {
414
+ if (node)
415
+ this.fail("EAGAIN", "fileStaging", path);
416
+ return;
417
+ }
418
+ if (!node)
419
+ this.fail("EAGAIN", "fileStaging", path);
420
+ const current = this.snapshot(node);
421
+ if (!expected.identityScope || expected.identityScope !== current.identityScope
422
+ || !Number.isSafeInteger(expected.ino) || !Number.isSafeInteger(expected.dev))
423
+ this.fail("ENOTSUP", "fileStaging", path);
424
+ if (current.ino !== expected.ino || current.dev !== expected.dev || current.type !== expected.type)
425
+ this.fail("EAGAIN", "fileStaging", path);
426
+ if (unchanged) {
427
+ if (!Number.isSafeInteger(expected.revision) || !Number.isSafeInteger(current.revision))
428
+ this.fail("ENOTSUP", "fileStaging", path);
429
+ if (current.revision !== expected.revision || current.size !== expected.size || current.mode !== expected.mode
430
+ || current.nlink !== expected.nlink || current.mtimeMs !== expected.mtimeMs || current.ctimeMs !== expected.ctimeMs)
431
+ this.fail("EAGAIN", "fileStaging", path);
432
+ }
433
+ }
434
+ stagingLocations(staging) {
435
+ const parent = this.entry(staging.parent.path, "fileStaging");
436
+ this.expectEntry(parent.node, staging.parent.stat, staging.parent.path, false);
437
+ if (parent.node.type !== "directory")
438
+ this.fail("ENOTDIR", "fileStaging", staging.parent.path);
439
+ const directory = this.entry(staging.directory.path, "fileStaging");
440
+ this.expectEntry(directory.node, staging.directory.stat, staging.directory.path, false);
441
+ if (directory.parent !== parent.node || directory.node.type !== "directory" || (directory.node.mode & 0o777) !== 0o700)
442
+ this.fail("EAGAIN", "fileStaging", staging.directory.path);
443
+ const file = this.entry(staging.file.path, "fileStaging", true);
444
+ if (file.parent !== directory.node || !["file", "symlink"].includes(staging.file.stat.type))
445
+ this.fail("EAGAIN", "fileStaging", staging.file.path);
446
+ return { directory, file };
447
+ }
448
+ async createStagedFile(directoryPath, name, content, options) {
449
+ options.signal?.throwIfAborted();
450
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\0"))
451
+ this.fail("EINVAL", "createStagedFile", directoryPath);
452
+ const location = this.entry(directoryPath, "createStagedFile", true);
453
+ if (location.node)
454
+ this.fail("EEXIST", "createStagedFile", directoryPath);
455
+ this.expectEntry(location.parent, options.parent, directoryPath, false);
456
+ this.permission(location.parent, 3, "createStagedFile", directoryPath);
457
+ const mode = this.mode(options.mode, content.type === "file" ? 0o666 : 0o777, "createStagedFile", directoryPath);
458
+ for (const time of [options.atimeMs, options.mtimeMs])
459
+ if (time !== undefined && !Number.isFinite(time))
460
+ this.fail("EINVAL", "createStagedFile", directoryPath);
461
+ if (content.type === "file" && !(content.data instanceof Uint8Array))
462
+ throw new TypeError("Staged files require Uint8Array data");
463
+ if (content.type === "symlink")
464
+ this.validatePath(content.target, "createStagedFile");
465
+ const retained = (location.name.length + name.length) * 2 + (content.type === "symlink" ? content.target.length * 2 : 0);
466
+ this.ledger.reserve(retained, 4, "createStagedFile", directoryPath);
467
+ let allocation;
468
+ let directory;
469
+ let file;
470
+ try {
471
+ if (content.type === "file")
472
+ allocation = this.bytes(content.data, "createStagedFile", directoryPath);
473
+ directory = this.directory(0o700);
474
+ file = content.type === "file"
475
+ ? { ...this.metadata(typeModes.file | mode), type: "file", data: allocation.data, allocation: allocation }
476
+ : { ...this.metadata(typeModes.symlink | mode), type: "symlink", target: content.target };
477
+ if (options.atimeMs !== undefined)
478
+ file.atimeMs = options.atimeMs;
479
+ if (options.mtimeMs !== undefined)
480
+ file.mtimeMs = options.mtimeMs;
481
+ directory.entries.set(name, file);
482
+ location.parent.entries.set(location.name, directory);
483
+ }
484
+ catch (error) {
485
+ allocation?.release();
486
+ this.ledger.release(retained, 4);
487
+ throw error;
488
+ }
489
+ this.changed(location.parent);
490
+ const parentPath = location.path.slice(0, location.path.lastIndexOf("/")) || "/";
491
+ const receipt = (path, node) => Object.freeze({ path, stat: Object.freeze(this.snapshot(node)) });
492
+ return Object.freeze({ parent: receipt(parentPath, location.parent), directory: receipt(location.path, directory), file: receipt(`${location.path}/${name}`, file) });
493
+ }
494
+ async publishStagedFile(staging, destination, options) {
495
+ options.signal?.throwIfAborted();
496
+ const { directory, file } = this.stagingLocations(staging);
497
+ this.expectEntry(file.node, staging.file.stat, staging.file.path);
498
+ const target = this.entry(destination, "publishStagedFile", true);
499
+ this.expectEntry(target.parent, options.parent, destination, false);
500
+ this.expectEntry(target.node, options.destination, destination);
501
+ if (target.parent === directory.node || target.node === directory.node || target.node === file.node)
502
+ this.fail("EINVAL", "publishStagedFile", destination);
503
+ if (target.node && (target.node.type !== "file" || target.node.nlink !== 1))
504
+ this.fail("EAGAIN", "publishStagedFile", destination);
505
+ return MemoryFileSystem.prototype.rename.call(this, staging.file.path, destination, { ...options, noReplace: options.destination === null });
506
+ }
507
+ async removeStagedFile(staging, options = {}) {
508
+ options.signal?.throwIfAborted();
509
+ const { directory, file } = this.stagingLocations(staging);
510
+ if (file.node)
511
+ this.expectEntry(file.node, staging.file.stat, staging.file.path);
512
+ const node = directory.node;
513
+ if (node.entries.size !== (file.node ? 1 : 0))
514
+ this.fail("ENOTEMPTY", "removeStagedFile", staging.directory.path);
515
+ this.permission(directory.parent, 3, "removeStagedFile", staging.directory.path);
516
+ this.permission(node, 3, "removeStagedFile", staging.file.path);
517
+ directory.parent.entries.delete(directory.name);
518
+ if (file.node) {
519
+ node.entries.delete(file.name);
520
+ this.ledger.release(file.name.length * 2, 1);
521
+ file.node.nlink--;
522
+ file.node.ctimeMs = Date.now();
523
+ file.node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, file.node.revision + 1);
524
+ this.releaseNode(file.node);
525
+ }
526
+ this.ledger.release(directory.name.length * 2, 1);
527
+ node.nlink = 0;
528
+ node.ctimeMs = Date.now();
529
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
530
+ this.releaseNode(node);
531
+ this.changed(directory.parent);
532
+ }
533
+ async prepareDirectory(path, options) {
534
+ options.signal?.throwIfAborted();
535
+ const location = this.entry(path, "prepareDirectory", true);
536
+ this.expectEntry(location.parent, options.parent, path, false);
537
+ this.expectEntry(location.node, options.expected, path, false);
538
+ const mode = options.mode === undefined ? undefined : this.mode(options.mode, 0, "prepareDirectory", path);
539
+ for (const time of [options.atimeMs, options.mtimeMs])
540
+ if (time !== undefined && !Number.isFinite(time))
541
+ this.fail("EINVAL", "prepareDirectory", path);
542
+ let node = location.node;
543
+ if (!node) {
544
+ this.permission(location.parent, 3, "prepareDirectory", path);
545
+ node = this.addNode(location.parent, location.name, () => this.directory(mode ?? 0o777), "prepareDirectory", path);
546
+ }
547
+ if (node.type !== "directory")
548
+ this.fail("ENOTDIR", "prepareDirectory", path);
549
+ if (mode !== undefined)
550
+ node.mode = typeModes.directory | mode;
551
+ if (options.atimeMs !== undefined)
552
+ node.atimeMs = options.atimeMs;
553
+ if (options.mtimeMs !== undefined)
554
+ node.mtimeMs = options.mtimeMs;
555
+ node.ctimeMs = Date.now();
556
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
557
+ return Object.freeze(this.snapshot(node));
558
+ }
559
+ async writeFileConditional(path, data, options) {
560
+ options.signal?.throwIfAborted();
561
+ const location = this.entry(path, "writeFileConditional", true);
562
+ this.expectEntry(location.parent, options.parent, path, false);
563
+ this.expectEntry(location.node, options.expected, path);
564
+ if (location.node && location.node.type !== "file")
565
+ this.fail("EINVAL", "writeFileConditional", path);
566
+ this.writeData(path, data, { ...(options.mode === undefined ? {} : { mode: options.mode }), flag: options.append ? "a" : "w" }, "writeFileConditional");
567
+ return Object.freeze(this.snapshot(location.parent.entries.get(location.name)));
568
+ }
569
+ async removeFileConditional(path, options) {
570
+ options.signal?.throwIfAborted();
571
+ const location = this.entry(path, "removeFileConditional", true);
572
+ this.expectEntry(location.parent, options.parent, path, false);
573
+ this.expectEntry(location.node, options.expected, path);
574
+ const node = location.node;
575
+ if (node.type !== "file")
576
+ this.fail("EINVAL", "removeFileConditional", path);
577
+ this.permission(location.parent, 3, "removeFileConditional", path);
578
+ location.parent.entries.delete(location.name);
579
+ this.ledger.release(location.name.length * 2, 1);
580
+ node.nlink--;
581
+ node.ctimeMs = Date.now();
582
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
583
+ this.releaseNode(node);
584
+ this.changed(location.parent);
585
+ }
408
586
  async writeFile(path, data, options = {}) {
409
587
  options.signal?.throwIfAborted();
410
588
  this.writeData(path, data, options, "writeFile");
@@ -467,6 +645,7 @@ export class MemoryFileSystem {
467
645
  this.ledger.release(location.name.length * 2, 1);
468
646
  node.nlink = 0;
469
647
  node.ctimeMs = Date.now();
648
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
470
649
  this.releaseNode(node);
471
650
  this.changed(location.parent);
472
651
  }
@@ -508,6 +687,7 @@ export class MemoryFileSystem {
508
687
  }
509
688
  entry.nlink--;
510
689
  entry.ctimeMs = Date.now();
690
+ entry.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, entry.revision + 1);
511
691
  this.releaseNode(entry);
512
692
  }
513
693
  this.changed(location.parent);
@@ -554,6 +734,7 @@ export class MemoryFileSystem {
554
734
  this.ledger.release(origin.name.length * 2, 1);
555
735
  target.node.nlink--;
556
736
  target.node.ctimeMs = Date.now();
737
+ target.node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, target.node.revision + 1);
557
738
  this.releaseNode(target.node);
558
739
  }
559
740
  else if (nameGrowth < 0)
@@ -561,6 +742,7 @@ export class MemoryFileSystem {
561
742
  this.changed(origin.parent);
562
743
  this.changed(target.parent);
563
744
  node.ctimeMs = Date.now();
745
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
564
746
  }
565
747
  catch (error) {
566
748
  if (error instanceof FsError)
@@ -636,6 +818,7 @@ export class MemoryFileSystem {
636
818
  }
637
819
  node.nlink++;
638
820
  node.ctimeMs = Date.now();
821
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
639
822
  this.changed(target.parent);
640
823
  }
641
824
  async chmod(path, mode, options = {}) {
@@ -644,6 +827,7 @@ export class MemoryFileSystem {
644
827
  const node = this.resolve(path, "chmod").node;
645
828
  node.mode = typeModes[node.type] | permissions;
646
829
  node.ctimeMs = Date.now();
830
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
647
831
  }
648
832
  async utimes(path, atimeMs, mtimeMs, options = {}) {
649
833
  options.signal?.throwIfAborted();
@@ -653,6 +837,7 @@ export class MemoryFileSystem {
653
837
  node.atimeMs = atimeMs;
654
838
  node.mtimeMs = mtimeMs;
655
839
  node.ctimeMs = Date.now();
840
+ node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, node.revision + 1);
656
841
  }
657
842
  async truncate(path, length = 0, options = {}) {
658
843
  options.signal?.throwIfAborted();
@@ -1,4 +1,4 @@
1
- import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
1
+ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  export interface MountFileSystemOptions {
4
4
  readonly root: FileSystem;
@@ -31,6 +31,13 @@ export declare class MountFileSystem implements FileSystem {
31
31
  mkdir(path: string, options?: MkdirOptions): Promise<void>;
32
32
  rmdir(path: string, options?: FsOptions): Promise<void>;
33
33
  rm(path: string, options?: RemoveOptions): Promise<void>;
34
+ private localStaging;
35
+ writeFileConditional(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
36
+ removeFileConditional(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
37
+ createStagedFile(path: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
38
+ publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
39
+ removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
40
+ prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
34
41
  rename(source: string, destination: string, options?: RenameOptions): Promise<void>;
35
42
  copyFile(source: string, destination: string, options?: CopyFileOptions): Promise<void>;
36
43
  realpath(path: string, options?: FsOptions): Promise<string>;
@@ -1,7 +1,7 @@
1
1
  import { FsError, isFsError, toFsError } from "../../contracts/errors.js";
2
2
  import { readBytes } from "../../contracts/io.js";
3
3
  import { finishCleanup } from "../../contracts/cleanup.js";
4
- import { openRetainedReadFile, openRetainedResizeFile, readOnlyCapabilities, retainedReadCapabilities, retainedResizeCapabilities } from "../capabilities.js";
4
+ import { requireOwnedMutation, ownedMutationCapabilities, openRetainedReadFile, openRetainedResizeFile, readOnlyCapabilities, retainedReadCapabilities, retainedResizeCapabilities } from "../capabilities.js";
5
5
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
6
6
  import { normalizePath, validatePath } from "../../contracts/virtual-path.js";
7
7
  import { pathNamespace } from "../path-namespace.js";
@@ -21,9 +21,10 @@ function fail(code) {
21
21
  throw new FsError(code);
22
22
  }
23
23
  function snapshotStat(stat) {
24
- const { type, size, allocatedBytes, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, identityScope, ino, dev, nlink, uid, gid } = stat;
24
+ const { type, size, allocatedBytes, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, ino, dev, nlink, uid, gid } = stat;
25
25
  return {
26
26
  type, size, mode, mtimeMs, atimeMs, ctimeMs,
27
+ ...(revision === undefined ? {} : { revision }),
27
28
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
28
29
  ...(preferredIoBlockSize === undefined ? {} : { preferredIoBlockSize }),
29
30
  ...(birthtimeMs === undefined ? {} : { birthtimeMs }),
@@ -90,6 +91,7 @@ export class MountFileSystem {
90
91
  : mounts.every(({ backend }) => backend.capabilities.append === false) ? false : undefined;
91
92
  const common = (capability) => {
92
93
  const optional = {
94
+ atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
93
95
  symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"],
94
96
  descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
95
97
  };
@@ -107,7 +109,7 @@ export class MountFileSystem {
107
109
  return values.every(value => value === true) ? true : values.every(value => value === false) ? false : undefined;
108
110
  };
109
111
  const semantics = Object.fromEntries([
110
- "read", "stat", "readdir", "realpath", "access",
112
+ "atomicFileMutation", "atomicFileStaging", "atomicDirectoryMetadata", "read", "stat", "readdir", "realpath", "access",
111
113
  "write", "append", "exclusiveCreate", "explicitDirectories", "implicitDirectories", "mkdir", "recursiveMkdir",
112
114
  "remove", "removeDirectory", "recursiveRemove", "rename", "atomicRenameNoReplace", "copy", "exclusiveCopy", "readlink", "truncate",
113
115
  "streamingAppend", "randomAccessWrite", "descriptorWriteStream", "retainedResize", "atomicResize", "symlinks", "hardlinks", "permissions", "timestamps",
@@ -132,8 +134,8 @@ export class MountFileSystem {
132
134
  allowMissing: options.create ?? true,
133
135
  ...(options.create === undefined ? {} : { resizeCreate: options.create }),
134
136
  });
135
- const observed = await location.mount.backend.capabilitiesFor?.(location.local, options)
136
- ?? location.mount.backend.capabilities;
137
+ const observed = ownedMutationCapabilities(location.mount.backend, await location.mount.backend.capabilitiesFor?.(location.local, options)
138
+ ?? location.mount.backend.capabilities);
137
139
  const declared = observed.descriptorWriteStream === true
138
140
  && (typeof location.mount.backend.writeStream !== "function" || observed.readOnly === true || observed.streamingWrite === false)
139
141
  ? { ...observed, descriptorWriteStream: false } : observed;
@@ -214,11 +216,12 @@ export class MountFileSystem {
214
216
  cause: error, syscall, path: globalPath(path), ...(dest === undefined ? {} : { dest: globalPath(dest) }),
215
217
  });
216
218
  }
217
- async operation(syscall, path, options, action, dest) {
219
+ async operation(syscall, path, options, action, dest, preserveReceipt = false) {
218
220
  try {
219
221
  options.signal?.throwIfAborted();
220
222
  const result = await action();
221
- options.signal?.throwIfAborted();
223
+ if (!preserveReceipt)
224
+ options.signal?.throwIfAborted();
222
225
  return result;
223
226
  }
224
227
  catch (error) {
@@ -512,6 +515,102 @@ export class MountFileSystem {
512
515
  await location.mount.backend.rm(location.local, options);
513
516
  });
514
517
  }
518
+ async localStaging(staging, options) {
519
+ const parent = await this.resolve(staging.parent.path, options, { followFinal: false, entry: true });
520
+ const directory = await this.resolve(staging.directory.path, options, { followFinal: false, entry: true });
521
+ const file = await this.resolve(staging.file.path, options, { followFinal: false, entry: true, allowMissing: true });
522
+ if (parent.mount !== directory.mount || directory.mount !== file.mount)
523
+ fail("EXDEV");
524
+ if (this.protected(directory.path) || this.protected(file.path))
525
+ fail("EBUSY");
526
+ for (const location of [directory, file])
527
+ this.mutable(location);
528
+ return { mount: directory.mount, staging: {
529
+ parent: { path: parent.local, stat: staging.parent.stat },
530
+ directory: { path: directory.local, stat: staging.directory.stat },
531
+ file: { path: file.local, stat: staging.file.stat },
532
+ } };
533
+ }
534
+ writeFileConditional(path, data, options) {
535
+ return this.operation("writeFileConditional", path, options, async () => {
536
+ const location = await this.resolve(path, options, { followFinal: false, entry: true, allowMissing: true });
537
+ if (this.protected(location.path))
538
+ fail("EBUSY");
539
+ this.mutable(location);
540
+ const backend = location.mount.backend;
541
+ await requireOwnedMutation(backend, location.local, "atomicFileMutation", options, options.expected === null);
542
+ if (!backend.writeFileConditional)
543
+ fail("ENOTSUP");
544
+ return snapshotStat(await backend.writeFileConditional(location.local, data, options));
545
+ }, undefined, true);
546
+ }
547
+ removeFileConditional(path, options) {
548
+ return this.operation("removeFileConditional", path, options, async () => {
549
+ const location = await this.resolve(path, options, { followFinal: false, entry: true, allowMissing: true });
550
+ if (this.protected(location.path))
551
+ fail("EBUSY");
552
+ this.mutable(location);
553
+ const backend = location.mount.backend;
554
+ await requireOwnedMutation(backend, location.local, "atomicFileMutation", options);
555
+ if (!backend.removeFileConditional)
556
+ fail("ENOTSUP");
557
+ await backend.removeFileConditional(location.local, options);
558
+ });
559
+ }
560
+ createStagedFile(path, name, content, options) {
561
+ return this.operation("createStagedFile", path, options, async () => {
562
+ const location = await this.resolve(path, options, { followFinal: false, entry: true, allowMissing: true, missingDirectory: true });
563
+ if (this.protected(location.path))
564
+ fail("EBUSY");
565
+ this.mutable(location);
566
+ const backend = location.mount.backend;
567
+ await requireOwnedMutation(backend, location.local, "atomicFileStaging", options, true);
568
+ if (!backend.createStagedFile)
569
+ fail("ENOTSUP");
570
+ const receipt = await backend.createStagedFile(location.local, name, content, options);
571
+ const map = (entry) => Object.freeze({
572
+ path: location.mount.path === "/" ? entry.path : `${location.mount.path}${entry.path === "/" ? "" : entry.path}`,
573
+ stat: Object.freeze(snapshotStat(entry.stat)),
574
+ });
575
+ return Object.freeze({ parent: map(receipt.parent), directory: map(receipt.directory), file: map(receipt.file) });
576
+ }, undefined, true);
577
+ }
578
+ publishStagedFile(staging, destination, options) {
579
+ return this.operation("publishStagedFile", staging.file.path, options, async () => {
580
+ const local = await this.localStaging(staging, options);
581
+ const target = await this.resolve(destination, options, { followFinal: false, entry: true, allowMissing: true });
582
+ if (this.protected(target.path))
583
+ fail("EBUSY");
584
+ if (local.mount !== target.mount)
585
+ fail("EXDEV");
586
+ this.mutable(target);
587
+ await requireOwnedMutation(local.mount.backend, local.staging.directory.path, "atomicFileStaging", options);
588
+ if (!local.mount.backend.publishStagedFile)
589
+ fail("ENOTSUP");
590
+ await local.mount.backend.publishStagedFile(local.staging, target.local, options);
591
+ }, destination);
592
+ }
593
+ removeStagedFile(staging, options = {}) {
594
+ return this.operation("removeStagedFile", staging.directory.path, options, async () => {
595
+ const local = await this.localStaging(staging, options);
596
+ await requireOwnedMutation(local.mount.backend, local.staging.directory.path, "atomicFileStaging", options);
597
+ if (!local.mount.backend.removeStagedFile)
598
+ fail("ENOTSUP");
599
+ await local.mount.backend.removeStagedFile(local.staging, options);
600
+ });
601
+ }
602
+ prepareDirectory(path, options) {
603
+ return this.operation("prepareDirectory", path, options, async () => {
604
+ const location = await this.resolve(path, options, { followFinal: false, entry: true, allowMissing: true, missingDirectory: true });
605
+ if (this.protected(location.path))
606
+ fail("EBUSY");
607
+ this.mutable(location);
608
+ await requireOwnedMutation(location.mount.backend, location.local, "atomicDirectoryMetadata", options, options.expected === null);
609
+ if (!location.mount.backend.prepareDirectory)
610
+ fail("ENOTSUP");
611
+ return snapshotStat(await location.mount.backend.prepareDirectory(location.local, options));
612
+ }, undefined, true);
613
+ }
515
614
  rename(source, destination, options = {}) {
516
615
  return this.operation("rename", source, options, async () => {
517
616
  const origin = await this.resolve(source, options, { followFinal: false, entry: true });
@@ -8,12 +8,13 @@ import { compareIdentity } from "../mount/identity.js";
8
8
  import { compareEntries, registerEntryView } from "../mount/comparison.js";
9
9
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
10
10
  function snapshotStat(stat) {
11
- const { type, size, allocatedBytes, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, identityScope, ino, dev, nlink, uid, gid } = stat;
11
+ const { type, size, allocatedBytes, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, ino, dev, nlink, uid, gid } = stat;
12
12
  return {
13
13
  type, size, mode, mtimeMs, atimeMs, ctimeMs,
14
14
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
15
15
  ...(preferredIoBlockSize === undefined ? {} : { preferredIoBlockSize }),
16
16
  ...(birthtimeMs === undefined ? {} : { birthtimeMs }),
17
+ ...(revision === undefined ? {} : { revision }),
17
18
  ...(identityScope === undefined ? {} : { identityScope }),
18
19
  ...(ino === undefined ? {} : { ino }),
19
20
  ...(dev === undefined ? {} : { dev }),
@@ -113,6 +114,7 @@ export class OverlayFileSystem {
113
114
  ].filter(([, value]) => value !== undefined));
114
115
  this.capabilities = Object.freeze({
115
116
  ...semantics,
117
+ atomicFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
116
118
  implicitDirectories: false,
117
119
  readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
118
120
  : upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
@@ -316,7 +316,7 @@ export function withFileSystemQuota(fs, options) {
316
316
  // capabilities and methods without violating invariants on own properties.
317
317
  return new Proxy(Object.create(fs), {
318
318
  get(_target, property) {
319
- if (property === "canonicalizeMissingTarget" || property === "resizeFile")
319
+ if (property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
320
320
  return undefined;
321
321
  if (property === "capabilities")
322
322
  return quotaCapabilities(retainedResizeCapabilities(fs, retainedReadCapabilities(fs)));
@@ -9,9 +9,10 @@ function readOnly(syscall, path, dest) {
9
9
  throw new FsError("EROFS", { syscall, path, ...(dest === undefined ? {} : { dest }) });
10
10
  }
11
11
  function snapshotStat(stat) {
12
- const { type, size, allocatedBytes, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, identityScope, ino, dev, nlink, uid, gid } = stat;
12
+ const { type, size, allocatedBytes, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, ino, dev, nlink, uid, gid } = stat;
13
13
  return {
14
14
  type, size, mode, mtimeMs, atimeMs, ctimeMs,
15
+ ...(revision === undefined ? {} : { revision }),
15
16
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
16
17
  ...(preferredIoBlockSize === undefined ? {} : { preferredIoBlockSize }),
17
18
  ...(birthtimeMs === undefined ? {} : { birthtimeMs }),
@@ -1,6 +1,8 @@
1
1
  import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
2
2
  export declare function scopeFileSystem(filesystem: FileSystem, charge: () => void, signal: AbortSignal, cleanupCharge?: () => void): FileSystem;
3
3
  export interface RetainedFileSystemCleanupView {
4
+ readonly removeFileConditional?: NonNullable<FileSystem["removeFileConditional"]>;
5
+ readonly removeStagedFile?: NonNullable<FileSystem["removeStagedFile"]>;
4
6
  readonly lstat: FileSystem["lstat"];
5
7
  readonly realpath: FileSystem["realpath"];
6
8
  readonly rm: (path: string, options?: FsOptions) => Promise<void>;
@@ -1,9 +1,10 @@
1
1
  import { FsError } from "../contracts/errors.js";
2
2
  import { finishCleanup } from "../contracts/cleanup.js";
3
3
  import { registerEntryView } from "./mount/comparison.js";
4
- import { openRetainedResizeFile, retainedResizeCapabilities } from "./capabilities.js";
4
+ import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabilities, requireOwnedMutation } from "./capabilities.js";
5
5
  const originals = new WeakMap();
6
6
  const operations = new Set([
7
+ "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
7
8
  "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
8
9
  "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir",
9
10
  "readlink", "realpath", "rename", "resizeFile", "rm", "rmdir", "stat", "symlink", "truncate", "utimes",
@@ -151,7 +152,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
151
152
  },
152
153
  get(_target, property) {
153
154
  if (property === "capabilities")
154
- return retainedResizeCapabilities(original);
155
+ return ownedMutationCapabilities(original, retainedResizeCapabilities(original));
155
156
  const method = Reflect.get(original, property, original);
156
157
  if (typeof method !== "function")
157
158
  return method;
@@ -163,6 +164,15 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
163
164
  const options = args.at(-1);
164
165
  admit(options && typeof options === "object" && "signal" in options ? options : undefined);
165
166
  }
167
+ if (["writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory"].includes(String(property)))
168
+ return (async () => {
169
+ const path = typeof args[0] === "string" ? args[0] : args[0].directory.path;
170
+ const options = args[property === "createStagedFile" ? 3 : property === "publishStagedFile" || property === "writeFileConditional" ? 2 : 1];
171
+ const create = property === "createStagedFile" || (property === "writeFileConditional" || property === "prepareDirectory") && options !== undefined && "expected" in options && options.expected === null;
172
+ await requireOwnedMutation(original, path, property === "prepareDirectory" ? "atomicDirectoryMetadata" : property === "writeFileConditional" || property === "removeFileConditional" ? "atomicFileMutation" : "atomicFileStaging", options ?? {}, create);
173
+ assertOpen(options);
174
+ return Reflect.apply(method, original, args);
175
+ })();
166
176
  if (property === "compareEntry") {
167
177
  const peer = args[1];
168
178
  args[1] = originals.get(peer)?.filesystem ?? peer;
@@ -187,7 +197,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
187
197
  return wrapResizeHandle(await openRetainedResizeFile(original, path, resizeOptions(options)));
188
198
  }
189
199
  : property === "capabilitiesFor"
190
- ? async (...args) => retainedResizeCapabilities(original, await dispatch(...args))
200
+ ? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
191
201
  : property === "openReadFile"
192
202
  ? async (...args) => wrapHandle(await dispatch(...args))
193
203
  : property === "readStream"
@@ -219,7 +229,8 @@ export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
219
229
  let operations = 0;
220
230
  let active = false;
221
231
  let closing;
222
- const invoke = (method, path, settings) => {
232
+ const invoke = (method, input, settings) => {
233
+ const path = typeof input === "string" ? input : input.directory.path;
223
234
  if (!active) {
224
235
  const rejected = Promise.reject(new FsError("EBADF", { syscall: method, path, message: "cleanup callback is not active" }));
225
236
  void rejected.catch(() => { });
@@ -237,7 +248,9 @@ export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
237
248
  if (typeof operation !== "function")
238
249
  throw new FsError("ENOTSUP", { syscall: method, path });
239
250
  const parameters = method === "rm" ? { ...settings, recursive: false, force: false } : settings;
240
- result = Promise.resolve(Reflect.apply(operation, backing, [path, parameters]));
251
+ result = method === "removeStagedFile" || method === "removeFileConditional"
252
+ ? requireOwnedMutation(backing, path, method === "removeFileConditional" ? "atomicFileMutation" : "atomicFileStaging", settings ?? {}).then(() => Reflect.apply(operation, backing, [input, parameters]))
253
+ : Promise.resolve(Reflect.apply(operation, backing, [input, parameters]));
241
254
  }
242
255
  catch (error) {
243
256
  result = Promise.reject(error);
@@ -249,6 +262,8 @@ export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
249
262
  return result;
250
263
  };
251
264
  const view = Object.freeze(Object.assign(Object.create(null), {
265
+ ...(typeof backing.removeFileConditional === "function" ? { removeFileConditional: invoke.bind(undefined, "removeFileConditional") } : {}),
266
+ ...(typeof backing.removeStagedFile === "function" ? { removeStagedFile: invoke.bind(undefined, "removeStagedFile") } : {}),
252
267
  lstat: invoke.bind(undefined, "lstat"),
253
268
  realpath: invoke.bind(undefined, "realpath"),
254
269
  rm: invoke.bind(undefined, "rm"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.545",
3
+ "version": "0.1.547",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",