@effect-vfs/core 0.0.1 → 0.2.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.
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @since 0.1.0
10
10
  */
11
+ import * as ByteSize from "effect/ByteSize";
11
12
  import * as Clock from "effect/Clock";
12
13
  import * as Context from "effect/Context";
13
14
  import { DecodeLimits, ImageError } from "./Snapshot.js";
@@ -18,11 +19,20 @@ import * as Encoding from "effect/Encoding";
18
19
  import * as PubSub from "effect/PubSub";
19
20
  import * as Result from "effect/Result";
20
21
  import * as Schema from "effect/Schema";
22
+ import * as SchemaIssue from "effect/SchemaIssue";
23
+ import * as SchemaTransformation from "effect/SchemaTransformation";
21
24
  import * as Scope from "effect/Scope";
22
25
  import * as Semaphore from "effect/Semaphore";
23
26
  import * as Stream from "effect/Stream";
27
+ import { BytePath, getBytes as getBytePathBytes, make as makeBytePath } from "./BytePath.js";
28
+ export { BytePath } from "./BytePath.js";
29
+ import * as Content from "./internal/content.js";
24
30
  import * as Image from "./internal/image.js";
25
- const BytePathId = Symbol("@effect-vfs/core/BytePath");
31
+ import { compareOverlay } from "./internal/overlayChanges.js";
32
+ import * as OverlayTesting from "./internal/overlayTesting.js";
33
+ import * as SnapshotDeltaInternal from "./internal/snapshotDelta.js";
34
+ import * as SnapshotDeltaModel from "./SnapshotDelta.js";
35
+ export { SnapshotChange, SnapshotChangesOptions, SnapshotDeltaError, SnapshotDeltaLimits, SnapshotDeltaTypeId, SnapshotDifference, SnapshotNodeKind } from "./SnapshotDelta.js";
26
36
  const VolumeId = Symbol("@effect-vfs/core/Volume");
27
37
  const CallerId = Symbol("@effect-vfs/core/Caller");
28
38
  const FileHandleId = Symbol("@effect-vfs/core/FileHandle");
@@ -108,11 +118,11 @@ export const VolumeOptions = Schema.Struct({
108
118
  /** Maximum number of filesystem nodes. Omission leaves the count unbounded. */
109
119
  maxEntries: Schema.optionalKey(Natural),
110
120
  /** Maximum combined regular-file content in bytes. */
111
- maxBytes: Schema.optionalKey(Natural),
121
+ maxBytes: Schema.optionalKey(Schema.ByteSize),
112
122
  /** Maximum content size of one regular file in bytes. */
113
- maxFileBytes: Schema.optionalKey(Natural.check(Schema.isLessThanOrEqualTo(0xffffffff))),
123
+ maxFileBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isLessThanOrEqualTo(size, ByteSize.bytes(0xffffffff)) ? undefined : "must be at most 4294967295 bytes"))),
114
124
  /** Maximum encoded byte length of an absolute or relative path. */
115
- maxPathBytes: Schema.optionalKey(Natural.check(Schema.isGreaterThanOrEqualTo(1)))
125
+ maxPathBytes: Schema.optionalKey(Schema.ByteSize.check(Schema.makeFilter((size) => ByteSize.isGreaterThanOrEqualTo(size, ByteSize.bytes(1)) ? undefined : "must be at least 1 byte")))
116
126
  });
117
127
  // Match snapshot v1's canonical signed decimal timestamp domain.
118
128
  const timestampLimit = 10n ** 128n - 1n;
@@ -195,6 +205,69 @@ const WriteFileSettings = Schema.Struct({
195
205
  /** Mode to apply after replacing an existing file. */
196
206
  finalMode: Schema.optionalKey(Mode)
197
207
  });
208
+ /**
209
+ * Schema for the filesystem entry kinds reported by overlay summaries.
210
+ *
211
+ * @category schemas
212
+ * @since 0.1.0
213
+ */
214
+ export const OverlayNodeKind = Schema.Literals(["directory", "file", "symlink"]);
215
+ /**
216
+ * Schema for observable fields that can differ from an overlay's immutable base.
217
+ *
218
+ * @category schemas
219
+ * @since 0.1.0
220
+ */
221
+ export const OverlayDifference = Schema.Literals([
222
+ "content",
223
+ "mode",
224
+ "uid",
225
+ "gid",
226
+ "atimeNs",
227
+ "mtimeNs",
228
+ "ctimeNs",
229
+ "birthtimeNs"
230
+ ]);
231
+ const OverlayDifferences = Schema.Array(OverlayDifference);
232
+ const NonEmptyOverlayDifferences = OverlayDifferences.check(Schema.isMinLength(1));
233
+ /**
234
+ * Schema for a final-state overlay difference.
235
+ *
236
+ * **Details**
237
+ *
238
+ * Paths retain arbitrary non-NUL bytes. Renames are reported only when retained
239
+ * base identity makes the removed and added names unambiguous.
240
+ *
241
+ * @category schemas
242
+ * @since 0.1.0
243
+ */
244
+ export const OverlayChange = Schema.Union([
245
+ Schema.TaggedStruct("Added", { path: BytePath, kind: OverlayNodeKind }),
246
+ Schema.TaggedStruct("Removed", { path: BytePath, kind: OverlayNodeKind }),
247
+ Schema.TaggedStruct("Replaced", {
248
+ path: BytePath,
249
+ beforeKind: OverlayNodeKind,
250
+ afterKind: OverlayNodeKind,
251
+ differences: OverlayDifferences
252
+ }),
253
+ Schema.TaggedStruct("Renamed", {
254
+ from: BytePath,
255
+ to: BytePath,
256
+ kind: OverlayNodeKind,
257
+ differences: OverlayDifferences
258
+ }),
259
+ Schema.TaggedStruct("Updated", { path: BytePath, kind: OverlayNodeKind, differences: NonEmptyOverlayDifferences })
260
+ ]);
261
+ /**
262
+ * Schema for overlay summary filtering.
263
+ *
264
+ * @category schemas
265
+ * @since 0.1.0
266
+ */
267
+ export const OverlayChangesOptions = Schema.Struct({
268
+ /** Include access, modification, change, and birth-time differences. Defaults to `false`. */
269
+ includeTimestamps: Schema.optionalKey(Schema.Boolean)
270
+ });
198
271
  /**
199
272
  * Optional Effect service for providing an existing filesystem caller.
200
273
  *
@@ -217,22 +290,66 @@ export const encodeSnapshot = Image.encodeSnapshot;
217
290
  * @since 0.1.0
218
291
  */
