@effect-vfs/core 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +41 -150
  2. package/dist/BytePath.d.ts +2 -1
  3. package/dist/BytePath.d.ts.map +1 -1
  4. package/dist/LiveVolume.d.ts +104 -0
  5. package/dist/LiveVolume.d.ts.map +1 -0
  6. package/dist/LiveVolume.js +73 -0
  7. package/dist/Snapshot.d.ts +4 -2
  8. package/dist/Snapshot.d.ts.map +1 -1
  9. package/dist/Snapshot.js +4 -2
  10. package/dist/VirtualFileSystem.d.ts +333 -108
  11. package/dist/VirtualFileSystem.d.ts.map +1 -1
  12. package/dist/VirtualFileSystem.js +122 -32
  13. package/dist/VirtualFileSystemError.d.ts +54 -0
  14. package/dist/VirtualFileSystemError.d.ts.map +1 -0
  15. package/dist/VirtualFileSystemError.js +49 -0
  16. package/dist/index.d.ts +14 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +14 -0
  19. package/dist/internal/errors.d.ts +12 -0
  20. package/dist/internal/errors.d.ts.map +1 -0
  21. package/dist/internal/{virtualFileSystem/errors.js → errors.js} +11 -15
  22. package/dist/internal/{virtualFileSystem/fixture.d.ts → fixture.d.ts} +11 -8
  23. package/dist/internal/fixture.d.ts.map +1 -0
  24. package/dist/internal/{virtualFileSystem/fixture.js → fixture.js} +11 -5
  25. package/dist/internal/liveImage.d.ts +286 -0
  26. package/dist/internal/liveImage.d.ts.map +1 -0
  27. package/dist/internal/liveImage.js +176 -0
  28. package/dist/internal/{virtualFileSystem/overlayContent.d.ts → overlayContent.d.ts} +2 -2
  29. package/dist/internal/overlayContent.d.ts.map +1 -0
  30. package/dist/internal/{virtualFileSystem/overlayContent.js → overlayContent.js} +2 -2
  31. package/dist/internal/{virtualFileSystem/overlayDiff.d.ts → overlayDiff.d.ts} +1 -1
  32. package/dist/internal/overlayDiff.d.ts.map +1 -0
  33. package/dist/internal/{virtualFileSystem/overlayDiff.js → overlayDiff.js} +1 -1
  34. package/dist/internal/{virtualFileSystem/path.d.ts → path.d.ts} +2 -2
  35. package/dist/internal/path.d.ts.map +1 -0
  36. package/dist/internal/{virtualFileSystem/path.js → path.js} +1 -1
  37. package/dist/internal/stagedState.d.ts +22 -0
  38. package/dist/internal/stagedState.d.ts.map +1 -0
  39. package/dist/internal/stagedState.js +62 -0
  40. package/dist/internal/{virtualFileSystem/testHooks.d.ts → testHooks.d.ts} +2 -2
  41. package/dist/internal/testHooks.d.ts.map +1 -0
  42. package/dist/internal/virtualFileSystem.d.ts +224 -16
  43. package/dist/internal/virtualFileSystem.d.ts.map +1 -1
  44. package/dist/internal/virtualFileSystem.js +1294 -159
  45. package/dist/internal/{virtualFileSystem/watchHub.d.ts → watchHub.d.ts} +3 -3
  46. package/dist/internal/watchHub.d.ts.map +1 -0
  47. package/dist/internal/watchHub.js +54 -0
  48. package/package.json +14 -6
  49. package/dist/internal/virtualFileSystem/errors.d.ts +0 -31
  50. package/dist/internal/virtualFileSystem/errors.d.ts.map +0 -1
  51. package/dist/internal/virtualFileSystem/fixture.d.ts.map +0 -1
  52. package/dist/internal/virtualFileSystem/overlayContent.d.ts.map +0 -1
  53. package/dist/internal/virtualFileSystem/overlayDiff.d.ts.map +0 -1
  54. package/dist/internal/virtualFileSystem/path.d.ts.map +0 -1
  55. package/dist/internal/virtualFileSystem/testHooks.d.ts.map +0 -1
  56. package/dist/internal/virtualFileSystem/watchHub.d.ts.map +0 -1
  57. package/dist/internal/virtualFileSystem/watchHub.js +0 -33
  58. /package/dist/internal/{virtualFileSystem/testHooks.js → testHooks.js} +0 -0
@@ -1,10 +1,12 @@
1
1
  // Runtime definitions and cohesive live virtual filesystem engine.
2
2
  import * as ByteSize from "effect/ByteSize";
3
3
  import * as Clock from "effect/Clock";
4
- import * as Context from "effect/Context";
4
+ import * as Crypto from "effect/Crypto";
5
5
  import * as Data from "effect/Data";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as Encoding from "effect/Encoding";
8
+ import * as Exit from "effect/Exit";
9
+ import * as Order from "effect/Order";
8
10
  import * as Predicate from "effect/Predicate";
9
11
  import * as Result from "effect/Result";
10
12
  import * as Schema from "effect/Schema";
@@ -12,14 +14,16 @@ import * as Semaphore from "effect/Semaphore";
12
14
  import { BytePath } from "../BytePath.js";
13
15
  import { ImageError } from "../Snapshot.js";
14
16
  import { CanonicalBase64 } from "./canonicalBase64.js";
17
+ import { ConfigurationError, decodeConfiguration, FsCode as FsCodeSchema, FsError } from "./errors.js";
15
18
  import * as Image from "./image.js";
19
+ import * as LiveImage from "./liveImage.js";
16
20
  import * as MetadataDomain from "./metadata.js";
17
- import { ConfigurationError, decodeConfiguration, FsCode as FsCodeSchema, FsError } from "./virtualFileSystem/errors.js";
18
- import * as Content from "./virtualFileSystem/overlayContent.js";
19
- import { compareOverlay } from "./virtualFileSystem/overlayDiff.js";
20
- import { DOT_DOT_HEX, DOT_HEX, inputBytes, isAttachedBytes, isDotComponent, MAX_NAME_BYTES, nameBytes, ownedPath, preparePath, SLASH_BYTE, SLASH_HEX, strictString } from "./virtualFileSystem/path.js";
21
- import * as TestHooks from "./virtualFileSystem/testHooks.js";
22
- import * as WatchHub from "./virtualFileSystem/watchHub.js";
21
+ import * as Content from "./overlayContent.js";
22
+ import { compareOverlay } from "./overlayDiff.js";
23
+ import { DOT_DOT_HEX, DOT_HEX, inputBytes, isAttachedBytes, isDotComponent, MAX_NAME_BYTES, nameBytes, ownedPath, preparePath, SLASH_BYTE, SLASH_HEX, strictString } from "./path.js";
24
+ import { makeStagedState } from "./stagedState.js";
25
+ import * as TestHooks from "./testHooks.js";
26
+ import * as WatchHub from "./watchHub.js";
23
27
  /** @internal */
24
28
  export const VolumeId = Symbol("@effect-vfs/core/Volume");
25
29
  /** @internal */
@@ -60,9 +64,34 @@ export const RootCallerOptions = Schema.Struct({
60
64
  identity: Schema.optionalKey(Identity),
61
65
  umask: Schema.optionalKey(Schema.Natural.check(Schema.isLessThanOrEqualTo(0o777)))
62
66
  });
67
+ const Hex128 = Schema.String.check(Schema.isPattern(/^[0-9a-f]{32}$/));
68
+ /** @internal */
69
+ export const VolumeDurability = Schema.Literals([
70
+ "memory-only",
71
+ "survives-process-crash",
72
+ "survives-operating-system-crash",
73
+ "survives-power-loss"
74
+ ]);
75
+ const durabilityRank = {
76
+ "memory-only": 0,
77
+ "survives-process-crash": 1,
78
+ "survives-operating-system-crash": 2,
79
+ "survives-power-loss": 3
80
+ };
81
+ /** @internal */
82
+ export const VolumeDurabilityOrder = Order.mapInput(Order.Number, (durability) => durabilityRank[durability]);
83
+ /** @internal */
84
+ export const isVolumeDurabilityAtLeast = (actual, required) => VolumeDurabilityOrder(actual, required) >= 0;
85
+ /** @internal */
86
+ export const VolumeIdentity = Hex128.pipe(Schema.brand("@effect-vfs/core/VolumeIdentity"));
87
+ /** @internal */
88
+ export const VolumeIncarnation = Hex128.pipe(Schema.brand("@effect-vfs/core/VolumeIncarnation"));
63
89
  /** @internal */
64
90
  export const VolumeOptions = Schema.Struct({
91
+ identity: Schema.optionalKey(VolumeIdentity),
65
92
  maxEntries: Schema.optionalKey(Schema.Natural),
93
+ maxPendingOperations: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
94
+ maxWatchEvents: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(2))),
66
95
  maxBytes: Schema.optionalKey(Schema.ByteSize),
67
96
  maxFileBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isLessThanOrEqualTo(size, ByteSize.bytes(0xffffffff)) ? undefined : "must be at most 4294967295 bytes"))),
68
97
  maxPathBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isGreaterThanOrEqualTo(size, ByteSize.bytes(1)) ? undefined : "must be at least 1 byte")))
@@ -107,6 +136,49 @@ export const OpenSettings = Schema.Struct({
107
136
  truncate: Schema.optionalKey(Schema.Boolean),
108
137
  followFinalSymlink: Schema.optionalKey(Schema.Boolean)
109
138
  });