219
292
  export const decodeSnapshot = Image.decodeSnapshot;
220
- const bytePaths = new WeakMap();
293
+ const deltaLimits = (limits) => {
294
+ const decoded = decodeConfiguration(SnapshotDeltaModel.SnapshotDeltaLimits, limits ?? SnapshotDeltaModel.SnapshotDeltaLimits.default);
295
+ return Result.isFailure(decoded) ? Effect.fail(decoded.failure) : Effect.succeed(decoded.success);
296
+ };
297
+ /**
298
+ * Computes an exact portable delta between two immutable snapshots.
299
+ * Requires the platform-neutral `Crypto.Crypto` service for base identity.
300
+ *
301
+ * @category snapshots
302
+ * @since 0.1.0
303
+ */
304
+ export const diffSnapshots = Effect.fn("VirtualFileSystem.diffSnapshots")(function* (base, target, limits) {
305
+ return yield* SnapshotDeltaInternal.diffSnapshots(base, target, yield* deltaLimits(limits));
306
+ });
307
+ /**
308
+ * Verifies an exact snapshot delta against its base and derives an owned path-oriented summary.
309
+ * Requires the platform-neutral `Crypto.Crypto` service for base identity.
310
+ */
311
+ export const inspectSnapshotDelta = Effect.fn("VirtualFileSystem.inspectSnapshotDelta")(function* (base, delta, options, limits) {
312
+ const decoded = decodeConfiguration(SnapshotDeltaModel.SnapshotChangesOptions, options ?? {});
313
+ if (Result.isFailure(decoded))
314
+ return yield* decoded.failure;
315
+ return yield* SnapshotDeltaInternal.inspectSnapshotDelta(base, delta, decoded.success, yield* deltaLimits(limits));
316
+ });
317
+ /**
318
+ * Applies an exact delta to its semantically matching base and returns a new snapshot.
319
+ * Requires the platform-neutral `Crypto.Crypto` service for base identity.
320
+ *
321
+ * @category snapshots
322
+ * @since 0.1.0
323
+ */
324
+ export const applySnapshotDelta = Effect.fn("VirtualFileSystem.applySnapshotDelta")(function* (base, delta, limits) {
325
+ return yield* SnapshotDeltaInternal.applySnapshotDelta(base, delta, yield* deltaLimits(limits));
326
+ });
327
+ const deltaSchemaIssue = (cause, input, options) => new SchemaIssue.InvalidValue({ message: `Snapshot delta ${cause.code}${cause.field === undefined ? "" : ` at ${cause.field}`}` }, input, options);
328
+ /**
329
+ * Creates an Effect Schema codec between owned bytes and opaque snapshot deltas.
330
+ * Omission uses `SnapshotDeltaLimits.default`.
331
+ */
332
+ export const SnapshotDeltaFromBytes = (limits) => {
333
+ const selected = Schema.decodeResult(SnapshotDeltaModel.SnapshotDeltaLimits, { onExcessProperty: "error" })(limits ?? SnapshotDeltaModel.SnapshotDeltaLimits.default);
334
+ return Schema.Uint8Array.pipe(Schema.decodeTo(SnapshotDeltaModel.SnapshotDelta, SchemaTransformation.transformEffect({
335
+ decode: (input, options) => Result.isFailure(selected)
336
+ ? Effect.fail(new SchemaIssue.InvalidValue({ message: "Invalid snapshot delta limits" }, limits, options))
337
+ : SnapshotDeltaInternal.decodeSnapshotDelta(input, selected.success).pipe(Effect.mapError((cause) => deltaSchemaIssue(cause, input, options))),
338
+ encode: (delta, options) => Result.isFailure(selected)
339
+ ? Effect.fail(new SchemaIssue.InvalidValue({ message: "Invalid snapshot delta limits" }, limits, options))
340
+ : SnapshotDeltaInternal.encodeSnapshotDelta(delta, selected.success).pipe(Effect.mapError((cause) => deltaSchemaIssue(cause, delta, options)))
341
+ })));
342
+ };
221
343
  const failure = (code, operation, path) => new FsError({ code, operation, ...(path === undefined ? {} : { path }) });