139
+ /** @internal */
140
+ export const DirectoryChange = Schema.Struct({
141
+ before: Schema.BigInt,
142
+ after: Schema.BigInt
143
+ });
144
+ /** @internal */
145
+ export const RenameReferenceResult = Schema.TaggedUnion({
146
+ SameDirectory: { directory: DirectoryChange },
147
+ DifferentDirectories: {
148
+ sourceDirectory: DirectoryChange,
149
+ destinationDirectory: DirectoryChange
150
+ }
151
+ });
152
+ /** @internal */
153
+ export const MkdirReferenceSettings = Schema.Struct({
154
+ mode: Schema.optionalKey(Mode),
155
+ exactMode: Schema.optionalKey(Schema.Boolean),
156
+ times: Schema.optionalKey(Times)
157
+ });
158
+ /** @internal */
159
+ export const SymlinkReferenceSettings = Schema.Struct({
160
+ times: Schema.optionalKey(Times)
161
+ });
162
+ /** @internal */
163
+ export const OpenReferenceSettings = Schema.Struct({
164
+ access: Schema.Literals(["read", "write", "readWrite"]),
165
+ append: Schema.optionalKey(Schema.Boolean),
166
+ truncate: Schema.optionalKey(Schema.Boolean)
167
+ });
168
+ /** @internal */
169
+ export const OpenChildReferenceSettings = Schema.Struct({
170
+ ...OpenSettings.fields,
171
+ times: Schema.optionalKey(Times),
172
+ initialSize: Schema.optionalKey(Schema.BigInt),
173
+ exactMode: Schema.optionalKey(Schema.Boolean),
174
+ owner: Schema.optionalKey(OwnerUpdate),
175
+ expectedChild: Schema.optionalKey(Schema.NullOr(Schema.Struct({
176
+ reference: Schema.declare((input) => Predicate.hasProperty(ObjectReferenceId)(input) && input[ObjectReferenceId] === true),
177
+ revision: Schema.BigInt,
178
+ atimeNs: MetadataDomain.Timestamp,
179
+ mtimeNs: MetadataDomain.Timestamp
180
+ })))
181
+ });
110
182
  const WriteFileSettings = Schema.Struct({
111
183
  ...OpenSettings.fields,
112
184
  replaceFinalSymlink: Schema.optionalKey(Schema.Boolean),
@@ -116,6 +188,10 @@ const decodeOwnerUpdate = Schema.decodeResult(OwnerUpdate, { onExcessProperty: "
116
188
  const decodeTimes = Schema.decodeResult(Times, { onExcessProperty: "error" });
117
189
  const decodeWriteFileSettings = Schema.decodeResult(WriteFileSettings, { onExcessProperty: "error" });
118
190
  const decodeOpenSettings = Schema.decodeResult(OpenSettings, { onExcessProperty: "error" });
191
+ const decodeMkdirReferenceSettings = Schema.decodeResult(MkdirReferenceSettings, { onExcessProperty: "error" });
192
+ const decodeSymlinkReferenceSettings = Schema.decodeResult(SymlinkReferenceSettings, { onExcessProperty: "error" });
193
+ const decodeOpenReferenceSettings = Schema.decodeResult(OpenReferenceSettings, { onExcessProperty: "error" });
194
+ const decodeOpenChildReferenceSettings = Schema.decodeResult(OpenChildReferenceSettings, { onExcessProperty: "error" });
119
195
  /** @internal */
120
196
  export const OverlayNodeKind = Schema.Literals(["directory", "file", "symlink"]);
121
197
  /** @internal */
@@ -154,9 +230,6 @@ export const OverlayChangesOptions = Schema.Struct({
154
230
  includeTimestamps: Schema.optionalKey(Schema.Boolean)
155
231
  });
156
232
  /** @internal */
157
- export class CurrentFileSystem extends Context.Service()("@effect-vfs/core/CurrentFileSystem") {
158
- }
159
- /** @internal */
160
233
  export const FixtureMetadata = Schema.Struct({
161
234
  uid: Schema.optionalKey(Schema.Natural),
162
235
  gid: Schema.optionalKey(Schema.Natural),
@@ -196,6 +269,68 @@ export const Fixture = Schema.Struct({
196
269
  rootMetadata: Schema.optionalKey(FixtureMetadata),
197
270
  entries: Schema.Array(FixtureEntry)
198
271
  });
272
+ /** @internal */
273
+ export const captureLiveImage = Effect.fnUntraced(function* (state, identity, limits) {
274
+ const records = [];
275
+ const visited = new Set();
276
+ const pending = [state.root, ...state.retainedFiles.values()];
277
+ for (let index = 0; index < pending.length; index++) {
278
+ if (index % WALK_YIELD_INTERVAL === 0)
279
+ yield* Effect.yieldNow;
280
+ const node = pending[index];
281
+ if (node === undefined || visited.has(node.metadata.ino))
282
+ continue;
283
+ visited.add(node.metadata.ino);
284
+ const common = {
285
+ ino: node.metadata.ino,
286
+ revision: node.revision,
287
+ metadata: {
288
+ ...storedMetadata(node.metadata),
289
+ nlink: node.metadata.nlink,
290
+ size: node.metadata.size
291
+ }
292
+ };
293
+ if (node.lineage !== undefined)
294
+ common.lineage = node.lineage;
295
+ if (node.kind === "directory") {
296
+ const entries = [];
297
+ for (const [name, child] of node.entries) {
298
+ entries.push({ name: CanonicalBase64.encode(nameBytes(name)), target: child.metadata.ino });
299
+ pending.push(child);
300
+ }
301
+ records.push(LiveImage.Record.cases.directory.make({ ...common, entries }));
302
+ }
303
+ else if (node.kind === "file") {
304
+ records.push(LiveImage.Record.cases.file.make({ ...common, data: CanonicalBase64.encode(node.data.bytes) }));
305
+ }
306
+ else {
307
+ records.push(LiveImage.Record.cases.symlink.make({ ...common, target: CanonicalBase64.encode(node.target) }));
308
+ }
309
+ }
310
+ const storedLimits = {};
311
+ if (limits.maxEntries !== undefined)
312
+ storedLimits.maxEntries = limits.maxEntries;
313
+ if (limits.maxBytes !== undefined)
314
+ storedLimits.maxBytes = ByteSize.toBigInt(limits.maxBytes);
315
+ if (limits.maxFileBytes !== undefined)
316
+ storedLimits.maxFileBytes = ByteSize.toBigInt(limits.maxFileBytes);
317
+ if (limits.maxPathBytes !== undefined)
318
+ storedLimits.maxPathBytes = ByteSize.toBigInt(limits.maxPathBytes);
319
+ const document = {
320
+ format: "effect-vfs-live",
321
+ version: 1,
322
+ identity,
323
+ root: state.root.metadata.ino,
324
+ nextInode: state.nextInode,
325
+ revisionCounter: state.revisionCounter,
326
+ entries: state.entries,
327
+ usedBytes: state.usedBytes,
328
+ limits: storedLimits,
329
+ retainedFiles: [...state.retainedFiles.keys()],
330
+ records
331
+ };
332
+ return yield* LiveImage.encode(document);
333
+ });
199
334
  const objectReferences = new WeakMap();
200
335
  const isFileHandle = (value) => Predicate.hasProperty(FileHandleId)(value);
201
336
  // Only a PathInput target names a path; a handle-based call has none to report in its error.
@@ -228,14 +363,34 @@ const storedMetadata = (metadata) => ({
228
363
  /** @internal */
229
364
  export const VolumeSource = Data.taggedEnum();
230
365
  const UpdateChange = Schema.TaggedStruct("Update", { path: BytePath });
366
+ const RescanChange = Schema.TaggedStruct("Rescan", { path: BytePath });
231
367
  // Each execution constructs a fresh volume and captures its Clock.
232
368
  /** @internal */
233
- export const makeVolume = Effect.fnUntraced(function* (source, options) {
369
+ export const makeVolume = Effect.fnUntraced(function* (source, options, commitProvider, captureInitial, durability = "memory-only") {
234
370
  const image = "image" in source ? source.image : undefined;
235
- const decoded = decodeConfiguration(VolumeOptions, options === undefined ? {} : options);
371
+ const live = Predicate.isTagged("Live")(source) ? source.document : undefined;
372
+ let restoredOptions = options;
373
+ if (live !== undefined) {
374
+ const recovered = { identity: VolumeIdentity.make(live.identity) };
375
+ if (live.limits.maxEntries !== undefined)
376
+ recovered.maxEntries = live.limits.maxEntries;
377
+ if (live.limits.maxBytes !== undefined)
378
+ recovered.maxBytes = ByteSize.bytes(live.limits.maxBytes);
379
+ if (live.limits.maxFileBytes !== undefined)
380
+ recovered.maxFileBytes = ByteSize.bytes(live.limits.maxFileBytes);
381
+ if (live.limits.maxPathBytes !== undefined)
382
+ recovered.maxPathBytes = ByteSize.bytes(live.limits.maxPathBytes);
383
+ restoredOptions = recovered;
384
+ }
385
+ const decoded = decodeConfiguration(VolumeOptions, restoredOptions === undefined ? {} : restoredOptions);
236
386
  if (Result.isFailure(decoded))
237
387
  return yield* decoded.failure;
238
388
  const settings = { ...decoded.success };
389
+ const crypto = yield* Crypto.Crypto;
390
+ const identity = settings.identity === undefined
391
+ ? VolumeIdentity.make(Encoding.encodeHex(yield* crypto.randomBytes(16)))
392
+ : VolumeIdentity.make(settings.identity);
393
+ const incarnation = VolumeIncarnation.make(Encoding.encodeHex(yield* crypto.randomBytes(16)));
239
394
  const clock = yield* Clock.clockWith(Effect.succeed);
240
395
  const initialTime = clock.currentTimeNanosUnsafe();
241
396
  if (!isTimestamp(initialTime)) {
@@ -248,28 +403,177 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
248
403
  : Effect.fail(new FsError({ code: "InvalidArgument", operation: operation }));
249
404
  });
250
405
  const volumeIdentity = Symbol();
406
+ let activeStage;
407
+ const fileReferences = new Set();
408
+ const directoryReferences = new Set();
409
+ // A capability holds a cell instead of a particular node object. Publication can replace
410
+ // the node behind the cell without replacing the capability held by a caller.
411
+ const cells = new WeakMap();
412
+ const cellFor = (node) => {
413
+ const existing = cells.get(node);
414
+ if (existing !== undefined)
415
+ return existing;
416
+ const previous = activeStage?.previous.get(node);
417
+ const retained = previous === undefined ? undefined : cells.get(previous);
418
+ if (retained !== undefined) {
419
+ cells.set(node, retained);
420
+ return retained;
421
+ }
422
+ let current = node;
423
+ const cell = {
424
+ get node() {
425
+ return activeStage?.nodes.get(current) ?? current;
426
+ },
427
+ set node(next) {
428
+ current = next;
429
+ }
430
+ };
431
+ cells.set(node, cell);
432
+ return cell;
433
+ };
434
+ const makeFileReference = (access, append) => {
435
+ const live = { cell: undefined, closed: false, offset: 0n };
436
+ let reference;
437
+ const record = () => {
438
+ if (activeStage === undefined)
439
+ return live;
440
+ let staged = activeStage.files.get(reference);
441
+ if (staged === undefined) {
442
+ staged = { ...live };
443
+ activeStage.files.set(reference, staged);
444
+ const pending = staged;
445
+ activeStage.apply.push(() => {
446
+ Object.assign(live, pending);
447
+ if (live.cell === undefined)
448
+ fileReferences.delete(reference);
449
+ else
450
+ fileReferences.add(reference);
451
+ });
452
+ }
453
+ return staged;
454
+ };
455
+ reference = {
456
+ volume: volumeIdentity,
457
+ get file() {
458
+ const node = record().cell?.node;
459
+ return node?.kind === "file" ? node : undefined;
460
+ },
461
+ set file(file) {
462
+ record().cell = file === undefined ? undefined : cellFor(file);
463
+ if (activeStage === undefined) {
464
+ if (file === undefined)
465
+ fileReferences.delete(reference);
466
+ else
467
+ fileReferences.add(reference);
468
+ }
469
+ },
470
+ get closed() {
471
+ return record().closed;
472
+ },
473
+ set closed(value) {
474
+ record().closed = value;
475
+ },
476
+ get offset() {
477
+ return record().offset;
478
+ },
479
+ set offset(value) {
480
+ record().offset = value;
481
+ },
482
+ access,
483
+ append
484
+ };
485
+ return reference;
486
+ };
487
+ const makeDirectoryReference = (directory) => {
488
+ const live = {
489
+ cell: directory === undefined ? undefined : cellFor(directory),
490
+ closed: false
491
+ };
492
+ let reference;
493
+ const record = () => {
494
+ if (activeStage === undefined)
495
+ return live;
496
+ let staged = activeStage.directories.get(reference);
497
+ if (staged === undefined) {
498
+ staged = { ...live };
499
+ activeStage.directories.set(reference, staged);
500
+ const pending = staged;
501
+ activeStage.apply.push(() => {
502
+ Object.assign(live, pending);
503
+ if (live.cell === undefined || live.cell.node === state.root)
504
+ directoryReferences.delete(reference);
505
+ else
506
+ directoryReferences.add(reference);
507
+ });
508
+ }
509
+ return staged;
510
+ };
511
+ reference = {
512
+ volume: volumeIdentity,
513
+ get directory() {
514
+ const node = record().cell?.node;
515
+ return node?.kind === "directory" ? node : undefined;
516
+ },
517
+ set directory(directory) {
518
+ record().cell = directory === undefined ? undefined : cellFor(directory);
519
+ if (activeStage === undefined) {
520
+ if (directory === undefined || directory === state.root)
521
+ directoryReferences.delete(reference);
522
+ else
523
+ directoryReferences.add(reference);
524
+ }
525
+ },
526
+ get closed() {
527
+ return record().closed;
528
+ },
529
+ set closed(value) {
530
+ record().closed = value;
531
+ }
532
+ };
533
+ if (directory !== undefined && directory !== state.root && activeStage === undefined) {
534
+ directoryReferences.add(reference);
535
+ }
536
+ return reference;
537
+ };
251
538
  const gate = Semaphore.makeUnsafe(1);
252
- let revisionCounter = 0n;
253
- const nextRevision = () => ++revisionCounter;
254
- const root = {
255
- kind: "directory",
256
- lineage: image?.root,
257
- parent: undefined,
258
- entries: new Map(),
259
- metadata: directoryMetadata(1n, 0, 0, 0o755, initialTime),
260
- revision: nextRevision(),
261
- objectReference: undefined
539
+ const maxPendingOperations = settings.maxPendingOperations ?? 64;
540
+ let admitted = 0;
541
+ const admit = (operation, effect) => Effect.suspend(() => {
542
+ if (admitted >= maxPendingOperations + 1) {
543
+ return Effect.fail(new FsError({ code: "VolumeBusy", operation }));
544
+ }
545
+ admitted += 1;
546
+ return effect.pipe(Effect.ensuring(Effect.sync(() => {
547
+ admitted -= 1;
548
+ })));
549
+ });
550
+ let state = {
551
+ root: {
552
+ kind: "directory",
553
+ lineage: image?.root,
554
+ parent: undefined,
555
+ entries: new Map(),
556
+ metadata: directoryMetadata(1n, 0, 0, 0o755, initialTime),
557
+ revision: 1n,
558
+ objectReference: undefined
559
+ },
560
+ retainedFiles: new Map(),
561
+ revisionCounter: 1n,
562
+ nextInode: 2n,
563
+ entries: 0,
564
+ usedBytes: 0n
262
565
  };
263
- let nextInode = 2n;
264
- let entries = 0;
265
- let usedBytes = 0n;
566
+ const nextRevision = () => ++state.revisionCounter;
266
567
  // The schema caps this value at uint32, so this boundary conversion is exact.
267
568
  const maxFileBytes = Number(ByteSize.toBigInt(settings.maxFileBytes ?? ByteSize.bytes(0xffffffff)));
268
- // Permit waits stay interruptible. State transitions and resource registration do not.
269
- const coordinated = (effect) => gate.withPermit(Effect.uninterruptible(effect));
270
- // Read-only walks hold the permit but mutate nothing, so abandoning one midway loses only
271
- // local state. Interruptible so a cancelled snapshot actually stops.
272
- const coordinatedRead = (effect) => gate.withPermit(effect);
569
+ const limits = Object.freeze({
570
+ maxBytes: settings.maxBytes,
571
+ maxFileBytes: ByteSize.bytes(maxFileBytes),
572
+ maxEntries: settings.maxEntries,
573
+ maxPathBytes: settings.maxPathBytes,
574
+ maxPendingOperations: settings.maxPendingOperations ?? 64,
575
+ maxWatchEvents: settings.maxWatchEvents ?? 256
576
+ });
273
577
  if (image !== undefined) {
274
578
  const incoming = new Map();
275
579
  let content = 0n;
@@ -294,7 +598,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
294
598
  const metadata = {
295
599
  ...record.metadata,
296
600
  kind: record._tag,
297
- ino: record.id === image.root ? 1n : nextInode++,
601
+ ino: record.id === image.root ? 1n : state.nextInode++,
298
602
  nlink: Image.Record.guards.directory(record) ? 2 : 0,
299
603
  size: 0n,
300
604
  atimeNs: record.metadata.atimeNs,
@@ -304,7 +608,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
304
608
  };
305
609
  if (Image.Record.guards.directory(record)) {
306
610
  const node = record.id === image.root
307
- ? root
611
+ ? state.root
308
612
  : {
309
613
  kind: "directory",
310
614
  lineage: record.id,
@@ -360,10 +664,191 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
360
664
  node.metadata = { ...node.metadata, nlink: node.metadata.nlink + 1 };
361
665
  }
362
666
  }
363
- entries = count;
364
- usedBytes = content;
667
+ state.entries = count;
668
+ state.usedBytes = content;
365
669
  }
366
- const watchHub = yield* WatchHub.make(coordinated);
670
+ if (live !== undefined) {
671
+ const incoming = new Map();
672
+ for (const record of live.records) {
673
+ const metadata = {
674
+ ...record.metadata,
675
+ kind: record._tag,
676
+ ino: record.ino
677
+ };
678
+ if (LiveImage.Record.guards.directory(record)) {
679
+ incoming.set(record.ino, {
680
+ kind: "directory",
681
+ lineage: record.lineage,
682
+ parent: undefined,
683
+ entries: new Map(),
684
+ metadata,
685
+ revision: record.revision,
686
+ objectReference: undefined
687
+ });
688
+ }
689
+ else if (LiveImage.Record.guards.file(record)) {
690
+ incoming.set(record.ino, {
691
+ kind: "file",
692
+ lineage: record.lineage,
693
+ data: Content.make(yield* CanonicalBase64.decode(record.data)),
694
+ openCount: 0,
695
+ metadata,
696
+ revision: record.revision,
697
+ objectReference: undefined
698
+ });
699
+ }
700
+ else {
701
+ incoming.set(record.ino, {
702
+ kind: "symlink",
703
+ lineage: record.lineage,
704
+ target: yield* CanonicalBase64.decode(record.target),
705
+ metadata,
706
+ revision: record.revision,
707
+ objectReference: undefined
708
+ });
709
+ }
710
+ }
711
+ for (const record of live.records) {
712
+ if (!LiveImage.Record.guards.directory(record))
713
+ continue;
714
+ const parent = incoming.get(record.ino);
715
+ if (parent?.kind !== "directory")
716
+ return yield* new ImageError({ code: "InvalidStructure" });
717
+ for (const entry of record.entries) {
718
+ const child = incoming.get(entry.target);
719
+ if (child === undefined)
720
+ return yield* new ImageError({ code: "InvalidStructure" });
721
+ parent.entries.set(Encoding.encodeHex(yield* CanonicalBase64.decode(entry.name)), child);
722
+ if (child.kind === "directory")
723
+ child.parent = parent;
724
+ }
725
+ }
726
+ const root = incoming.get(live.root);
727
+ if (root?.kind !== "directory")
728
+ return yield* new ImageError({ code: "InvalidStructure" });
729
+ state.root = root;
730
+ state.nextInode = live.nextInode;
731
+ state.revisionCounter = live.revisionCounter;
732
+ state.entries = live.entries;
733
+ state.usedBytes = live.usedBytes;
734
+ // A previous process's handles no longer exist. Their zero-link files
735
+ // remain in the stored image but are reclaimed from this runtime state.
736
+ for (const ino of live.retainedFiles) {
737
+ const orphan = incoming.get(ino);
738
+ if (orphan?.kind !== "file")
739
+ return yield* new ImageError({ code: "InvalidStructure" });
740
+ state.usedBytes -= BigInt(orphan.data.bytes.length);
741
+ }
742
+ }
743
+ if (captureInitial !== undefined)
744
+ yield* captureInitial(state, identity, limits);
745
+ const contexts = new WeakMap();
746
+ const copyState = (current) => Effect.sync(() => {
747
+ const nodes = new Map();
748
+ const copyDirectory = (node) => {
749
+ const existing = nodes.get(node);
750
+ if (existing?.kind === "directory")
751
+ return existing;
752
+ const copy = { ...node, entries: new Map(), metadata: { ...node.metadata }, parent: undefined };
753
+ nodes.set(node, copy);
754
+ copy.parent = node.parent === undefined ? undefined : copyDirectory(node.parent);
755
+ for (const [name, child] of node.entries)
756
+ copy.entries.set(name, copyNode(child));
757
+ return copy;
758
+ };
759
+ const copyNode = (node) => {
760
+ if (node.kind === "directory")
761
+ return copyDirectory(node);
762
+ const existing = nodes.get(node);
763
+ if (existing !== undefined)
764
+ return existing;
765
+ const copy = { ...node, metadata: { ...node.metadata } };
766
+ nodes.set(node, copy);
767
+ return copy;
768
+ };
769
+ const candidate = { ...current, root: copyDirectory(current.root), retainedFiles: new Map() };
770
+ for (const [ino, file] of current.retainedFiles) {
771
+ const copy = copyNode(file);
772
+ if (copy.kind === "file")
773
+ candidate.retainedFiles.set(ino, copy);
774
+ }
775
+ // Open resources keep zero-link objects alive after the namespace stops reaching them.
776
+ for (const reference of fileReferences) {
777
+ const file = reference.file;
778
+ if (file !== undefined)
779
+ copyNode(file);
780
+ }
781
+ for (const reference of directoryReferences) {
782
+ const directory = reference.directory;
783
+ if (directory !== undefined)
784
+ copyDirectory(directory);
785
+ }
786
+ contexts.set(candidate, {
787
+ nodes,
788
+ previous: new Map([...nodes].map(([prior, next]) => [next, prior])),
789
+ files: new Map(),
790
+ directories: new Map(),
791
+ invalidated: new Set(),
792
+ events: [],
793
+ apply: []
794
+ });
795
+ return candidate;
796
+ });
797
+ const staged = commitProvider === undefined ? undefined : makeStagedState(state, copyState, commitProvider, (candidate, events) => {
798
+ const context = contexts.get(candidate);
799
+ if (context === undefined)
800
+ throw new Error("Missing staged engine state");
801
+ state = candidate;
802
+ for (const [previous, next] of context.nodes) {
803
+ const cell = cells.get(previous);
804
+ if (cell !== undefined) {
805
+ cell.node = next;
806
+ cells.set(next, cell);
807
+ }
808
+ }
809
+ for (const apply of context.apply)
810
+ apply();
811
+ for (const reference of context.invalidated) {
812
+ reference.active = false;
813
+ reference.cell = undefined;
814
+ }
815
+ for (const event of events)
816
+ event();
817
+ });
818
+ if (staged !== undefined)
819
+ commitProvider?.onReady?.(staged.shutdown);
820
+ // Permit waits stay interruptible. Changes and their publication run under one permit.
821
+ const coordinated = (operation, effect, onStorageFailure) => admit(operation, staged === undefined
822
+ ? gate.withPermit(Effect.uninterruptible(effect))
823
+ : staged.mutate(operation, (candidate, emit) => Effect.gen(function* () {
824
+ const previous = state;
825
+ const context = contexts.get(candidate);
826
+ if (context === undefined)
827
+ return yield* Effect.die("Missing staged engine state");
828
+ state = candidate;
829
+ activeStage = context;
830
+ const result = yield* Effect.exit(effect);
831
+ state = previous;
832
+ activeStage = undefined;
833
+ if (Exit.isFailure(result))
834
+ return yield* Effect.failCause(result.cause);
835
+ for (const event of context.events)
836
+ emit(event);
837
+ return result.value;
838
+ }), onStorageFailure));
839
+ // Pure observations share the permit without making a candidate or calling the provider.
840
+ const coordinatedRead = (operation, effect) => admit(operation, staged === undefined ? gate.withPermit(effect) : staged.read(operation, () => effect));
841
+ const coordinatedCleanup = (effect) => staged === undefined
842
+ ? gate.withPermit(Effect.uninterruptible(effect))
843
+ : staged.coordinate(Effect.uninterruptible(effect));
844
+ const watchCoordinate = (effect) => staged === undefined ? gate.withPermit(effect) : staged.coordinate(effect);
845
+ const watchHub = yield* WatchHub.make(watchCoordinate, settings.maxWatchEvents ?? 256, () => RescanChange.make({ path: ownedPath(new Uint8Array([47])) }), staged?.checkAvailable("watch"));
846
+ const queuePublish = (publish) => {
847
+ if (activeStage === undefined)
848
+ publish();
849
+ else
850
+ activeStage.events.push(publish);
851
+ };
367
852
  const directoryHex = (directory) => {
368
853
  const names = [];
369
854
  let current = directory;
@@ -378,10 +863,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
378
863
  return SLASH_HEX + names.reverse().join(SLASH_HEX);
379
864
  };
380
865
  const publishEntry = (_tag, parent, name) => {
381
- watchHub.publishUnsafe(() => {
866
+ queuePublish(() => watchHub.publishUnsafe(() => {
382
867
  const prefix = directoryHex(parent);
383
868
  return { _tag, path: ownedPath(nameBytes(prefix + (prefix === SLASH_HEX ? "" : SLASH_HEX) + name)) };
384
- });
869
+ }));
385
870
  };
386
871
  const publishNode = (target) => {
387
872
  // Directories are never hard linked, so one name reaches them and the parent chain resolves it.
@@ -391,12 +876,12 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
391
876
  // The file scan below stops on the same signal, and `lookup` already reads it as NotFound.
392
877
  if (target.metadata.nlink === 0)
393
878
  return;
394
- watchHub.publishUnsafe(() => UpdateChange.make({ path: ownedPath(nameBytes(directoryHex(target))) }));
879
+ queuePublish(() => watchHub.publishUnsafe(() => UpdateChange.make({ path: ownedPath(nameBytes(directoryHex(target))) })));
395
880
  return;
396
881
  }
397
- watchHub.publishManyUnsafe(() => {
882
+ queuePublish(() => watchHub.publishManyUnsafe(() => {
398
883
  const changes = [];
399
- const pending = [[root, SLASH_HEX]];
884
+ const pending = [[state.root, SLASH_HEX]];
400
885
  // nlink counts the names bound to this node, so the scan stops once it has found them all.
401
886
  while (pending.length > 0 && changes.length < target.metadata.nlink) {
402
887
  const next = pending.pop();
@@ -413,11 +898,11 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
413
898
  }
414
899
  }
415
900
  return changes;
416
- });
901
+ }));
417
902
  };
418
903
  const captureSnapshot = Effect.fnUntraced(function* () {
419
- const ids = new Map([[root, "0"]]);
420
- const pending = [root];
904
+ const ids = new Map([[state.root, "0"]]);
905
+ const pending = [state.root];
421
906
  const records = [];
422
907
  for (let index = 0; index < pending.length; index++) {
423
908
  if (index % WALK_YIELD_INTERVAL === 0)
@@ -453,7 +938,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
453
938
  });
454
939
  const observeChanges = Effect.fnUntraced(function* () {
455
940
  const observation = [];
456
- const paths = [[root, new Uint8Array([SLASH_BYTE])]];
941
+ const paths = [[state.root, new Uint8Array([SLASH_BYTE])]];
457
942
  for (let index = 0; index < paths.length; index++) {
458
943
  if (index % WALK_YIELD_INTERVAL === 0)
459
944
  yield* Effect.yieldNow;
@@ -496,7 +981,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
496
981
  if (node.objectReference !== undefined)
497
982
  return node.objectReference;
498
983
  const reference = Object.freeze({ [ObjectReferenceId]: true });
499
- objectReferences.set(reference, { volume: volumeIdentity, node });
984
+ objectReferences.set(reference, { volume: volumeIdentity, cell: cellFor(node), active: true });
500
985
  node.objectReference = reference;
501
986
  return reference;
502
987
  };
@@ -505,11 +990,17 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
505
990
  if (reference === undefined)
506
991
  return;
507
992
  const state = objectReferences.get(reference);
508
- if (state !== undefined)
509
- state.node = undefined;
993
+ if (state !== undefined) {
994
+ if (activeStage === undefined) {
995
+ state.active = false;
996
+ state.cell = undefined;
997
+ }
998
+ else
999
+ activeStage.invalidated.add(state);
1000
+ }
510
1001
  node.objectReference = undefined;
511
1002
  };
512
- const release = (reference) => coordinated(Effect.sync(() => {
1003
+ const release = (reference) => coordinatedCleanup(Effect.sync(() => {
513
1004
  reference.directory = undefined;
514
1005
  reference.closed = true;
515
1006
  }));
@@ -530,13 +1021,14 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
530
1021
  };
531
1022
  const reclaim = (file) => {
532
1023
  if (file.metadata.nlink === 0 && file.openCount === 0) {
533
- usedBytes -= BigInt(file.data.bytes.length);
1024
+ state.usedBytes -= BigInt(file.data.bytes.length);
534
1025
  file.data = Content.empty();
1026
+ state.retainedFiles.delete(file.metadata.ino);
535
1027
  invalidateReference(file);
536
1028
  }
537
1029
  };
538
1030
  // Whether the volume's entry quota leaves room for one more name.
539
- const atEntryLimit = () => settings.maxEntries !== undefined && entries >= settings.maxEntries;
1031
+ const atEntryLimit = () => settings.maxEntries !== undefined && state.entries >= settings.maxEntries;
540
1032
  // A new subdirectory's ".." entry is a second link to the parent; other node kinds add none.
541
1033
  const attach = (parent, name, node, now) => {
542
1034
  parent.entries.set(name, node);
@@ -558,10 +1050,13 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
558
1050
  else {
559
1051
  node.metadata = { ...node.metadata, nlink: node.metadata.nlink - 1, ctimeNs: now };
560
1052
  advanceRevision(node);
561
- if (node.kind === "file")
1053
+ if (node.kind === "file") {
1054
+ if (node.metadata.nlink === 0 && node.openCount > 0)
1055
+ state.retainedFiles.set(node.metadata.ino, node);
562
1056
  reclaim(node);
1057
+ }
563
1058
  else if (node.metadata.nlink === 0) {
564
- usedBytes -= BigInt(node.target.length);
1059
+ state.usedBytes -= BigInt(node.target.length);
565
1060
  invalidateReference(node);
566
1061
  }
567
1062
  }
@@ -574,9 +1069,19 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
574
1069
  }
575
1070
  ref.closed = true;
576
1071
  };
1072
+ const finalizeFile = (ref) => Effect.suspend(() => ref.closed
1073
+ ? Effect.void
1074
+ : ref.file === undefined
1075
+ ? Effect.sync(() => {
1076
+ ref.closed = true;
1077
+ })
1078
+ : coordinated("close", Effect.sync(() => releaseFile(ref)), () => releaseFile(ref)).pipe(Effect.catch(() => coordinatedCleanup(Effect.sync(() => {
1079
+ if (!ref.closed)
1080
+ releaseFile(ref);
1081
+ })))));
577
1082
  // Replacing a payload clears setuid and setgid, and charges the volume for the size delta.
578
- const replaceContent = (file, data, now) => {
579
- usedBytes += BigInt(data.length - file.data.bytes.length);
1083
+ const replaceContent = (file, data, now, publish = true) => {
1084
+ state.usedBytes += BigInt(data.length - file.data.bytes.length);
580
1085
  file.data = Content.make(data);
581
1086
  file.metadata = {
582
1087
  ...file.metadata,
@@ -586,9 +1091,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
586
1091
  ctimeNs: now
587
1092
  };
588
1093
  advanceRevision(file);
589
- publishNode(file);
1094
+ if (publish)
1095
+ publishNode(file);
590
1096
  };
591
- const resize = Effect.fnUntraced(function* (file, length, operation) {
1097
+ const resize = Effect.fnUntraced(function* (file, length, operation, publish = true) {
592
1098
  if (!Predicate.isBigInt(length) || length < 0n) {
593
1099
  return yield* new FsError({ code: "InvalidArgument", operation: operation });
594
1100
  }
@@ -596,19 +1102,19 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
596
1102
  return yield* new FsError({ code: "FileTooLarge", operation: operation });
597
1103
  const size = Number(length);
598
1104
  if (settings.maxBytes !== undefined &&
599
- BigInt(size - file.data.bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes) {
1105
+ BigInt(size - file.data.bytes.length) > ByteSize.toBigInt(settings.maxBytes) - state.usedBytes) {
600
1106
  return yield* new FsError({ code: "NoSpace", operation: operation });
601
1107
  }
602
1108
  const data = new Uint8Array(size);
603
1109
  data.set(file.data.bytes.subarray(0, size));
604
- replaceContent(file, data, yield* timestamp(operation));
1110
+ replaceContent(file, data, yield* timestamp(operation), publish);
605
1111
  });
606
1112
  const fileHandle = (ref) => {
607
1113
  const get = (operation, access) => ref.file === undefined || (access === "read" && ref.access === "write") ||
608
1114
  (access === "write" && ref.access === "read")
609
1115
  ? Effect.fail(new FsError({ code: "InvalidHandle", operation: operation }))
610
1116
  : Effect.succeed(ref.file);
611
- const read = Effect.fnUntraced(function* (maximum, position) {
1117
+ const read = (maximum, position) => coordinated(position === undefined ? "read" : "pread", Effect.gen(function* () {
612
1118
  const file = yield* get(position === undefined ? "read" : "pread", "read");
613
1119
  if (!isNatural(maximum))
614
1120
  return yield* new FsError({ code: "InvalidArgument", operation: "read" });
@@ -624,12 +1130,12 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
624
1130
  if (position === undefined)
625
1131
  ref.offset += BigInt(data.length);
626
1132
  return data;
627
- }, coordinated);
1133
+ }));
628
1134
  const write = Effect.fnUntraced(function* (input, position) {
629
1135
  if (!isAttachedBytes(input))
630
1136
  return yield* new FsError({ code: "InvalidArgument", operation: "write" });
631
1137
  const bytes = new Uint8Array(input);
632
- return yield* coordinated(Effect.gen(function* () {
1138
+ return yield* coordinated(position === undefined ? "write" : "pwrite", Effect.gen(function* () {
633
1139
  const file = yield* get(position === undefined ? "write" : "pwrite", "write");
634
1140
  const offset = position ?? (ref.append ? file.metadata.size : ref.offset);
635
1141
  if (!Predicate.isBigInt(offset) || offset < 0n || offset > MAX_FILE_OFFSET) {
@@ -643,7 +1149,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
643
1149
  const start = Number(offset);
644
1150
  const free = settings.maxBytes === undefined
645
1151
  ? BigInt(maxFileBytes)
646
- : ByteSize.toBigInt(settings.maxBytes) - usedBytes;
1152
+ : ByteSize.toBigInt(settings.maxBytes) - state.usedBytes;
647
1153
  const maximumEnd = BigInt(file.data.bytes.length) + free;
648
1154
  const end = Number(BigInt(maxFileBytes) < maximumEnd ? BigInt(maxFileBytes) : maximumEnd);
649
1155
  const count = Math.min(bytes.length, Math.max(0, end - start));
@@ -677,12 +1183,16 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
677
1183
  return yield* write(bytes, offset);
678
1184
  }),
679
1185
  seek: Effect.fn("FileHandle.seek")(function* (offset, mode) {
680
- return yield* coordinated(Effect.gen(function* () {
1186
+ return yield* coordinatedRead("seek", Effect.uninterruptible(Effect.gen(function* () {
681
1187
  const file = yield* get("seek");
682
1188
  if (!Predicate.isBigInt(offset) || !isSeekMode(mode)) {
683
1189
  return yield* new FsError({ code: "InvalidArgument", operation: "seek" });
684
1190
  }
685
- let next = mode === "current" ? ref.offset + offset : mode === "end" ? file.metadata.size + offset : offset;
1191
+ let next = mode === "current"
1192
+ ? ref.offset + offset
1193
+ : mode === "end"
1194
+ ? file.metadata.size + offset
1195
+ : offset;
686
1196
  if (next < 0n || next > MAX_FILE_OFFSET) {
687
1197
  return yield* new FsError({ code: "InvalidArgument", operation: "seek" });
688
1198
  }
@@ -694,21 +1204,24 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
694
1204
  }
695
1205
  ref.offset = next;
696
1206
  return next;
697
- }));
1207
+ })));
698
1208
  }),
699
1209
  truncate: Effect.fn("FileHandle.truncate")(function* (length) {
700
- return yield* coordinated(Effect.gen(function* () {
1210
+ return yield* coordinated("truncate", Effect.gen(function* () {
701
1211
  yield* resize(yield* get("truncate", "write"), length, "truncate");
702
1212
  }));
703
1213
  }),
704
- stat: coordinated(Effect.gen(function* () {
1214
+ stat: coordinatedRead("stat", Effect.gen(function* () {
705
1215
  return { ...(yield* get("stat")).metadata };
706
1216
  })).pipe(Effect.withSpan("FileHandle.stat")),
707
- sync: coordinated(Effect.suspend(() => Effect.asVoid(get("sync")))).pipe(Effect.withSpan("FileHandle.sync")),
708
- close: coordinated(Effect.gen(function* () {
1217
+ sync: coordinatedRead("sync", Effect.suspend(() => Effect.asVoid(get("sync")))).pipe(Effect.withSpan("FileHandle.sync")),
1218
+ close: coordinated("close", Effect.gen(function* () {
709
1219
  yield* get("close");
710
1220
  releaseFile(ref);
711
- })).pipe(Effect.withSpan("FileHandle.close"))
1221
+ }), () => releaseFile(ref)).pipe(Effect.catch((error) => coordinatedCleanup(Effect.sync(() => {
1222
+ if (!ref.closed)
1223
+ releaseFile(ref);
1224
+ })).pipe(Effect.andThen(Effect.fail(error)))), Effect.withSpan("FileHandle.close"))
712
1225
  });
713
1226
  files.set(handle, ref);
714
1227
  return handle;
@@ -725,17 +1238,44 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
725
1238
  if (state.volume !== volumeIdentity) {
726
1239
  return yield* new FsError({ code: "ForeignReference", operation: operation });
727
1240
  }
728
- if (state.node === undefined)
1241
+ if (!state.active || activeStage?.invalidated.has(state)) {
729
1242
  return yield* new FsError({ code: "StaleReference", operation: operation });
730
- return state.node;
1243
+ }
1244
+ const node = state.cell?.node;
1245
+ if (node === undefined)
1246
+ return yield* new FsError({ code: "StaleReference", operation });
1247
+ return node;
1248
+ });
1249
+ const referencedName = Effect.fnUntraced(function* (input, operation) {
1250
+ if (!isAttachedBytes(input) || input.length === 0 || input.length > MAX_NAME_BYTES || input.includes(0) ||
1251
+ input.includes(SLASH_BYTE))
1252
+ return yield* new FsError({ code: "InvalidArgument", operation });
1253
+ const name = Encoding.encodeHex(new Uint8Array(input));
1254
+ if (isDotComponent(name))
1255
+ return yield* new FsError({ code: "InvalidArgument", operation });
1256
+ return name;
1257
+ });
1258
+ const referencedDirectory = Effect.fnUntraced(function* (target, operation) {
1259
+ const node = yield* referencedNode(target, operation);
1260
+ if (node.kind !== "directory")
1261
+ return yield* new FsError({ code: "NotDirectory", operation });
1262
+ return node;
1263
+ });
1264
+ const creationTimes = (times, now) => ({
1265
+ atimeNs: times?.access.kind === "value" ? times.access.nanoseconds : now,
1266
+ mtimeNs: times?.modification.kind === "value" ? times.modification.nanoseconds : now
731
1267
  });
732
- const lookup = Effect.fnUntraced(function* (path, base, operation, options = {}) {
1268
+ const lookup = Effect.fnUntraced(function* (path, base, operation, options = {}, referencedBase) {
733
1269
  const { followFinalSymlink = true, allowMissing = false, parentOnly = false } = options;
734
1270
  if (reference.directory === undefined) {
735
1271
  return yield* new FsError({ code: "ClosedCaller", operation: operation, path: path.input });
736
1272
  }
737
- let current = path.absolute ? root : reference.directory;
738
- if (!path.absolute && base !== undefined) {
1273
+ let current = path.absolute ? state.root : reference.directory;
1274
+ if (!path.absolute && referencedBase !== undefined) {
1275
+ current = referencedBase;
1276
+ yield* authorize(current, identity, EXECUTE, operation);
1277
+ }
1278
+ else if (!path.absolute && base !== undefined) {
739
1279
  const target = handles.get(base);
740
1280
  if (target === undefined) {
741
1281
  return yield* new FsError({ code: "InvalidHandle", operation: operation, path: path.input });
@@ -804,7 +1344,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
804
1344
  }
805
1345
  work = expanded.success;
806
1346
  if (work.absolute)
807
- current = root;
1347
+ current = state.root;
808
1348
  index = -1;
809
1349
  }
810
1350
  else
@@ -836,11 +1376,11 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
836
1376
  const acquireDirectory = Effect.fnUntraced(function* (input, options, operation) {
837
1377
  const prepared = preparePath(input, operation, settings.maxPathBytes);
838
1378
  const base = options?.relativeTo;
839
- const acquired = { volume: volumeIdentity, directory: undefined, closed: false };
1379
+ const acquired = makeDirectoryReference();
840
1380
  // Register before retaining a directory. Closed scopes can run this immediately,
841
1381
  // so registration must not happen while holding the volume permit.
842
1382
  yield* Effect.addFinalizer(() => release(acquired));
843
- return yield* coordinated(Effect.gen(function* () {
1383
+ return yield* coordinatedRead(operation, Effect.uninterruptible(Effect.gen(function* () {
844
1384
  if (acquired.closed)
845
1385
  return yield* Effect.interrupt;
846
1386
  const path = yield* Effect.fromResult(prepared);
@@ -848,12 +1388,12 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
848
1388
  yield* authorize(directory, identity, EXECUTE, operation, input);
849
1389
  acquired.directory = directory;
850
1390
  return acquired;
851
- }));
1391
+ })));
852
1392
  });
853
1393
  const list = Effect.fnUntraced(function* (input, options) {
854
1394
  const prepared = preparePath(input, "readDirectory", settings.maxPathBytes);
855
1395
  const base = options?.relativeTo;
856
- return yield* coordinated(Effect.gen(function* () {
1396
+ return yield* coordinated("readDirectory", Effect.gen(function* () {
857
1397
  const directory = yield* locate(yield* Effect.fromResult(prepared), base, "readDirectory");
858
1398
  yield* authorize(directory, identity, READ, "readDirectory", input);
859
1399
  const result = [...directory.entries.keys()].map(nameBytes);
@@ -864,7 +1404,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
864
1404
  const readTarget = Effect.fnUntraced(function* (input, options) {
865
1405
  const prepared = preparePath(input, "readLink", settings.maxPathBytes);
866
1406
  const base = options?.relativeTo;
867
- return yield* coordinated(Effect.gen(function* () {
1407
+ return yield* coordinatedRead("readLink", Effect.gen(function* () {
868
1408
  const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "readLink", {
869
1409
  followFinalSymlink: false
870
1410
  });
@@ -877,7 +1417,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
877
1417
  const canonical = Effect.fnUntraced(function* (input, options) {
878
1418
  const prepared = preparePath(input, "realPath", settings.maxPathBytes);
879
1419
  const base = options?.relativeTo;
880
- return yield* coordinated(Effect.gen(function* () {
1420
+ return yield* coordinatedRead("realPath", Effect.gen(function* () {
881
1421
  const result = yield* lookup(yield* Effect.fromResult(prepared), base, "realPath");
882
1422
  const components = [];
883
1423
  if (result.node?.kind !== "directory" && result.name !== undefined)
@@ -886,8 +1426,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
886
1426
  while (directory !== undefined && directory.parent !== undefined) {
887
1427
  const parent = directory.parent;
888
1428
  const entry = [...parent.entries].find(([, child]) => child === directory);
889
- if (entry === undefined)
1429
+ if (entry === undefined) {
890
1430
  return yield* new FsError({ code: "NotFound", operation: "realPath", path: input });
1431
+ }
891
1432
  components.push(entry[0]);
892
1433
  directory = parent;
893
1434
  }
@@ -928,7 +1469,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
928
1469
  if (!isMode(mode))
929
1470
  return yield* new FsError({ code: "InvalidArgument", operation: "chmod" });
930
1471
  const chosen = options === undefined ? undefined : { ...options };
931
- return yield* coordinated(Effect.gen(function* () {
1472
+ return yield* coordinated("chmod", Effect.gen(function* () {
932
1473
  const node = yield* metadataNode(target, chosen, "chmod");
933
1474
  const permitted = yield* permittedMode(node.metadata, mode, "chmod");
934
1475
  node.metadata = {
@@ -946,7 +1487,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
946
1487
  return yield* new FsError({ code: "InvalidArgument", operation: "chown" });
947
1488
  const update = { ...decoded.success };
948
1489
  const chosen = options === undefined ? undefined : { ...options };
949
- return yield* coordinated(Effect.gen(function* () {
1490
+ return yield* coordinated("chown", Effect.gen(function* () {
950
1491
  const node = yield* metadataNode(target, chosen, "chown");
951
1492
  if (!identity.privileged && (identity.uid !== node.metadata.uid ||
952
1493
  (update.uid !== undefined && update.uid !== node.metadata.uid) ||
@@ -973,7 +1514,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
973
1514
  const access = { ...decoded.success.access };
974
1515
  const modification = { ...decoded.success.modification };
975
1516
  const chosen = options === undefined ? undefined : { ...options };
976
- return yield* coordinated(Effect.gen(function* () {
1517
+ return yield* coordinated("utimes", Effect.gen(function* () {
977
1518
  const node = yield* metadataNode(target, chosen, "utimes");
978
1519
  if (access.kind === "omit" && modification.kind === "omit")
979
1520
  return;
@@ -1009,15 +1550,17 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1009
1550
  });
1010
1551
  const authorizeRemoval = (parent, child, operation, input) => (parent.metadata.mode & STICKY_BIT) !== 0 && !identity.privileged &&
1011
1552
  identity.uid !== parent.metadata.uid && identity.uid !== child.metadata.uid
1012
- ? Effect.fail(new FsError({ code: "AccessDenied", operation: operation, path: input }))
1553
+ ? Effect.fail(input === undefined
1554
+ ? new FsError({ code: "AccessDenied", operation })
1555
+ : new FsError({ code: "AccessDenied", operation, path: input }))
1013
1556
  : Effect.void;
1014
1557
  return Object.freeze({
1015
1558
  [CallerId]: true,
1016
- rootReference: coordinated(Effect.gen(function* () {
1559
+ rootReference: coordinatedRead("rootReference", Effect.gen(function* () {
1017
1560
  if (reference.directory === undefined) {
1018
1561
  return yield* new FsError({ code: "ClosedCaller", operation: "rootReference" });
1019
1562
  }
1020
- return referenceFor(root);
1563
+ return referenceFor(state.root);
1021
1564
  })).pipe(Effect.withSpan("Caller.rootReference")),
1022
1565
  lookupReference: Effect.fn("Caller.lookupReference")(function* (directoryReference, name) {
1023
1566
  if (!isAttachedBytes(name) || name.length === 0 || name.length > MAX_NAME_BYTES || name.includes(0) ||
@@ -1026,7 +1569,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1026
1569
  const key = Encoding.encodeHex(new Uint8Array(name));
1027
1570
  if (isDotComponent(key))
1028
1571
  return yield* new FsError({ code: "InvalidArgument", operation: "lookupReference" });
1029
- return yield* coordinated(Effect.gen(function* () {
1572
+ return yield* coordinatedRead("lookupReference", Effect.gen(function* () {
1030
1573
  const directory = yield* referencedNode(directoryReference, "lookupReference");
1031
1574
  if (directory.kind !== "directory") {
1032
1575
  return yield* new FsError({ code: "NotDirectory", operation: "lookupReference" });
@@ -1039,7 +1582,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1039
1582
  }));
1040
1583
  }),
1041
1584
  parentReference: Effect.fn("Caller.parentReference")(function* (directoryReference) {
1042
- return yield* coordinated(Effect.gen(function* () {
1585
+ return yield* coordinatedRead("parentReference", Effect.gen(function* () {
1043
1586
  const directory = yield* referencedNode(directoryReference, "parentReference");
1044
1587
  if (directory.kind !== "directory") {
1045
1588
  return yield* new FsError({ code: "NotDirectory", operation: "parentReference" });
@@ -1049,13 +1592,25 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1049
1592
  }));
1050
1593
  }),
1051
1594
  observeMetadata: Effect.fn("Caller.observeMetadata")(function* (objectReference) {
1052
- return yield* coordinated(Effect.gen(function* () {
1595
+ return yield* coordinatedRead("observeMetadata", Effect.gen(function* () {
1053
1596
  const node = yield* referencedNode(objectReference, "observeMetadata");
1054
1597
  return Object.freeze({ value: Object.freeze({ ...node.metadata }), revision: node.revision });
1055
1598
  }));
1056
1599
  }),
1600
+ accessReference: Effect.fn("Caller.accessReference")(function* (objectReference, bits = 0) {
1601
+ if (!Number.isInteger(bits) || bits < 0 || bits > (READ | WRITE | EXECUTE)) {
1602
+ return yield* new FsError({ code: "InvalidArgument", operation: "accessReference" });
1603
+ }
1604
+ return yield* coordinatedRead("accessReference", Effect.gen(function* () {
1605
+ const node = yield* referencedNode(objectReference, "accessReference");
1606
+ if (node.kind === "file" && (bits & EXECUTE) !== 0 && (node.metadata.mode & ANY_EXECUTE) === 0) {
1607
+ return yield* new FsError({ code: "AccessDenied", operation: "accessReference" });
1608
+ }
1609
+ yield* authorize(node, identity, bits, "accessReference");
1610
+ }));
1611
+ }),
1057
1612
  observeDirectory: Effect.fn("Caller.observeDirectory")(function* (directoryReference) {
1058
- return yield* coordinated(Effect.gen(function* () {
1613
+ return yield* coordinatedRead("observeDirectory", Effect.gen(function* () {
1059
1614
  const directory = yield* referencedNode(directoryReference, "observeDirectory");
1060
1615
  if (directory.kind !== "directory") {
1061
1616
  return yield* new FsError({ code: "NotDirectory", operation: "observeDirectory" });
@@ -1066,7 +1621,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1066
1621
  }));
1067
1622
  }),
1068
1623
  readLinkReference: Effect.fn("Caller.readLinkReference")(function* (objectReference) {
1069
- return yield* coordinated(Effect.gen(function* () {
1624
+ return yield* coordinatedRead("readLinkReference", Effect.gen(function* () {
1070
1625
  const node = yield* referencedNode(objectReference, "readLinkReference");
1071
1626
  if (node.kind !== "symlink") {
1072
1627
  return yield* new FsError({ code: "InvalidArgument", operation: "readLinkReference" });
@@ -1074,17 +1629,394 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1074
1629
  return new Uint8Array(node.target);
1075
1630
  }));
1076
1631
  }),
1077
- openReference: Effect.fn("Caller.openReference")(function* (objectReference) {
1078
- const acquired = {
1079
- volume: volumeIdentity,
1080
- file: undefined,
1081
- closed: false,
1082
- offset: 0n,
1083
- access: "read",
1084
- append: false
1085
- };
1086
- yield* Effect.addFinalizer(() => coordinated(Effect.sync(() => releaseFile(acquired))));
1087
- return yield* coordinated(Effect.gen(function* () {
1632
+ mkdirReference: Effect.fn("Caller.mkdirReference")(function* (directoryReference, input, raw = {}) {
1633
+ const name = yield* referencedName(input, "mkdirReference");
1634
+ const decoded = decodeMkdirReferenceSettings(raw);
1635
+ if (Result.isFailure(decoded)) {
1636
+ return yield* new FsError({ code: "InvalidArgument", operation: "mkdirReference" });
1637
+ }
1638
+ const chosen = { ...decoded.success };
1639
+ if (chosen.exactMode && chosen.mode === undefined) {
1640
+ return yield* new FsError({ code: "InvalidArgument", operation: "mkdirReference" });
1641
+ }
1642
+ const mode = chosen.mode ?? 0o777;
1643
+ return yield* coordinated("mkdirReference", Effect.gen(function* () {
1644
+ const parent = yield* referencedDirectory(directoryReference, "mkdirReference");
1645
+ yield* authorize(parent, identity, WRITE | EXECUTE, "mkdirReference");
1646
+ if (parent.entries.has(name)) {
1647
+ return yield* new FsError({ code: "AlreadyExists", operation: "mkdirReference" });
1648
+ }
1649
+ if (atEntryLimit())
1650
+ return yield* new FsError({ code: "NoSpace", operation: "mkdirReference" });
1651
+ const before = parent.revision;
1652
+ const now = yield* timestamp("mkdirReference");
1653
+ const initial = creationTimes(chosen.times, now);
1654
+ const creationMode = chosen.exactMode
1655
+ ? yield* permittedMode({ kind: "directory", uid: identity.uid, gid: parent.metadata.gid }, mode, "mkdirReference")
1656
+ : (mode & 0o777 & ~umask) | (mode & STICKY_BIT);
1657
+ const child = {
1658
+ kind: "directory",
1659
+ lineage: undefined,
1660
+ parent,
1661
+ entries: new Map(),
1662
+ metadata: {
1663
+ ...directoryMetadata(state.nextInode, identity.uid, parent.metadata.gid, creationMode, now),
1664
+ ...initial
1665
+ },
1666
+ revision: nextRevision(),
1667
+ objectReference: undefined
1668
+ };
1669
+ attach(parent, name, child, now);
1670
+ state.nextInode += 1n;
1671
+ state.entries += 1;
1672
+ publishEntry("Create", parent, name);
1673
+ return { reference: referenceFor(child), directory: { before, after: parent.revision } };
1674
+ }));
1675
+ }),
1676
+ symlinkReference: Effect.fn("Caller.symlinkReference")(function* (target, directoryReference, input, raw = {}) {
1677
+ const name = yield* referencedName(input, "symlinkReference");
1678
+ const decoded = decodeSymlinkReferenceSettings(raw);
1679
+ if (Result.isFailure(decoded)) {
1680
+ return yield* new FsError({ code: "InvalidArgument", operation: "symlinkReference" });
1681
+ }
1682
+ const rawTarget = inputBytes(target);
1683
+ if (Result.isFailure(rawTarget)) {
1684
+ return yield* new FsError({ code: rawTarget.failure, operation: "symlinkReference" });
1685
+ }
1686
+ if (rawTarget.success.includes(0)) {
1687
+ return yield* new FsError({ code: "InvalidArgument", operation: "symlinkReference" });
1688
+ }
1689
+ const targetBytes = new Uint8Array(rawTarget.success);
1690
+ const chosen = { ...decoded.success };
1691
+ return yield* coordinated("symlinkReference", Effect.gen(function* () {
1692
+ const parent = yield* referencedDirectory(directoryReference, "symlinkReference");
1693
+ yield* authorize(parent, identity, WRITE | EXECUTE, "symlinkReference");
1694
+ if (parent.entries.has(name)) {
1695
+ return yield* new FsError({ code: "AlreadyExists", operation: "symlinkReference" });
1696
+ }
1697
+ if (atEntryLimit() ||
1698
+ (settings.maxBytes !== undefined &&
1699
+ BigInt(targetBytes.length) > ByteSize.toBigInt(settings.maxBytes) - state.usedBytes))
1700
+ return yield* new FsError({ code: "NoSpace", operation: "symlinkReference" });
1701
+ const before = parent.revision;
1702
+ const now = yield* timestamp("symlinkReference");
1703
+ const initial = creationTimes(chosen.times, now);
1704
+ const node = {
1705
+ kind: "symlink",
1706
+ lineage: undefined,
1707
+ target: targetBytes,
1708
+ metadata: {
1709
+ ...directoryMetadata(state.nextInode, identity.uid, parent.metadata.gid, 0o777, now),
1710
+ ...initial,
1711
+ kind: "symlink",
1712
+ nlink: 1,
1713
+ size: BigInt(targetBytes.length)
1714
+ },
1715
+ revision: nextRevision(),
1716
+ objectReference: undefined
1717
+ };
1718
+ attach(parent, name, node, now);
1719
+ state.nextInode += 1n;
1720
+ state.entries += 1;
1721
+ state.usedBytes += BigInt(targetBytes.length);
1722
+ publishEntry("Create", parent, name);
1723
+ return { reference: referenceFor(node), directory: { before, after: parent.revision } };
1724
+ }));
1725
+ }),
1726
+ linkReference: Effect.fn("Caller.linkReference")(function* (sourceReference, directoryReference, input) {
1727
+ const name = yield* referencedName(input, "linkReference");
1728
+ return yield* coordinated("linkReference", Effect.gen(function* () {
1729
+ const node = yield* referencedNode(sourceReference, "linkReference");
1730
+ if (node.kind === "directory") {
1731
+ return yield* new FsError({ code: "IsDirectory", operation: "linkReference" });
1732
+ }
1733
+ if (node.metadata.nlink === 0) {
1734
+ return yield* new FsError({ code: "StaleReference", operation: "linkReference" });
1735
+ }
1736
+ const parent = yield* referencedDirectory(directoryReference, "linkReference");
1737
+ yield* authorize(parent, identity, WRITE | EXECUTE, "linkReference");
1738
+ if (parent.entries.has(name)) {
1739
+ return yield* new FsError({ code: "AlreadyExists", operation: "linkReference" });
1740
+ }
1741
+ if (atEntryLimit())
1742
+ return yield* new FsError({ code: "NoSpace", operation: "linkReference" });
1743
+ const before = parent.revision;
1744
+ const now = yield* timestamp("linkReference");
1745
+ attach(parent, name, node, now);
1746
+ node.metadata = { ...node.metadata, nlink: node.metadata.nlink + 1, ctimeNs: now };
1747
+ advanceRevision(node);
1748
+ state.entries += 1;
1749
+ publishEntry("Create", parent, name);
1750
+ return { reference: referenceFor(node), directory: { before, after: parent.revision } };
1751
+ }));
1752
+ }),
1753
+ unlinkReference: Effect.fn("Caller.unlinkReference")(function* (directoryReference, input) {
1754
+ const name = yield* referencedName(input, "unlinkReference");
1755
+ return yield* coordinated("unlinkReference", Effect.gen(function* () {
1756
+ const parent = yield* referencedDirectory(directoryReference, "unlinkReference");
1757
+ yield* authorize(parent, identity, WRITE | EXECUTE, "unlinkReference");
1758
+ const child = parent.entries.get(name);
1759
+ if (child === undefined)
1760
+ return yield* new FsError({ code: "NotFound", operation: "unlinkReference" });
1761
+ if (child.kind === "directory") {
1762
+ return yield* new FsError({ code: "IsDirectory", operation: "unlinkReference" });
1763
+ }
1764
+ yield* authorizeRemoval(parent, child, "unlinkReference");
1765
+ const before = parent.revision;
1766
+ const now = yield* timestamp("unlinkReference");
1767
+ parent.entries.delete(name);
1768
+ parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
1769
+ advanceRevision(parent);
1770
+ detach(child, now);
1771
+ state.entries -= 1;
1772
+ publishEntry("Remove", parent, name);
1773
+ return { before, after: parent.revision };
1774
+ }));
1775
+ }),
1776
+ rmdirReference: Effect.fn("Caller.rmdirReference")(function* (directoryReference, input) {
1777
+ const name = yield* referencedName(input, "rmdirReference");
1778
+ return yield* coordinated("rmdirReference", Effect.gen(function* () {
1779
+ const parent = yield* referencedDirectory(directoryReference, "rmdirReference");
1780
+ yield* authorize(parent, identity, WRITE | EXECUTE, "rmdirReference");
1781
+ const child = parent.entries.get(name);
1782
+ if (child === undefined)
1783
+ return yield* new FsError({ code: "NotFound", operation: "rmdirReference" });
1784
+ yield* authorizeRemoval(parent, child, "rmdirReference");
1785
+ if (child.kind !== "directory") {
1786
+ return yield* new FsError({ code: "NotDirectory", operation: "rmdirReference" });
1787
+ }
1788
+ if (child.entries.size > 0) {
1789
+ return yield* new FsError({ code: "NotEmpty", operation: "rmdirReference" });
1790
+ }
1791
+ const before = parent.revision;
1792
+ const now = yield* timestamp("rmdirReference");
1793
+ parent.entries.delete(name);
1794
+ parent.metadata = { ...parent.metadata, nlink: parent.metadata.nlink - 1, mtimeNs: now, ctimeNs: now };
1795
+ child.parent = undefined;
1796
+ child.metadata = { ...child.metadata, nlink: 0, ctimeNs: now };
1797
+ advanceRevision(parent);
1798
+ advanceRevision(child);
1799
+ invalidateReference(child);
1800
+ state.entries -= 1;
1801
+ publishEntry("Remove", parent, name);
1802
+ return { before, after: parent.revision };
1803
+ }));
1804
+ }),
1805
+ removeReference: Effect.fn("Caller.removeReference")(function* (directoryReference, input) {
1806
+ const name = yield* referencedName(input, "removeReference");
1807
+ return yield* coordinated("removeReference", Effect.gen(function* () {
1808
+ const parent = yield* referencedDirectory(directoryReference, "removeReference");
1809
+ yield* authorize(parent, identity, WRITE | EXECUTE, "removeReference");
1810
+ const child = parent.entries.get(name);
1811
+ if (child === undefined)
1812
+ return yield* new FsError({ code: "NotFound", operation: "removeReference" });
1813
+ yield* authorizeRemoval(parent, child, "removeReference");
1814
+ if (child.kind === "directory" && child.entries.size > 0) {
1815
+ return yield* new FsError({ code: "NotEmpty", operation: "removeReference" });
1816
+ }
1817
+ const before = parent.revision;
1818
+ const now = yield* timestamp("removeReference");
1819
+ parent.entries.delete(name);
1820
+ parent.metadata = {
1821
+ ...parent.metadata,
1822
+ nlink: parent.metadata.nlink - (child.kind === "directory" ? 1 : 0),
1823
+ mtimeNs: now,
1824
+ ctimeNs: now
1825
+ };
1826
+ if (child.kind === "directory") {
1827
+ child.parent = undefined;
1828
+ child.metadata = { ...child.metadata, nlink: 0, ctimeNs: now };
1829
+ advanceRevision(parent);
1830
+ advanceRevision(child);
1831
+ invalidateReference(child);
1832
+ }
1833
+ else {
1834
+ advanceRevision(parent);
1835
+ detach(child, now);
1836
+ }
1837
+ state.entries -= 1;
1838
+ publishEntry("Remove", parent, name);
1839
+ return { before, after: parent.revision };
1840
+ }));
1841
+ }),
1842
+ renameReference: Effect.fn("Caller.renameReference")(function* (sourceDirectoryReference, sourceInput, destinationDirectoryReference, destinationInput) {
1843
+ const sourceName = yield* referencedName(sourceInput, "renameReference");
1844
+ const destinationName = yield* referencedName(destinationInput, "renameReference");
1845
+ return yield* coordinated("renameReference", Effect.gen(function* () {
1846
+ const sourceDirectory = yield* referencedDirectory(sourceDirectoryReference, "renameReference");
1847
+ const destinationDirectory = yield* referencedDirectory(destinationDirectoryReference, "renameReference");
1848
+ yield* authorize(sourceDirectory, identity, WRITE | EXECUTE, "renameReference");
1849
+ yield* authorize(destinationDirectory, identity, WRITE | EXECUTE, "renameReference");
1850
+ const sourceBefore = sourceDirectory.revision;
1851
+ const destinationBefore = destinationDirectory.revision;
1852
+ const child = sourceDirectory.entries.get(sourceName);
1853
+ if (child === undefined) {
1854
+ return yield* new FsError({ code: "NotFound", operation: "renameReference" });
1855
+ }
1856
+ const replaced = destinationDirectory.entries.get(destinationName);
1857
+ if (child === replaced) {
1858
+ return sourceDirectory === destinationDirectory
1859
+ ? { _tag: "SameDirectory", directory: { before: sourceBefore, after: sourceBefore } }
1860
+ : {
1861
+ _tag: "DifferentDirectories",
1862
+ sourceDirectory: { before: sourceBefore, after: sourceBefore },
1863
+ destinationDirectory: { before: destinationBefore, after: destinationBefore }
1864
+ };
1865
+ }
1866
+ yield* authorizeRemoval(sourceDirectory, child, "renameReference");
1867
+ if (replaced !== undefined) {
1868
+ yield* authorizeRemoval(destinationDirectory, replaced, "renameReference");
1869
+ if (child.kind === "directory" && replaced.kind !== "directory") {
1870
+ return yield* new FsError({ code: "NotDirectory", operation: "renameReference" });
1871
+ }
1872
+ if (child.kind !== "directory" && replaced.kind === "directory") {
1873
+ return yield* new FsError({ code: "IsDirectory", operation: "renameReference" });
1874
+ }
1875
+ if (replaced.kind === "directory" && replaced.entries.size > 0) {
1876
+ return yield* new FsError({ code: "NotEmpty", operation: "renameReference" });
1877
+ }
1878
+ }
1879
+ for (let ancestor = destinationDirectory; ancestor !== undefined; ancestor = ancestor.parent) {
1880
+ if (ancestor === child) {
1881
+ return yield* new FsError({ code: "InvalidArgument", operation: "renameReference" });
1882
+ }
1883
+ }
1884
+ const now = yield* timestamp("renameReference");
1885
+ const oldEvent = () => ownedPath(nameBytes(directoryHex(sourceDirectory) + (sourceDirectory === state.root ? "" : SLASH_HEX) + sourceName));
1886
+ sourceDirectory.entries.delete(sourceName);
1887
+ destinationDirectory.entries.set(destinationName, child);
1888
+ if (child.kind === "directory")
1889
+ child.parent = destinationDirectory;
1890
+ sourceDirectory.metadata = {
1891
+ ...sourceDirectory.metadata,
1892
+ nlink: sourceDirectory.metadata.nlink - (child.kind === "directory" ? 1 : 0),
1893
+ mtimeNs: now,
1894
+ ctimeNs: now
1895
+ };
1896
+ destinationDirectory.metadata = {
1897
+ ...destinationDirectory.metadata,
1898
+ nlink: destinationDirectory.metadata.nlink +
1899
+ (child.kind === "directory" && replaced === undefined ? 1 : 0),
1900
+ mtimeNs: now,
1901
+ ctimeNs: now
1902
+ };
1903
+ child.metadata = { ...child.metadata, ctimeNs: now };
1904
+ advanceRevision(sourceDirectory);
1905
+ if (destinationDirectory !== sourceDirectory)
1906
+ advanceRevision(destinationDirectory);
1907
+ advanceRevision(child);
1908
+ if (replaced !== undefined) {
1909
+ detach(replaced, now);
1910
+ state.entries -= 1;
1911
+ }
1912
+ queuePublish(() => watchHub.publishUnsafe(() => ({ _tag: "Remove", path: oldEvent() })));
1913
+ publishEntry("Create", destinationDirectory, destinationName);
1914
+ return sourceDirectory === destinationDirectory
1915
+ ? {
1916
+ _tag: "SameDirectory",
1917
+ directory: { before: sourceBefore, after: sourceDirectory.revision }
1918
+ }
1919
+ : {
1920
+ _tag: "DifferentDirectories",
1921
+ sourceDirectory: { before: sourceBefore, after: sourceDirectory.revision },
1922
+ destinationDirectory: { before: destinationBefore, after: destinationDirectory.revision }
1923
+ };
1924
+ }));
1925
+ }),
1926
+ chmodReference: Effect.fn("Caller.chmodReference")(function* (objectReference, mode) {
1927
+ if (!isMode(mode))
1928
+ return yield* new FsError({ code: "InvalidArgument", operation: "chmodReference" });
1929
+ return yield* coordinated("chmodReference", Effect.gen(function* () {
1930
+ const node = yield* referencedNode(objectReference, "chmodReference");
1931
+ const permitted = yield* permittedMode(node.metadata, mode, "chmodReference");
1932
+ node.metadata = { ...node.metadata, mode: permitted, ctimeNs: (yield* timestamp("chmodReference")) };
1933
+ advanceRevision(node);
1934
+ publishNode(node);
1935
+ }));
1936
+ }),
1937
+ chownReference: Effect.fn("Caller.chownReference")(function* (objectReference, owner) {
1938
+ const decoded = decodeOwnerUpdate(owner);
1939
+ if (Result.isFailure(decoded)) {
1940
+ return yield* new FsError({ code: "InvalidArgument", operation: "chownReference" });
1941
+ }
1942
+ const update = { ...decoded.success };
1943
+ return yield* coordinated("chownReference", Effect.gen(function* () {
1944
+ const node = yield* referencedNode(objectReference, "chownReference");
1945
+ if (!identity.privileged && (identity.uid !== node.metadata.uid ||
1946
+ (update.uid !== undefined && update.uid !== node.metadata.uid) ||
1947
+ (update.gid !== undefined && update.gid !== identity.gid && !identity.groups.includes(update.gid))))
1948
+ return yield* new FsError({ code: "AccessDenied", operation: "chownReference" });
1949
+ if (update.uid === undefined && update.gid === undefined)
1950
+ return;
1951
+ node.metadata = {
1952
+ ...node.metadata,
1953
+ uid: update.uid ?? node.metadata.uid,
1954
+ gid: update.gid ?? node.metadata.gid,
1955
+ mode: node.kind === "file" ? node.metadata.mode & ~SET_ID_BITS : node.metadata.mode,
1956
+ ctimeNs: (yield* timestamp("chownReference"))
1957
+ };
1958
+ advanceRevision(node);
1959
+ publishNode(node);
1960
+ }));
1961
+ }),
1962
+ utimesReference: Effect.fn("Caller.utimesReference")(function* (objectReference, times) {
1963
+ const decoded = decodeTimes(times);
1964
+ if (Result.isFailure(decoded)) {
1965
+ return yield* new FsError({ code: "InvalidArgument", operation: "utimesReference" });
1966
+ }
1967
+ const access = { ...decoded.success.access };
1968
+ const modification = { ...decoded.success.modification };
1969
+ return yield* coordinated("utimesReference", Effect.gen(function* () {
1970
+ const node = yield* referencedNode(objectReference, "utimesReference");
1971
+ if (access.kind === "omit" && modification.kind === "omit")
1972
+ return;
1973
+ if (!identity.privileged && identity.uid !== node.metadata.uid) {
1974
+ if (access.kind !== "now" || modification.kind !== "now") {
1975
+ return yield* new FsError({ code: "AccessDenied", operation: "utimesReference" });
1976
+ }
1977
+ yield* authorize(node, identity, WRITE, "utimesReference");
1978
+ }
1979
+ const now = yield* timestamp("utimesReference");
1980
+ node.metadata = {
1981
+ ...node.metadata,
1982
+ atimeNs: access.kind === "omit"
1983
+ ? node.metadata.atimeNs
1984
+ : access.kind === "now"
1985
+ ? now
1986
+ : access.nanoseconds,
1987
+ mtimeNs: modification.kind === "omit"
1988
+ ? node.metadata.mtimeNs
1989
+ : modification.kind === "now"
1990
+ ? now
1991
+ : modification.nanoseconds,
1992
+ ctimeNs: now
1993
+ };
1994
+ advanceRevision(node);
1995
+ publishNode(node);
1996
+ }));
1997
+ }),
1998
+ truncateReference: Effect.fn("Caller.truncateReference")(function* (objectReference, length) {
1999
+ return yield* coordinated("truncateReference", Effect.gen(function* () {
2000
+ const node = yield* referencedNode(objectReference, "truncateReference");
2001
+ if (node.kind !== "file") {
2002
+ return yield* new FsError({ code: "IsDirectory", operation: "truncateReference" });
2003
+ }
2004
+ yield* authorize(node, identity, WRITE, "truncateReference");
2005
+ yield* resize(node, length, "truncateReference");
2006
+ }));
2007
+ }),
2008
+ openReference: Effect.fn("Caller.openReference")(function* (objectReference, raw = { access: "read" }) {
2009
+ const decoded = decodeOpenReferenceSettings(raw);
2010
+ if (Result.isFailure(decoded)) {
2011
+ return yield* new FsError({ code: "InvalidArgument", operation: "openReference" });
2012
+ }
2013
+ const chosen = { ...decoded.success };
2014
+ if (chosen.access === "read" && (chosen.append || chosen.truncate)) {
2015
+ return yield* new FsError({ code: "InvalidArgument", operation: "openReference" });
2016
+ }
2017
+ const acquired = makeFileReference(chosen.access, chosen.append ?? false);
2018
+ yield* Effect.addFinalizer(() => finalizeFile(acquired));
2019
+ return yield* coordinated("openReference", Effect.gen(function* () {
1088
2020
  if (acquired.closed)
1089
2021
  return yield* Effect.interrupt;
1090
2022
  const node = yield* referencedNode(objectReference, "openReference");
@@ -1093,16 +2025,170 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1093
2025
  if (node.metadata.nlink === 0) {
1094
2026
  return yield* new FsError({ code: "StaleReference", operation: "openReference" });
1095
2027
  }
1096
- yield* authorize(node, identity, READ, "openReference", "/");
2028
+ yield* authorize(node, identity, chosen.access === "read" ? READ : chosen.access === "write" ? WRITE : READ | WRITE, "openReference");
2029
+ if (chosen.truncate)
2030
+ yield* resize(node, 0n, "openReference");
1097
2031
  node.openCount += 1;
1098
2032
  acquired.file = node;
1099
2033
  return fileHandle(acquired);
1100
2034
  }));
1101
2035
  }),
2036
+ openChildReference: Effect.fn("Caller.openChildReference")(function* (directoryReference, input, raw, expected) {
2037
+ const name = yield* referencedName(input, "openChildReference");
2038
+ const decoded = decodeOpenChildReferenceSettings(raw);
2039
+ if (Result.isFailure(decoded)) {
2040
+ return yield* new FsError({ code: "InvalidArgument", operation: "openChildReference" });
2041
+ }
2042
+ const chosen = { ...decoded.success };
2043
+ if (chosen.access === "read" && (chosen.append || chosen.truncate)) {
2044
+ return yield* new FsError({ code: "InvalidArgument", operation: "openChildReference" });
2045
+ }
2046
+ if ((chosen.mode !== undefined || chosen.times !== undefined || chosen.initialSize !== undefined ||
2047
+ chosen.exactMode !== undefined || chosen.owner !== undefined) &&
2048
+ (chosen.create === undefined || chosen.create === "never")) {
2049
+ return yield* new FsError({ code: "InvalidArgument", operation: "openChildReference" });
2050
+ }
2051
+ if (chosen.exactMode && chosen.mode === undefined) {
2052
+ return yield* new FsError({ code: "InvalidArgument", operation: "openChildReference" });
2053
+ }
2054
+ const relativePath = preparePath(ownedPath(nameBytes(name)), "openChildReference", undefined);
2055
+ const acquired = makeFileReference(chosen.access, chosen.append ?? false);
2056
+ yield* Effect.addFinalizer(() => finalizeFile(acquired));
2057
+ return yield* coordinated("openChildReference", Effect.gen(function* () {
2058
+ if (acquired.closed)
2059
+ return yield* Effect.interrupt;
2060
+ const parent = yield* referencedDirectory(directoryReference, "openChildReference");
2061
+ yield* authorize(parent, identity, EXECUTE, "openChildReference");
2062
+ const direct = parent.entries.get(name);
2063
+ if (expected !== undefined) {
2064
+ let expectedNode;
2065
+ if (expected !== null) {
2066
+ const observed = yield* Effect.result(referencedNode(expected, "openChildReference"));
2067
+ if (Result.isFailure(observed)) {
2068
+ return yield* new FsError({ code: "VolumeBusy", operation: "openChildReference" });
2069
+ }
2070
+ expectedNode = observed.success;
2071
+ }
2072
+ if (direct !== expectedNode) {
2073
+ return yield* new FsError({ code: "VolumeBusy", operation: "openChildReference" });
2074
+ }
2075
+ }
2076
+ if (chosen.expectedChild === null) {
2077
+ if (direct !== undefined) {
2078
+ return yield* new FsError({ code: "StaleReference", operation: "openChildReference" });
2079
+ }
2080
+ }
2081
+ else if (chosen.expectedChild !== undefined) {
2082
+ const expected = chosen.expectedChild;
2083
+ const observed = yield* referencedNode(expected.reference, "openChildReference");
2084
+ if (direct !== observed || observed.revision !== expected.revision ||
2085
+ observed.metadata.atimeNs !== expected.atimeNs || observed.metadata.mtimeNs !== expected.mtimeNs) {
2086
+ return yield* new FsError({ code: "StaleReference", operation: "openChildReference" });
2087
+ }
2088
+ }
2089
+ if (direct !== undefined && chosen.create === "exclusive") {
2090
+ return yield* new FsError({ code: "AlreadyExists", operation: "openChildReference" });
2091
+ }
2092
+ let file = direct;
2093
+ let mutationParent = parent;
2094
+ let mutationName = name;
2095
+ if (file?.kind === "symlink" && chosen.followFinalSymlink !== false) {
2096
+ const resolved = yield* lookup(yield* Effect.fromResult(relativePath), undefined, "openChildReference", {
2097
+ followFinalSymlink: true,
2098
+ allowMissing: chosen.create === "ifMissing" || chosen.create === "exclusive"
2099
+ }, parent);
2100
+ file = resolved.node;
2101
+ if (file === undefined) {
2102
+ if (resolved.parent === undefined || resolved.name === undefined) {
2103
+ return yield* new FsError({ code: "IsDirectory", operation: "openChildReference" });
2104
+ }
2105
+ mutationParent = resolved.parent;
2106
+ mutationName = resolved.name;
2107
+ }
2108
+ }
2109
+ const before = mutationParent.revision;
2110
+ let created = false;
2111
+ if (file === undefined) {
2112
+ if (chosen.create === undefined || chosen.create === "never") {
2113
+ return yield* new FsError({ code: "NotFound", operation: "openChildReference" });
2114
+ }
2115
+ yield* authorize(mutationParent, identity, WRITE | EXECUTE, "openChildReference");
2116
+ if (atEntryLimit()) {
2117
+ return yield* new FsError({ code: "NoSpace", operation: "openChildReference" });
2118
+ }
2119
+ const size = chosen.initialSize ?? 0n;
2120
+ if (size < 0n) {
2121
+ return yield* new FsError({ code: "InvalidArgument", operation: "openChildReference" });
2122
+ }
2123
+ if (size > BigInt(maxFileBytes)) {
2124
+ return yield* new FsError({ code: "FileTooLarge", operation: "openChildReference" });
2125
+ }
2126
+ if (settings.maxBytes !== undefined && size > ByteSize.toBigInt(settings.maxBytes) - state.usedBytes) {
2127
+ return yield* new FsError({ code: "NoSpace", operation: "openChildReference" });
2128
+ }
2129
+ if (!identity.privileged &&
2130
+ ((chosen.owner?.uid !== undefined && chosen.owner.uid !== identity.uid) ||
2131
+ (chosen.owner?.gid !== undefined && chosen.owner.gid !== identity.gid &&
2132
+ !identity.groups.includes(chosen.owner.gid)))) {
2133
+ return yield* new FsError({ code: "AccessDenied", operation: "openChildReference" });
2134
+ }
2135
+ const now = yield* timestamp("openChildReference");
2136
+ const initial = creationTimes(chosen.times, now);
2137
+ const creationMode = chosen.exactMode
2138
+ ? yield* permittedMode({
2139
+ kind: "file",
2140
+ uid: chosen.owner?.uid ?? identity.uid,
2141
+ gid: chosen.owner?.gid ?? mutationParent.metadata.gid
2142
+ }, chosen.mode, "openChildReference")
2143
+ : (chosen.mode ?? 0o666) & 0o777 & ~umask;
2144
+ const createdFile = {
2145
+ kind: "file",
2146
+ lineage: undefined,
2147
+ data: Content.make(new Uint8Array(Number(size))),
2148
+ openCount: 0,
2149
+ metadata: {
2150
+ ...directoryMetadata(state.nextInode, chosen.owner?.uid ?? identity.uid, chosen.owner?.gid ?? mutationParent.metadata.gid, creationMode, now),
2151
+ ...initial,
2152
+ kind: "file",
2153
+ size,
2154
+ nlink: 1
2155
+ },
2156
+ revision: nextRevision(),
2157
+ objectReference: undefined
2158
+ };
2159
+ file = createdFile;
2160
+ attach(mutationParent, mutationName, createdFile, now);
2161
+ state.entries += 1;
2162
+ state.usedBytes += size;
2163
+ state.nextInode += 1n;
2164
+ created = true;
2165
+ publishEntry("Create", mutationParent, mutationName);
2166
+ }
2167
+ else {
2168
+ if (file.kind === "symlink") {
2169
+ return yield* new FsError({ code: "SymlinkLoop", operation: "openChildReference" });
2170
+ }
2171
+ if (file.kind !== "file") {
2172
+ return yield* new FsError({ code: "IsDirectory", operation: "openChildReference" });
2173
+ }
2174
+ yield* authorize(file, identity, chosen.access === "read" ? READ : chosen.access === "write" ? WRITE : READ | WRITE, "openChildReference");
2175
+ if (chosen.truncate)
2176
+ yield* resize(file, 0n, "openChildReference");
2177
+ }
2178
+ file.openCount += 1;
2179
+ acquired.file = file;
2180
+ return {
2181
+ handle: fileHandle(acquired),
2182
+ reference: referenceFor(file),
2183
+ created,
2184
+ directory: { before, after: mutationParent.revision }
2185
+ };
2186
+ }));
2187
+ }),
1102
2188
  readFile: Effect.fn("Caller.readFile")(function* (input, options) {
1103
2189
  const prepared = preparePath(input, "readFile", settings.maxPathBytes);
1104
2190
  const base = options?.relativeTo;
1105
- return yield* coordinated(Effect.gen(function* () {
2191
+ return yield* coordinated("readFile", Effect.gen(function* () {
1106
2192
  const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "readFile");
1107
2193
  if (node.kind !== "file") {
1108
2194
  return yield* new FsError({ code: "IsDirectory", operation: "readFile", path: input });
@@ -1125,7 +2211,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1125
2211
  return yield* new FsError({ code: "InvalidArgument", operation: "writeFile", path: input });
1126
2212
  }
1127
2213
  const chosen = decoded.success;
1128
- return yield* coordinated(Effect.gen(function* () {
2214
+ return yield* coordinated("writeFile", Effect.gen(function* () {
1129
2215
  const path = yield* Effect.fromResult(prepared);
1130
2216
  if (chosen.create === "exclusive") {
1131
2217
  const exists = yield* Effect.result(lookup(path, base, "writeFile", { followFinalSymlink: false }));
@@ -1172,7 +2258,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1172
2258
  }
1173
2259
  const reclaimed = replaced !== undefined && replaced.metadata.nlink === 1 ? replaced.target.length : 0;
1174
2260
  if (settings.maxBytes !== undefined &&
1175
- BigInt(size - previous) > ByteSize.toBigInt(settings.maxBytes) - usedBytes + BigInt(reclaimed)) {
2261
+ BigInt(size - previous) > ByteSize.toBigInt(settings.maxBytes) - state.usedBytes + BigInt(reclaimed)) {
1176
2262
  return yield* new FsError({ code: "NoSpace", operation: "writeFile", path: input });
1177
2263
  }
1178
2264
  if (file !== undefined && !chosen.truncate && captured.length === 0 && chosen.finalMode === undefined) {
@@ -1194,7 +2280,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1194
2280
  data: Content.empty(),
1195
2281
  openCount: 0,
1196
2282
  metadata: {
1197
- ...directoryMetadata(nextInode++, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
2283
+ ...directoryMetadata(state.nextInode++, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
1198
2284
  kind: "file",
1199
2285
  nlink: 1
1200
2286
  },
@@ -1210,13 +2296,13 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1210
2296
  ctimeNs: now
1211
2297
  };
1212
2298
  advanceRevision(node);
1213
- usedBytes += BigInt(size - previous);
2299
+ state.usedBytes += BigInt(size - previous);
1214
2300
  if (file === undefined) {
1215
2301
  if (replaced !== undefined)
1216
2302
  detach(replaced, now);
1217
2303
  attach(parent, name, node, now);
1218
2304
  if (replaced === undefined)
1219
- entries += 1;
2305
+ state.entries += 1;
1220
2306
  publishEntry(replaced === undefined ? "Create" : "Update", parent, name);
1221
2307
  }
1222
2308
  else
@@ -1247,7 +2333,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1247
2333
  if (!Number.isInteger(bits) || bits < 0 || bits > (READ | WRITE | EXECUTE)) {
1248
2334
  return yield* new FsError({ code: "InvalidArgument", operation: "access", path: input });
1249
2335
  }
1250
- return yield* coordinated(Effect.gen(function* () {
2336
+ return yield* coordinatedRead("access", Effect.gen(function* () {
1251
2337
  const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "access");
1252
2338
  if (node.kind === "file" && (bits & EXECUTE) !== 0 && (node.metadata.mode & ANY_EXECUTE) === 0) {
1253
2339
  return yield* new FsError({ code: "AccessDenied", operation: "access", path: input });
@@ -1258,7 +2344,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1258
2344
  truncate: Effect.fn("Caller.truncate")(function* (input, length, options) {
1259
2345
  const prepared = preparePath(input, "truncate", settings.maxPathBytes);
1260
2346
  const base = options?.relativeTo;
1261
- return yield* coordinated(Effect.gen(function* () {
2347
+ return yield* coordinated("truncate", Effect.gen(function* () {
1262
2348
  const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "truncate");
1263
2349
  if (node.kind !== "file") {
1264
2350
  return yield* new FsError({ code: "IsDirectory", operation: "truncate", path: input });
@@ -1270,7 +2356,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1270
2356
  lstat: Effect.fn("Caller.lstat")(function* (input, options) {
1271
2357
  const prepared = preparePath(input, "lstat", settings.maxPathBytes);
1272
2358
  const base = options?.relativeTo;
1273
- return yield* coordinated(Effect.gen(function* () {
2359
+ return yield* coordinatedRead("lstat", Effect.gen(function* () {
1274
2360
  const node = yield* resolveNode(yield* Effect.fromResult(prepared), base, "lstat", {
1275
2361
  followFinalSymlink: false
1276
2362
  });
@@ -1283,7 +2369,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1283
2369
  const sourceBase = options?.sourceRelativeTo;
1284
2370
  const destinationBase = options?.destinationRelativeTo;
1285
2371
  const follow = options?.followSourceSymlink ?? false;
1286
- return yield* coordinated(Effect.gen(function* () {
2372
+ return yield* coordinated("link", Effect.gen(function* () {
1287
2373
  const node = yield* resolveNode(yield* Effect.fromResult(a), sourceBase, "link", {
1288
2374
  followFinalSymlink: follow
1289
2375
  });
@@ -1307,7 +2393,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1307
2393
  attach(parent, name, node, now);
1308
2394
  node.metadata = { ...node.metadata, nlink: node.metadata.nlink + 1, ctimeNs: now };
1309
2395
  advanceRevision(node);
1310
- entries += 1;
2396
+ state.entries += 1;
1311
2397
  publishEntry("Create", parent, name);
1312
2398
  }));
1313
2399
  }),
@@ -1322,7 +2408,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1322
2408
  }
1323
2409
  const targetBytes = new Uint8Array(rawTarget.success);
1324
2410
  const base = options?.relativeTo;
1325
- return yield* coordinated(Effect.gen(function* () {
2411
+ return yield* coordinated("symlink", Effect.gen(function* () {
1326
2412
  const path = yield* Effect.fromResult(prepared);
1327
2413
  const bytes = targetBytes;
1328
2414
  const parent = yield* locate(path, base, "symlink", { parentOnly: true });
@@ -1336,7 +2422,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1336
2422
  }
1337
2423
  if (atEntryLimit() ||
1338
2424
  (settings.maxBytes !== undefined &&
1339
- BigInt(bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes))
2425
+ BigInt(bytes.length) > ByteSize.toBigInt(settings.maxBytes) - state.usedBytes))
1340
2426
  return yield* new FsError({ code: "NoSpace", operation: "symlink", path: input });
1341
2427
  const now = yield* timestamp("symlink");
1342
2428
  const node = {
@@ -1344,7 +2430,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1344
2430
  lineage: undefined,
1345
2431
  target: new Uint8Array(bytes),
1346
2432
  metadata: {
1347
- ...directoryMetadata(nextInode, identity.uid, parent.metadata.gid, 0o777, now),
2433
+ ...directoryMetadata(state.nextInode, identity.uid, parent.metadata.gid, 0o777, now),
1348
2434
  kind: "symlink",
1349
2435
  nlink: 1,
1350
2436
  size: BigInt(bytes.length)
@@ -1353,9 +2439,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1353
2439
  objectReference: undefined
1354
2440
  };
1355
2441
  attach(parent, name, node, now);
1356
- nextInode += 1n;
1357
- entries += 1;
1358
- usedBytes += BigInt(bytes.length);
2442
+ state.nextInode += 1n;
2443
+ state.entries += 1;
2444
+ state.usedBytes += BigInt(bytes.length);
1359
2445
  publishEntry("Create", parent, name);
1360
2446
  }));
1361
2447
  }),
@@ -1391,16 +2477,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1391
2477
  if (chosen.mode !== undefined && (chosen.create === undefined || chosen.create === "never")) {
1392
2478
  return yield* new FsError({ code: "InvalidArgument", operation: "open", path: input });
1393
2479
  }
1394
- const acquired = {
1395
- volume: volumeIdentity,
1396
- file: undefined,
1397
- closed: false,
1398
- offset: 0n,
1399
- access: chosen.access,
1400
- append: chosen.append ?? false
1401
- };
1402
- yield* Effect.addFinalizer(() => coordinated(Effect.sync(() => releaseFile(acquired))));
1403
- return yield* coordinated(Effect.gen(function* () {
2480
+ const acquired = makeFileReference(chosen.access, chosen.append ?? false);
2481
+ yield* Effect.addFinalizer(() => finalizeFile(acquired));
2482
+ return yield* coordinated("open", Effect.gen(function* () {
1404
2483
  if (acquired.closed)
1405
2484
  return yield* Effect.interrupt;
1406
2485
  const path = yield* Effect.fromResult(prepared);
@@ -1417,8 +2496,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1417
2496
  allowMissing: chosen.create === "ifMissing" || chosen.create === "exclusive"
1418
2497
  });
1419
2498
  const parent = resolved.parent;
1420
- if (parent === undefined)
2499
+ if (parent === undefined) {
1421
2500
  return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
2501
+ }
1422
2502
  const name = resolved.name;
1423
2503
  if (isDotComponent(name)) {
1424
2504
  return yield* new FsError({ code: "IsDirectory", operation: "open", path: input });
@@ -1443,7 +2523,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1443
2523
  data: Content.empty(),
1444
2524
  openCount: 0,
1445
2525
  metadata: {
1446
- ...directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
2526
+ ...directoryMetadata(state.nextInode, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
1447
2527
  kind: "file",
1448
2528
  nlink: 1
1449
2529
  },
@@ -1451,8 +2531,8 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1451
2531
  objectReference: undefined
1452
2532
  };
1453
2533
  attach(parent, name, file, now);
1454
- entries += 1;
1455
- nextInode += 1n;
2534
+ state.entries += 1;
2535
+ state.nextInode += 1n;
1456
2536
  publishEntry("Create", parent, name);
1457
2537
  }
1458
2538
  else {
@@ -1477,7 +2557,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1477
2557
  unlink: Effect.fn("Caller.unlink")(function* (input, options) {
1478
2558
  const prepared = preparePath(input, "unlink", settings.maxPathBytes);
1479
2559
  const base = options?.relativeTo;
1480
- return yield* coordinated(Effect.gen(function* () {
2560
+ return yield* coordinated("unlink", Effect.gen(function* () {
1481
2561
  const path = yield* Effect.fromResult(prepared);
1482
2562
  const parent = yield* locate(path, base, "unlink", { parentOnly: true });
1483
2563
  yield* authorize(parent, identity, WRITE | EXECUTE, "unlink", input);
@@ -1500,7 +2580,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1500
2580
  parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
1501
2581
  advanceRevision(parent);
1502
2582
  detach(child, now);
1503
- entries -= 1;
2583
+ state.entries -= 1;
1504
2584
  publishEntry("Remove", parent, name);
1505
2585
  }));
1506
2586
  }),
@@ -1509,7 +2589,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1509
2589
  const newPrepared = preparePath(destination, "rename", settings.maxPathBytes);
1510
2590
  const oldBase = options?.sourceRelativeTo;
1511
2591
  const newBase = options?.destinationRelativeTo;
1512
- return yield* coordinated(Effect.gen(function* () {
2592
+ return yield* coordinated("rename", Effect.gen(function* () {
1513
2593
  const oldPath = yield* Effect.fromResult(oldPrepared);
1514
2594
  const newPath = yield* Effect.fromResult(newPrepared);
1515
2595
  const oldParent = yield* locate(oldPath, oldBase, "rename", { parentOnly: true });
@@ -1522,8 +2602,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1522
2602
  return yield* new FsError({ code: "InvalidArgument", operation: "rename", path: source });
1523
2603
  }
1524
2604
  const child = oldParent.entries.get(oldName);
1525
- if (child === undefined)
2605
+ if (child === undefined) {
1526
2606
  return yield* new FsError({ code: "NotFound", operation: "rename", path: source });
2607
+ }
1527
2608
  const replaced = newParent.entries.get(newName);
1528
2609
  if (newPath.trailingSlash && replaced === undefined) {
1529
2610
  return yield* new FsError({ code: "NotFound", operation: "rename", path: destination });
@@ -1556,7 +2637,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1556
2637
  }
1557
2638
  const now = yield* timestamp("rename");
1558
2639
  // All rejection checks precede namespace, ancestry, quota, and metadata publication.
1559
- const oldEvent = () => ownedPath(nameBytes(directoryHex(oldParent) + (oldParent === root ? "" : SLASH_HEX) + oldName));
2640
+ const oldEvent = () => ownedPath(nameBytes(directoryHex(oldParent) + (oldParent === state.root ? "" : SLASH_HEX) + oldName));
1560
2641
  oldParent.entries.delete(oldName);
1561
2642
  newParent.entries.set(newName, child);
1562
2643
  if (child.kind === "directory")
@@ -1580,16 +2661,16 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1580
2661
  advanceRevision(child);
1581
2662
  if (replaced !== undefined) {
1582
2663
  detach(replaced, now);
1583
- entries -= 1;
2664
+ state.entries -= 1;
1584
2665
  }
1585
- watchHub.publishUnsafe(() => ({ _tag: "Remove", path: oldEvent() }));
2666
+ queuePublish(() => watchHub.publishUnsafe(() => ({ _tag: "Remove", path: oldEvent() })));
1586
2667
  publishEntry("Create", newParent, newName);
1587
2668
  }));
1588
2669
  }),
1589
2670
  rmdir: Effect.fn("Caller.rmdir")(function* (input, options) {
1590
2671
  const prepared = preparePath(input, "rmdir", settings.maxPathBytes);
1591
2672
  const base = options?.relativeTo;
1592
- return yield* coordinated(Effect.gen(function* () {
2673
+ return yield* coordinated("rmdir", Effect.gen(function* () {
1593
2674
  const path = yield* Effect.fromResult(prepared);
1594
2675
  const parent = yield* locate(path, base, "rmdir", { parentOnly: true });
1595
2676
  yield* authorize(parent, identity, WRITE | EXECUTE, "rmdir", input);
@@ -1604,8 +2685,9 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1604
2685
  if (child.kind !== "directory") {
1605
2686
  return yield* new FsError({ code: "NotDirectory", operation: "rmdir", path: input });
1606
2687
  }
1607
- if (child.entries.size > 0)
2688
+ if (child.entries.size > 0) {
1608
2689
  return yield* new FsError({ code: "NotEmpty", operation: "rmdir", path: input });
2690
+ }
1609
2691
  const now = yield* timestamp("rmdir");
1610
2692
  parent.entries.delete(name);
1611
2693
  parent.metadata = { ...parent.metadata, nlink: parent.metadata.nlink - 1, mtimeNs: now, ctimeNs: now };
@@ -1614,14 +2696,14 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1614
2696
  advanceRevision(parent);
1615
2697
  advanceRevision(child);
1616
2698
  invalidateReference(child);
1617
- entries -= 1;
2699
+ state.entries -= 1;
1618
2700
  publishEntry("Remove", parent, name);
1619
2701
  }));
1620
2702
  }),
1621
2703
  stat: Effect.fn("Caller.stat")(function* (input, options) {
1622
2704
  const prepared = preparePath(input, "stat", settings.maxPathBytes);
1623
2705
  const base = options?.relativeTo;
1624
- return yield* coordinated(Effect.gen(function* () {
2706
+ return yield* coordinatedRead("stat", Effect.gen(function* () {
1625
2707
  const path = yield* Effect.fromResult(prepared);
1626
2708
  const directory = yield* resolveNode(path, base, "stat");
1627
2709
  return { ...directory.metadata };
@@ -1633,7 +2715,7 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1633
2715
  const mode = options?.mode === undefined ? 0o777 : options.mode;
1634
2716
  if (!isMode(mode))
1635
2717
  return yield* new FsError({ code: "InvalidArgument", operation: "mkdir", path: input });
1636
- return yield* coordinated(Effect.gen(function* () {
2718
+ return yield* coordinated("mkdir", Effect.gen(function* () {
1637
2719
  const path = yield* Effect.fromResult(prepared);
1638
2720
  const parent = yield* locate(path, base, "mkdir", { parentOnly: true });
1639
2721
  yield* authorize(parent, identity, WRITE | EXECUTE, "mkdir", input);
@@ -1650,14 +2732,14 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1650
2732
  lineage: undefined,
1651
2733
  parent,
1652
2734
  entries: new Map(),
1653
- metadata: directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (mode & 0o777 & ~umask) | (mode & STICKY_BIT), now),
2735
+ metadata: directoryMetadata(state.nextInode, identity.uid, parent.metadata.gid, (mode & 0o777 & ~umask) | (mode & STICKY_BIT), now),
1654
2736
  revision: nextRevision(),
1655
2737
  objectReference: undefined
1656
2738
  };
1657
2739
  // No Effect yield or expected failure between these publication writes.
1658
2740
  attach(parent, name, child, now);
1659
- nextInode += 1n;
1660
- entries += 1;
2741
+ state.nextInode += 1n;
2742
+ state.entries += 1;
1661
2743
  publishEntry("Create", parent, name);
1662
2744
  }));
1663
2745
  }),
@@ -1669,10 +2751,10 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1669
2751
  const acquired = yield* acquireDirectory(input, options, "openDirectory");
1670
2752
  const handle = Object.freeze({
1671
2753
  [DirectoryHandleId]: true,
1672
- stat: coordinated(Effect.suspend(() => acquired.directory === undefined
2754
+ stat: coordinatedRead("stat", Effect.suspend(() => acquired.directory === undefined
1673
2755
  ? Effect.fail(new FsError({ code: "InvalidHandle", operation: "stat" }))
1674
2756
  : Effect.succeed({ ...acquired.directory.metadata }))).pipe(Effect.withSpan("DirectoryHandle.stat")),
1675
- close: coordinated(Effect.suspend(() => {
2757
+ close: coordinatedCleanup(Effect.suspend(() => {
1676
2758
  if (acquired.directory === undefined) {
1677
2759
  return Effect.fail(new FsError({ code: "InvalidHandle", operation: "close" }));
1678
2760
  }
@@ -1700,18 +2782,24 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1700
2782
  let surface;
1701
2783
  const volume = Object.freeze({
1702
2784
  [VolumeId]: true,
2785
+ durability,
2786
+ identity,
2787
+ incarnation,
2788
+ limits,
2789
+ usage: coordinatedRead("usage", Effect.sync(() => ({ usedBytes: state.usedBytes, entries: state.entries })))
2790
+ .pipe(Effect.withSpan("Volume.usage")),
1703
2791
  watch: Effect.gen(function* () {
1704
2792
  const hook = TestHooks.getRegistrationHook(surface);
1705
- return yield* watchHub.subscribe(hook?.afterSubscribe);
2793
+ return yield* admit("watch", watchHub.subscribe(hook?.afterSubscribe));
1706
2794
  }).pipe(Effect.withSpan("Volume.watch")),
1707
- snapshot: coordinatedRead(captureSnapshot()).pipe(Effect.withSpan("Volume.snapshot")),
2795
+ snapshot: coordinatedRead("snapshot", captureSnapshot()).pipe(Effect.withSpan("Volume.snapshot")),
1708
2796
  caller: Effect.fn("Volume.caller")(function* (options) {
1709
2797
  const decoded = decodeConfiguration(RootCallerOptions, options === undefined ? {} : options);
1710
2798
  if (Result.isFailure(decoded))
1711
2799
  return yield* decoded.failure;
1712
2800
  const chosen = decoded.success.identity ?? { uid: 0, gid: 0, groups: [], privileged: true };
1713
2801
  const identity = Object.freeze({ ...chosen, groups: Object.freeze([...chosen.groups]) });
1714
- return createCaller({ volume: volumeIdentity, directory: root, closed: false }, identity, decoded.success.umask ?? 0o022);
2802
+ return yield* coordinatedRead("caller", Effect.sync(() => createCaller(makeDirectoryReference(state.root), identity, decoded.success.umask ?? 0o022)));
1715
2803
  })
1716
2804
  });
1717
2805
  surface = volume;
@@ -1724,12 +2812,12 @@ export const makeVolume = Effect.fnUntraced(function* (source, options) {
1724
2812
  ...volume,
1725
2813
  changes: Effect.fn("OverlayVolume.changes")(function* (options) {
1726
2814
  const selected = yield* changeOptions(options);
1727
- const current = yield* coordinatedRead(observeChanges());
2815
+ const current = yield* coordinatedRead("changes", observeChanges());
1728
2816
  return publicChanges(compareOverlay(baseObservation, current, selected.includeTimestamps ?? false));
1729
2817
  }),
1730
2818
  capture: Effect.fn("OverlayVolume.capture")(function* (options) {
1731
2819
  const selected = yield* changeOptions(options);
1732
- const current = yield* coordinatedRead(captureState(hook));
2820
+ const current = yield* coordinatedRead("capture", captureState(hook));
1733
2821
  return Object.freeze({
1734
2822
  snapshot: current.snapshot,
1735
2823
  changes: publicChanges(compareOverlay(baseObservation, current.observation, selected.includeTimestamps ?? false))
@@ -1744,6 +2832,53 @@ export const make = Effect.fn("VirtualFileSystem.make")(function* (options) {
1744
2832
  return yield* makeVolume(VolumeSource.Empty(), options).pipe(Effect.catchTag("ImageError", Effect.die));
1745
2833
  });
1746
2834
  /** @internal */
2835
+ export const prepareEmptyLiveImage = Effect.fnUntraced(function* (options) {
2836
+ let bytes;
2837
+ yield* makeVolume(VolumeSource.Empty(), options, undefined, (initial, identity, limits) => captureLiveImage(initial, identity, limits).pipe(Effect.tap((image) => Effect.sync(() => {
2838
+ bytes = image;
2839
+ })), Effect.asVoid));
2840
+ if (bytes === undefined)
2841
+ return yield* new ImageError({ code: "InvalidStructure", field: "liveImage" });
2842
+ return bytes;
2843
+ });
2844
+ /** @internal */
2845
+ export const openImageVolume = Effect.fnUntraced(function* (image, maxImageBytes, commit, durability = "memory-only") {
2846
+ const document = yield* LiveImage.decode(image, maxImageBytes);
2847
+ const prepared = new WeakMap();
2848
+ const identity = VolumeIdentity.make(document.identity);
2849
+ let shutdown;
2850
+ const limits = {
2851
+ maxEntries: document.limits.maxEntries,
2852
+ maxBytes: document.limits.maxBytes === undefined ? undefined : ByteSize.bytes(document.limits.maxBytes),
2853
+ maxFileBytes: document.limits.maxFileBytes === undefined
2854
+ ? ByteSize.bytes(0xffffffff)
2855
+ : ByteSize.bytes(document.limits.maxFileBytes),
2856
+ maxPathBytes: document.limits.maxPathBytes === undefined ? undefined : ByteSize.bytes(document.limits.maxPathBytes),
2857
+ maxPendingOperations: 64,
2858
+ maxWatchEvents: 256
2859
+ };
2860
+ const volume = yield* makeVolume(VolumeSource.Live({ document }), undefined, {
2861
+ onReady: (effect) => {
2862
+ shutdown = effect;
2863
+ },
2864
+ prepare: (candidate) => captureLiveImage(candidate, identity, limits).pipe(Effect.flatMap((bytes) => ByteSize.isGreaterThan(ByteSize.bytes(bytes.length), maxImageBytes)
2865
+ ? new FsError({ code: "StorageRejected", operation: "commit" })
2866
+ : Effect.sync(() => {
2867
+ prepared.set(candidate, bytes);
2868
+ })), Effect.mapError(() => new FsError({ code: "StorageRejected", operation: "commit" }))),
2869
+ commit: (candidate) => Effect.suspend(() => {
2870
+ const bytes = prepared.get(candidate);
2871
+ if (bytes === undefined)
2872
+ return Effect.succeed("unknown");
2873
+ prepared.delete(candidate);
2874
+ return commit(bytes);
2875
+ })
2876
+ }, undefined, durability);
2877
+ if (shutdown === undefined)
2878
+ return yield* new ImageError({ code: "InvalidStructure", field: "liveImage" });
2879
+ return Object.freeze({ volume, shutdown });
2880
+ });
2881
+ /** @internal */
1747
2882
  export const fromSnapshot = Effect.fn("VirtualFileSystem.fromSnapshot")(function* (snapshot, options) {
1748
2883
  const image = yield* Image.inspect(snapshot);
1749
2884
  return yield* makeVolume(VolumeSource.Snapshot({ image }), options);