222
344
  const ownedPath = (bytes) => {
223
- const path = Object.freeze({ [BytePathId]: true });
224
- bytePaths.set(path, bytes);
225
- return path;
345
+ return makeBytePath(bytes);
226
346
  };
227
347
  const strictString = (bytes, operation) => Effect.try({
228
348
  try: () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes),
229
349
  catch: () => failure("UnrepresentableName", operation)
230
350
  });
231
351
  const nameBytes = (name) => {
232
- const bytes = new Uint8Array(name.length / 2);
233
- for (let i = 0; i < bytes.length; i++)
234
- bytes[i] = Number.parseInt(name.slice(i * 2, i * 2 + 2), 16);
235
- return bytes;
352
+ return Result.getOrThrow(Encoding.decodeHex(name));
236
353
  };
237
354
  // A zero-length view distinguishes a detached buffer from a valid empty buffer.
238
355
  const attachedBuffer = (bytes) => {
@@ -265,9 +382,7 @@ export const pathFromBytes = Effect.fn("VirtualFileSystem.pathFromBytes")(functi
265
382
  const owned = new Uint8Array(bytes);
266
383
  if (owned.length === 0 || owned.includes(0))
267
384
  return yield* failure("InvalidArgument", "pathFromBytes");
268
- const path = Object.freeze({ [BytePathId]: true });
269
- bytePaths.set(path, owned);
270
- return path;
385
+ return makeBytePath(owned);
271
386
  });
272
387
  /**
273
388
  * Copies the bytes held by an opaque byte path.
@@ -276,7 +391,7 @@ export const pathFromBytes = Effect.fn("VirtualFileSystem.pathFromBytes")(functi
276
391
  * @since 0.1.0
277
392
  */
278
393
  export const pathToBytes = Effect.fn("VirtualFileSystem.pathToBytes")(function* (path) {
279
- const bytes = bytePaths.get(path);
394
+ const bytes = getBytePathBytes(path);
280
395
  if (bytes === undefined)
281
396
  return yield* failure("InvalidArgument", "pathToBytes");
282
397
  return new Uint8Array(bytes);
@@ -304,7 +419,7 @@ const preparePath = (input, operation, maxPathBytes) => {
304
419
  bytes = new TextEncoder().encode(input);
305
420
  }
306
421
  else if (typeof input === "object" && input !== null) {
307
- bytes = bytePaths.get(input);
422
+ bytes = getBytePathBytes(input);
308
423
  }
309
424
  if (bytes === undefined)
310
425
  return Result.fail(failure("InvalidArgument", operation));
@@ -312,7 +427,7 @@ const preparePath = (input, operation, maxPathBytes) => {
312
427
  return Result.fail(failure("NotFound", operation, input));
313
428
  if (bytes.includes(0))
314
429
  return Result.fail(failure("InvalidArgument", operation, input));
315
- if (maxPathBytes !== undefined && bytes.length > maxPathBytes) {
430
+ if (maxPathBytes !== undefined && ByteSize.isGreaterThan(ByteSize.bytes(bytes.length), maxPathBytes)) {
316
431
  return Result.fail(failure("PathTooLong", operation, input));
317
432
  }
318
433
  const components = [];
@@ -370,7 +485,8 @@ const storedMetadata = (metadata) => ({
370
485
  birthtimeNs: String(metadata.birthtimeNs)
371
486
  });
372
487
  /** Each execution constructs a fresh volume and captures its Clock. */
373
- const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options, image) {
488
+ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (source, options) {
489
+ const image = source._tag === "Empty" ? undefined : source.image;
374
490
  const decoded = decodeConfiguration(VolumeOptions, options === undefined ? {} : options);
375
491
  if (Result.isFailure(decoded))
376
492
  return yield* decoded.failure;
@@ -388,17 +504,19 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
388
504
  const gate = Semaphore.makeUnsafe(1);
389
505
  const root = {
390
506
  kind: "directory",
507
+ lineage: image?.root,
391
508
  parent: undefined,
392
509
  entries: new Map(),
393
510
  metadata: directoryMetadata(1n, 0, 0, 0o755, initialTime)
394
511
  };
395
512
  let nextInode = 2n;
396
513
  let entries = 0;
397
- let usedBytes = 0;
398
- const maxFileBytes = settings.maxFileBytes ?? 0xffffffff;
514
+ let usedBytes = 0n;
515
+ // The schema caps this value at uint32, so this boundary conversion is exact.
516
+ const maxFileBytes = Number(ByteSize.toBigInt(settings.maxFileBytes ?? ByteSize.bytes(0xffffffff)));
399
517
  if (image !== undefined) {
400
518
  const incoming = new Map();
401
- let content = 0;
519
+ let content = 0n;
402
520
  let count = 0;
403
521
  for (const record of image.records) {
404
522
  if (record.kind === "directory")
@@ -408,13 +526,14 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
408
526
  if (record.kind === "file" && length > maxFileBytes) {
409
527
  return yield* new ImageError({ code: "LimitExceeded", field: "maxFileBytes" });
410
528
  }
411
- content += length;
529
+ content += BigInt(length);
412
530
  }
413
531
  }
414
532
  if ((settings.maxEntries !== undefined && count > settings.maxEntries) ||
415
- (settings.maxBytes !== undefined && content > settings.maxBytes)) {
533
+ (settings.maxBytes !== undefined && content > ByteSize.toBigInt(settings.maxBytes))) {
416
534
  return yield* new ImageError({ code: "LimitExceeded", field: "volume" });
417
535
  }
536
+ const baseContents = source._tag === "Overlay" ? Content.forOverlay(source.base, image) : undefined;
418
537
  for (const record of image.records) {
419
538
  const metadata = {
420
539
  ...record.metadata,
@@ -430,22 +549,28 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
430
549
  if (record.kind === "directory") {
431
550
  const node = record.id === image.root
432
551
  ? root
433
- : { kind: "directory", parent: undefined, entries: new Map(), metadata };
552
+ : { kind: "directory", lineage: record.id, parent: undefined, entries: new Map(), metadata };
434
553
  node.metadata = metadata;
435
554
  incoming.set(record.id, node);
436
555
  }
437
556
  else if (record.kind === "file") {
438
- const data = Image.bytes(record.data);
557
+ const data = baseContents?.get(record.id) ?? Content.make(Image.bytes(record.data));
439
558
  incoming.set(record.id, {
440
559
  kind: "file",
560
+ lineage: record.id,
441
561
  data,
442
562
  openCount: 0,
443
- metadata: { ...metadata, size: BigInt(data.length) }
563
+ metadata: { ...metadata, size: BigInt(data.bytes.length) }
444
564
  });
445
565
  }
446
566
  else {
447
567
  const target = Image.bytes(record.target);
448
- incoming.set(record.id, { kind: "symlink", target, metadata: { ...metadata, size: BigInt(target.length) } });
568
+ incoming.set(record.id, {
569
+ kind: "symlink",
570
+ lineage: record.id,
571
+ target,
572
+ metadata: { ...metadata, size: BigInt(target.length) }
573
+ });
449
574
  }
450
575
  }
451
576
  for (const record of image.records) {
@@ -513,6 +638,75 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
513
638
  }
514
639
  }
515
640
  };
641
+ const captureSnapshot = Effect.fnUntraced(function* () {
642
+ const ids = new Map([[root, "0"]]);
643
+ const pending = [root];
644
+ const records = [];
645
+ for (let index = 0; index < pending.length; index++) {
646
+ const node = pending[index];
647
+ if (node === undefined)
648
+ continue;
649
+ const id = ids.get(node);
650
+ if (id === undefined)
651
+ return yield* new ImageError({ code: "InvalidStructure" });
652
+ const metadata = storedMetadata(node.metadata);
653
+ if (node.kind === "directory") {
654
+ const children = [];
655
+ for (const [name, child] of node.entries) {
656
+ let target = ids.get(child);
657
+ if (target === undefined) {
658
+ target = String(ids.size);
659
+ ids.set(child, target);
660
+ pending.push(child);
661
+ }
662
+ children.push({ name: Image.base64(nameBytes(name)), target });
663
+ }
664
+ records.push({ id, kind: "directory", metadata, entries: children });
665
+ }
666
+ else if (node.kind === "file") {
667
+ records.push({ id, kind: "file", metadata, data: Image.base64(node.data.bytes) });
668
+ }
669
+ else
670
+ records.push({ id, kind: "symlink", metadata, target: Image.base64(node.target) });
671
+ }
672
+ return yield* Image.capture({ format: "effect-vfs", version: 1, root: "0", records });
673
+ });
674
+ const observeChanges = () => {
675
+ const observation = [];
676
+ const paths = [[root, new Uint8Array([47])]];
677
+ for (let index = 0; index < paths.length; index++) {
678
+ const current = paths[index];
679
+ if (current === undefined)
680
+ continue;
681
+ const [node, path] = current;
682
+ observation.push({
683
+ path: new Uint8Array(path),
684
+ lineage: node.lineage,
685
+ kind: node.kind,
686
+ content: node.kind === "file" ? node.data.bytes : node.kind === "symlink" ? node.target : undefined,
687
+ metadata: storedMetadata(node.metadata)
688
+ });
689
+ if (node.kind !== "directory")
690
+ continue;
691
+ for (const [name, child] of node.entries) {
692
+ const bytes = nameBytes(name);
693
+ const childPath = new Uint8Array(path.length + (path.length === 1 ? 0 : 1) + bytes.length);
694
+ childPath.set(path);
695
+ let offset = path.length;
696
+ if (path.length !== 1)
697
+ childPath[offset++] = 47;
698
+ childPath.set(bytes, offset);
699
+ paths.push([child, childPath]);
700
+ }
701
+ }
702
+ return observation;
703
+ };
704
+ const captureState = Effect.fnUntraced(function* (hook) {
705
+ const snapshot = yield* captureSnapshot();
706
+ if (hook !== undefined)
707
+ yield* hook.betweenSnapshotAndSummary;
708
+ return { snapshot, observation: observeChanges() };
709
+ });
516
710
  // Permit waits stay interruptible. State transitions and resource registration do not.
517
711
  const coordinated = (effect) => gate.withPermit(Effect.uninterruptible(effect));
518
712
  const release = (reference) => coordinated(Effect.sync(() => {
@@ -534,8 +728,8 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
534
728
  };
535
729
  const reclaim = (file) => {
536
730
  if (file.metadata.nlink === 0 && file.openCount === 0) {
537
- usedBytes -= file.data.length;
538
- file.data = new Uint8Array(0);
731
+ usedBytes -= BigInt(file.data.bytes.length);
732
+ file.data = Content.empty();
539
733
  }
540
734
  };
541
735
  const detach = (node, now) => {
@@ -548,7 +742,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
548
742
  if (node.kind === "file")
549
743
  reclaim(node);
550
744
  else if (node.metadata.nlink === 0)
551
- usedBytes -= node.target.length;
745
+ usedBytes -= BigInt(node.target.length);
552
746
  }
553
747
  };
554
748
  const releaseFile = (ref) => {
@@ -565,14 +759,15 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
565
759
  if (length > BigInt(maxFileBytes))
566
760
  return yield* failure("FileTooLarge", operation);
567
761
  const size = Number(length);
568
- if (size - file.data.length > (settings.maxBytes ?? Number.MAX_SAFE_INTEGER) - usedBytes) {
762
+ if (settings.maxBytes !== undefined &&
763
+ BigInt(size - file.data.bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes) {
569
764
  return yield* failure("NoSpace", operation);
570
765
  }
571
766
  const data = new Uint8Array(size);
572
- data.set(file.data.subarray(0, size));
767
+ data.set(file.data.bytes.subarray(0, size));
573
768
  const now = yield* timestamp(operation);
574
- usedBytes += size - file.data.length;
575
- file.data = data;
769
+ usedBytes += BigInt(size - file.data.bytes.length);
770
+ file.data = Content.make(data);
576
771
  file.metadata = {
577
772
  ...file.metadata,
578
773
  size: length,
@@ -596,7 +791,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
596
791
  return yield* failure("InvalidArgument", "read");
597
792
  }
598
793
  const start = Number(offset > file.metadata.size ? file.metadata.size : offset);
599
- const data = file.data.slice(start, start + Math.min(maximum, file.data.length - start));
794
+ const data = file.data.bytes.slice(start, start + Math.min(maximum, file.data.bytes.length - start));
600
795
  if (maximum > 0) {
601
796
  file.metadata = { ...file.metadata, atimeNs: (yield* timestamp("read")) };
602
797
  }
@@ -621,19 +816,23 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
621
816
  if (offset >= BigInt(maxFileBytes))
622
817
  return yield* failure("FileTooLarge", "write");
623
818
  const start = Number(offset);
624
- const free = (settings.maxBytes ?? Number.MAX_SAFE_INTEGER) - usedBytes;
625
- const end = Math.min(maxFileBytes, file.data.length + free);
819
+ const free = settings.maxBytes === undefined
820
+ ? BigInt(maxFileBytes)
821
+ : ByteSize.toBigInt(settings.maxBytes) - usedBytes;
822
+ const maximumEnd = BigInt(file.data.bytes.length) + free;
823
+ const end = Number(BigInt(maxFileBytes) < maximumEnd ? BigInt(maxFileBytes) : maximumEnd);
626
824
  const count = Math.min(bytes.length, Math.max(0, end - start));
627
825
  if (count === 0)
628
826
  return yield* failure("NoSpace", "write");
629
- const size = Math.max(file.data.length, start + count);
630
- const data = size === file.data.length ? file.data : new Uint8Array(size);
631
- if (data !== file.data)
632
- data.set(file.data);
827
+ const size = Math.max(file.data.bytes.length, start + count);
828
+ // Always detach before mutation. A same-sized write is the critical
829
+ // case: the current payload may belong to the base or a prior capture.
830
+ const data = new Uint8Array(size);
831
+ data.set(file.data.bytes);
633
832
  const now = yield* timestamp("write");
634
833
  data.set(bytes.subarray(0, count), start);
635
- usedBytes += size - file.data.length;
636
- file.data = data;
834
+ usedBytes += BigInt(size - file.data.bytes.length);
835
+ file.data = Content.make(data);
637
836
  file.metadata = {
638
837
  ...file.metadata,
639
838
  size: BigInt(size),
@@ -750,7 +949,8 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
750
949
  if (++traversals > 40)
751
950
  return yield* failure("SymlinkLoop", operation, path.input);
752
951
  const suffix = work.suffixes[index] ?? new Uint8Array(0);
753
- if (settings.maxPathBytes !== undefined && child.target.length + suffix.length > settings.maxPathBytes) {
952
+ if (settings.maxPathBytes !== undefined &&
953
+ ByteSize.isGreaterThan(ByteSize.bytes(child.target.length + suffix.length), settings.maxPathBytes)) {
754
954
  return yield* failure("PathTooLong", operation, path.input);
755
955
  }
756
956
  const expansion = new Uint8Array(child.target.length + suffix.length);
@@ -958,7 +1158,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
958
1158
  if (node.kind !== "file")
959
1159
  return yield* failure("IsDirectory", "readFile", input);
960
1160
  yield* authorize(node, identity, 4, "readFile", input);
961
- const data = new Uint8Array(node.data);
1161
+ const data = new Uint8Array(node.data.bytes);
962
1162
  node.metadata = { ...node.metadata, atimeNs: (yield* timestamp("readFile")) };
963
1163
  return data;
964
1164
  }));
@@ -1009,14 +1209,15 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1009
1209
  else
1010
1210
  yield* authorize(file, identity, chosen.access === "readWrite" ? 6 : 2, "writeFile", input);
1011
1211
  const finalMode = chosen.finalMode === undefined ? undefined : yield* permittedMode(file?.metadata ?? { kind: "file", uid: identity.uid, gid: parent.metadata.gid }, chosen.finalMode, "writeFile", input);
1012
- const previous = file?.data.length ?? 0;
1212
+ const previous = file?.data.bytes.length ?? 0;
1013
1213
  const initial = chosen.truncate ? 0 : previous;
1014
1214
  const position = chosen.append ? initial : 0;
1015
1215
  const size = Math.max(initial, position + captured.length);
1016
1216
  if (size > maxFileBytes)
1017
1217
  return yield* failure("FileTooLarge", "writeFile", input);
1018
1218
  const reclaimed = replaced !== undefined && replaced.metadata.nlink === 1 ? replaced.target.length : 0;
1019
- if (size - previous > (settings.maxBytes ?? Number.MAX_SAFE_INTEGER) - usedBytes + reclaimed) {
1219
+ if (settings.maxBytes !== undefined &&
1220
+ BigInt(size - previous) > ByteSize.toBigInt(settings.maxBytes) - usedBytes + BigInt(reclaimed)) {
1020
1221
  return yield* failure("NoSpace", "writeFile", input);
1021
1222
  }
1022
1223
  if (file !== undefined && !chosen.truncate && captured.length === 0 && chosen.finalMode === undefined) {
@@ -1026,14 +1227,15 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1026
1227
  if (position !== 0 || size !== captured.length) {
1027
1228
  data = new Uint8Array(size);
1028
1229
  if (file !== undefined && !chosen.truncate)
1029
- data.set(file.data);
1230
+ data.set(file.data.bytes);
1030
1231
  data.set(captured, position);
1031
1232
  }
1032
1233
  const now = yield* timestamp("writeFile");
1033
1234
  const node = file ??
1034
1235
  {
1035
1236
  kind: "file",
1036
- data,
1237
+ lineage: undefined,
1238
+ data: Content.make(data),
1037
1239
  openCount: 0,
1038
1240
  metadata: {
1039
1241
  ...directoryMetadata(nextInode++, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
@@ -1041,7 +1243,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1041
1243
  nlink: 1
1042
1244
  }
1043
1245
  };
1044
- node.data = data;
1246
+ node.data = Content.make(data);
1045
1247
  node.metadata = {
1046
1248
  ...node.metadata,
1047
1249
  mode: finalMode ?? node.metadata.mode & ~0o6000,
@@ -1049,7 +1251,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1049
1251
  mtimeNs: now,
1050
1252
  ctimeNs: now
1051
1253
  };
1052
- usedBytes += size - previous;
1254
+ usedBytes += BigInt(size - previous);
1053
1255
  if (file === undefined) {
1054
1256
  if (replaced !== undefined)
1055
1257
  detach(replaced, now);
@@ -1152,7 +1354,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1152
1354
  if (typeof target === "string" && !wellFormed(target)) {
1153
1355
  return yield* failure("InvalidPathEncoding", "symlink", target);
1154
1356
  }
1155
- const rawTarget = typeof target === "string" ? new TextEncoder().encode(target) : bytePaths.get(target);
1357
+ const rawTarget = typeof target === "string" ? new TextEncoder().encode(target) : getBytePathBytes(target);
1156
1358
  if (rawTarget === undefined || rawTarget.includes(0)) {
1157
1359
  return yield* failure("InvalidArgument", "symlink", target);
1158
1360
  }
@@ -1170,11 +1372,13 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1170
1372
  if (path.trailingSlash)
1171
1373
  return yield* failure("NotDirectory", "symlink", input);
1172
1374
  if ((settings.maxEntries !== undefined && entries >= settings.maxEntries) ||
1173
- bytes.length > (settings.maxBytes ?? Number.MAX_SAFE_INTEGER) - usedBytes)
1375
+ (settings.maxBytes !== undefined &&
1376
+ BigInt(bytes.length) > ByteSize.toBigInt(settings.maxBytes) - usedBytes))
1174
1377
  return yield* failure("NoSpace", "symlink", input);
1175
1378
  const now = yield* timestamp("symlink");
1176
1379
  const node = {
1177
1380
  kind: "symlink",
1381
+ lineage: undefined,
1178
1382
  target: new Uint8Array(bytes),
1179
1383
  metadata: {
1180
1384
  ...directoryMetadata(nextInode, identity.uid, parent.metadata.gid, 0o777, now),
@@ -1187,7 +1391,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1187
1391
  parent.metadata = { ...parent.metadata, mtimeNs: now, ctimeNs: now };
1188
1392
  nextInode += 1n;
1189
1393
  entries += 1;
1190
- usedBytes += bytes.length;
1394
+ usedBytes += BigInt(bytes.length);
1191
1395
  publishEntry("Create", parent, name);
1192
1396
  }));
1193
1397
  }),
@@ -1269,7 +1473,8 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1269
1473
  const now = yield* timestamp("open");
1270
1474
  file = {
1271
1475
  kind: "file",
1272
- data: new Uint8Array(0),
1476
+ lineage: undefined,
1477
+ data: Content.empty(),
1273
1478
  openCount: 0,
1274
1479
  metadata: {
1275
1480
  ...directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (chosen.mode ?? 0o666) & 0o777 & ~umask, now),
@@ -1464,6 +1669,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1464
1669
  const now = yield* timestamp("mkdir");
1465
1670
  const child = {
1466
1671
  kind: "directory",
1672
+ lineage: undefined,
1467
1673
  parent,
1468
1674
  entries: new Map(),
1469
1675
  metadata: directoryMetadata(nextInode, identity.uid, parent.metadata.gid, (mode & 0o777 & ~umask) | (mode & 0o1000), now)
@@ -1506,6 +1712,28 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1506
1712
  })
1507
1713
  });
1508
1714
  };
1715
+ const baseObservation = source._tag === "Overlay" ? observeChanges() : undefined;
1716
+ const publicChange = (change) => {
1717
+ switch (change._tag) {
1718
+ case "Added":
1719
+ case "Removed":
1720
+ case "Updated":
1721
+ case "Replaced":
1722
+ return Object.freeze({ ...change, path: ownedPath(new Uint8Array(change.path)) });
1723
+ case "Renamed":
1724
+ return Object.freeze({
1725
+ ...change,
1726
+ from: ownedPath(new Uint8Array(change.from)),
1727
+ to: ownedPath(new Uint8Array(change.to))
1728
+ });
1729
+ }
1730
+ throw new Error("Unknown internal overlay change");
1731
+ };
1732
+ const publicChanges = (changes) => Object.freeze(changes.map(publicChange));
1733
+ const changeOptions = (options) => {
1734
+ const decoded = decodeConfiguration(OverlayChangesOptions, options === undefined ? {} : options);
1735
+ return Result.isFailure(decoded) ? Effect.fail(decoded.failure) : Effect.succeed(decoded.success);
1736
+ };
1509
1737
  const volume = Object.freeze({
1510
1738
  [VolumeId]: true,
1511
1739
  watch: Effect.gen(function* () {
@@ -1516,39 +1744,7 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1516
1744
  }));
1517
1745
  return Stream.fromEffectRepeat(PubSub.take(subscription));
1518
1746
  }).pipe(Effect.withSpan("Volume.watch")),
1519
- snapshot: coordinated(Effect.gen(function* () {
1520
- const ids = new Map([[root, "0"]]);
1521
- const pending = [root];
1522
- const records = [];
1523
- for (let index = 0; index < pending.length; index++) {
1524
- const node = pending[index];
1525
- if (node === undefined)
1526
- continue;
1527
- const id = ids.get(node);
1528
- if (id === undefined)
1529
- return yield* new ImageError({ code: "InvalidStructure" });
1530
- const metadata = storedMetadata(node.metadata);
1531
- if (node.kind === "directory") {
1532
- const children = [];
1533
- for (const [name, child] of node.entries) {
1534
- let target = ids.get(child);
1535
- if (target === undefined) {
1536
- target = String(ids.size);
1537
- ids.set(child, target);
1538
- pending.push(child);
1539
- }
1540
- children.push({ name: Image.base64(nameBytes(name)), target });
1541
- }
1542
- records.push({ id, kind: "directory", metadata, entries: children });
1543
- }
1544
- else if (node.kind === "file") {
1545
- records.push({ id, kind: "file", metadata, data: Image.base64(node.data) });
1546
- }
1547
- else
1548
- records.push({ id, kind: "symlink", metadata, target: Image.base64(node.target) });
1549
- }
1550
- return yield* Image.capture({ format: "effect-vfs", version: 1, root: "0", records });
1551
- })).pipe(Effect.withSpan("Volume.snapshot")),
1747
+ snapshot: coordinated(captureSnapshot()).pipe(Effect.withSpan("Volume.snapshot")),
1552
1748
  caller: Effect.fn("Volume.caller")(function* (options) {
1553
1749
  const decoded = decodeConfiguration(RootCallerOptions, options === undefined ? {} : options);
1554
1750
  if (Result.isFailure(decoded))
@@ -1558,7 +1754,27 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1558
1754
  return createCaller({ volume: volumeIdentity, directory: root, closed: false }, identity, decoded.success.umask ?? 0o022);
1559
1755
  })
1560
1756
  });
1561
- return volume;
1757
+ if (source._tag !== "Overlay" || baseObservation === undefined) {
1758
+ return { _tag: "Volume", volume };
1759
+ }
1760
+ const hook = OverlayTesting.getObservationHook(source.base);
1761
+ const overlay = Object.freeze({
1762
+ ...volume,
1763
+ changes: Effect.fn("OverlayVolume.changes")(function* (options) {
1764
+ const selected = yield* changeOptions(options);
1765
+ const current = yield* coordinated(Effect.sync(observeChanges));
1766
+ return publicChanges(compareOverlay(baseObservation, current, selected.includeTimestamps ?? false));
1767
+ }),
1768
+ capture: Effect.fn("OverlayVolume.capture")(function* (options) {
1769
+ const selected = yield* changeOptions(options);
1770
+ const current = yield* coordinated(captureState(hook));
1771
+ return Object.freeze({
1772
+ snapshot: current.snapshot,
1773
+ changes: publicChanges(compareOverlay(baseObservation, current.observation, selected.includeTimestamps ?? false))
1774
+ });
1775
+ })
1776
+ });
1777
+ return { _tag: "Overlay", volume: overlay };
1562
1778
  });
1563
1779
  /**
1564
1780
  * Creates a fresh empty volume and captures the current Effect `Clock`.
@@ -1572,7 +1788,10 @@ const makeVolume = Effect.fn("VirtualFileSystem.makeVolume")(function* (options,
1572
1788
  * @since 0.1.0
1573
1789
  */
1574
1790
  export const make = Effect.fn("VirtualFileSystem.make")(function* (options) {
1575
- return yield* makeVolume(options).pipe(Effect.catchTag("ImageError", Effect.die));
1791
+ const result = yield* makeVolume({ _tag: "Empty" }, options).pipe(Effect.catchTag("ImageError", Effect.die));
1792
+ if (result._tag === "Overlay")
1793
+ return yield* Effect.die(new Error("empty volume constructed as overlay"));
1794
+ return result.volume;
1576
1795
  });
1577
1796
  /**
1578
1797
  * Restores a fresh volume from an opaque snapshot under the supplied destination limits.
@@ -1582,7 +1801,33 @@ export const make = Effect.fn("VirtualFileSystem.make")(function* (options) {
1582
1801
  */
1583
1802
  export const fromSnapshot = Effect.fn("VirtualFileSystem.fromSnapshot")(function* (snapshot, options) {
1584
1803
  const image = yield* Image.inspect(snapshot);
1585
- return yield* makeVolume(options, image);
1804
+ const result = yield* makeVolume({ _tag: "Snapshot", image }, options);
1805
+ if (result._tag === "Overlay")
1806
+ return yield* new ImageError({ code: "InvalidStructure" });
1807
+ return result.volume;
1808
+ });
1809
+ /**
1810
+ * Creates an isolated writable volume relative to one immutable snapshot base.
1811
+ *
1812
+ * **Details**
1813
+ *
1814
+ * Workspaces made from the same snapshot share unchanged regular-file payloads.
1815
+ * The first content mutation copies the whole file into workspace-private
1816
+ * storage. Metadata, namespace state, coordination, handles, and watches are
1817
+ * always private to the new workspace.
1818
+ *
1819
+ * Invalid base snapshots fail with `ImageError`; invalid volume limits fail
1820
+ * with `ConfigurationError`. Each execution creates a fresh workspace.
1821
+ *
1822
+ * @category constructors
1823
+ * @since 0.1.0
1824
+ */
1825
+ export const makeOverlay = Effect.fn("VirtualFileSystem.makeOverlay")(function* (base, options) {
1826
+ const image = yield* Image.inspect(base);
1827
+ const result = yield* makeVolume({ _tag: "Overlay", base, image }, options);
1828
+ if (result._tag === "Volume")
1829
+ return yield* new ImageError({ code: "InvalidStructure" });
1830
+ return result.volume;
1586
1831
  });
1587
1832
  const FixtureMetadata = Schema.Struct({
1588
1833
  uid: Schema.optionalKey(Natural),
@@ -1595,7 +1840,7 @@ const FixtureMetadata = Schema.Struct({
1595
1840
  });
1596
1841
  const FixturePath = Schema.Union([
1597
1842
  Schema.String,
1598
- Schema.declare((value) => typeof value === "object" && value !== null && BytePathId in value && value[BytePathId] === true)
1843
+ BytePath
1599
1844
  ]);
1600
1845
  /**
1601
1846
  * Schema for a complete fixture namespace with optional metadata and forward hard links.
@@ -1713,7 +1958,7 @@ export const fromFixture = Effect.fn("VirtualFileSystem.fromFixture")(function*
1713
1958
  }
1714
1959
  const target = typeof entry.target === "string"
1715
1960
  ? new TextEncoder().encode(entry.target)
1716
- : bytePaths.get(entry.target);
1961
+ : getBytePathBytes(entry.target);
1717
1962
  if (target === undefined || target.includes(0)) {
1718
1963
  return yield* new ImageError({
1719
1964
  code: "InvalidStructure",
@@ -1764,5 +2009,9 @@ export const fromFixture = Effect.fn("VirtualFileSystem.fromFixture")(function*
1764
2009
  }
1765
2010
  const records = [...new Set(declarations.values())].map((record) => record.kind === "directory" ? { ...record, entries: children.get(record.id) ?? [] } : record);
1766
2011
  const snapshot = yield* Image.capture({ format: "effect-vfs", version: 1, root: "root", records });
1767
- return yield* makeVolume(config.success, yield* Image.inspect(snapshot));
2012
+ const image = yield* Image.inspect(snapshot);
2013
+ const result = yield* makeVolume({ _tag: "Snapshot", image }, config.success);
2014
+ if (result._tag === "Overlay")
2015
+ return yield* new ImageError({ code: "InvalidStructure" });
2016
+ return result.volume;
1768
2017
  });