@effect-vfs/core 0.4.0 → 0.6.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 (143) hide show
  1. package/README.md +39 -32
  2. package/dist/BytePath.d.ts +114 -4
  3. package/dist/BytePath.d.ts.map +1 -1
  4. package/dist/BytePath.js +155 -0
  5. package/dist/Caller.d.ts +391 -0
  6. package/dist/Caller.d.ts.map +1 -0
  7. package/dist/Caller.js +199 -0
  8. package/dist/FileHandle.d.ts +50 -0
  9. package/dist/FileHandle.d.ts.map +1 -0
  10. package/dist/FileHandle.js +28 -0
  11. package/dist/Fixture.d.ts +155 -0
  12. package/dist/Fixture.d.ts.map +1 -0
  13. package/dist/Fixture.js +65 -0
  14. package/dist/LiveVolume.d.ts +15 -22
  15. package/dist/LiveVolume.d.ts.map +1 -1
  16. package/dist/LiveVolume.js +19 -15
  17. package/dist/Metadata.d.ts +186 -0
  18. package/dist/Metadata.d.ts.map +1 -0
  19. package/dist/Metadata.js +125 -0
  20. package/dist/Snapshot.d.ts +31 -53
  21. package/dist/Snapshot.d.ts.map +1 -1
  22. package/dist/Snapshot.js +26 -49
  23. package/dist/SnapshotDelta.d.ts +24 -39
  24. package/dist/SnapshotDelta.d.ts.map +1 -1
  25. package/dist/SnapshotDelta.js +14 -35
  26. package/dist/Target.d.ts +225 -0
  27. package/dist/Target.d.ts.map +1 -0
  28. package/dist/Target.js +92 -0
  29. package/dist/Testing.d.ts +128 -0
  30. package/dist/Testing.d.ts.map +1 -0
  31. package/dist/Testing.js +111 -0
  32. package/dist/VfsError.d.ts +199 -0
  33. package/dist/VfsError.d.ts.map +1 -0
  34. package/dist/VfsError.js +164 -0
  35. package/dist/VirtualFileSystem.d.ts +628 -347
  36. package/dist/VirtualFileSystem.d.ts.map +1 -1
  37. package/dist/VirtualFileSystem.js +450 -117
  38. package/dist/Volume.d.ts +211 -0
  39. package/dist/Volume.d.ts.map +1 -0
  40. package/dist/Volume.js +172 -0
  41. package/dist/Watch.d.ts +68 -0
  42. package/dist/Watch.d.ts.map +1 -0
  43. package/dist/Watch.js +46 -0
  44. package/dist/index.d.ts +56 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +56 -1
  47. package/dist/internal/budget.d.ts +90 -0
  48. package/dist/internal/budget.d.ts.map +1 -0
  49. package/dist/internal/budget.js +70 -0
  50. package/dist/internal/bytePath.d.ts +2 -0
  51. package/dist/internal/bytePath.d.ts.map +1 -1
  52. package/dist/internal/bytePath.js +3 -2
  53. package/dist/internal/bytes.d.ts +3 -0
  54. package/dist/internal/bytes.d.ts.map +1 -1
  55. package/dist/internal/bytes.js +4 -2
  56. package/dist/internal/canonicalBase64.d.ts +1 -4
  57. package/dist/internal/canonicalBase64.d.ts.map +1 -1
  58. package/dist/internal/canonicalBase64.js +9 -13
  59. package/dist/internal/errors.d.ts +54 -0
  60. package/dist/internal/errors.d.ts.map +1 -0
  61. package/dist/internal/errors.js +91 -0
  62. package/dist/internal/{virtualFileSystem/fixture.d.ts → fixture.d.ts} +8 -8
  63. package/dist/internal/fixture.d.ts.map +1 -0
  64. package/dist/internal/fixture.js +133 -0
  65. package/dist/internal/hex128.d.ts +12 -0
  66. package/dist/internal/hex128.d.ts.map +1 -0
  67. package/dist/internal/hex128.js +7 -0
  68. package/dist/internal/hmac.d.ts +5 -0
  69. package/dist/internal/hmac.d.ts.map +1 -0
  70. package/dist/internal/hmac.js +150 -0
  71. package/dist/internal/image.d.ts +14 -117
  72. package/dist/internal/image.d.ts.map +1 -1
  73. package/dist/internal/image.js +137 -167
  74. package/dist/internal/inodeTable.d.ts +20 -0
  75. package/dist/internal/inodeTable.d.ts.map +1 -0
  76. package/dist/internal/inodeTable.js +48 -0
  77. package/dist/internal/lines.d.ts +36 -0
  78. package/dist/internal/lines.d.ts.map +1 -0
  79. package/dist/internal/lines.js +115 -0
  80. package/dist/internal/liveImage.d.ts +19 -278
  81. package/dist/internal/liveImage.d.ts.map +1 -1
  82. package/dist/internal/liveImage.js +75 -166
  83. package/dist/internal/merkle.d.ts +45 -0
  84. package/dist/internal/merkle.d.ts.map +1 -0
  85. package/dist/internal/merkle.js +179 -0
  86. package/dist/internal/metadata.d.ts +2 -13
  87. package/dist/internal/metadata.d.ts.map +1 -1
  88. package/dist/internal/metadata.js +5 -15
  89. package/dist/internal/{virtualFileSystem/overlayDiff.d.ts → overlayDiff.d.ts} +3 -3
  90. package/dist/internal/overlayDiff.d.ts.map +1 -0
  91. package/dist/internal/{virtualFileSystem/overlayDiff.js → overlayDiff.js} +1 -4
  92. package/dist/internal/{virtualFileSystem/path.d.ts → path.d.ts} +19 -7
  93. package/dist/internal/path.d.ts.map +1 -0
  94. package/dist/internal/{virtualFileSystem/path.js → path.js} +39 -25
  95. package/dist/internal/snapshotDelta.d.ts +61 -60
  96. package/dist/internal/snapshotDelta.d.ts.map +1 -1
  97. package/dist/internal/snapshotDelta.js +612 -504
  98. package/dist/internal/snapshotDeltaModel.d.ts.map +1 -1
  99. package/dist/internal/snapshotDeltaModel.js +0 -1
  100. package/dist/internal/stagedState.d.ts +18 -0
  101. package/dist/internal/stagedState.d.ts.map +1 -0
  102. package/dist/internal/stagedState.js +18 -0
  103. package/dist/internal/testSeams.d.ts +17 -0
  104. package/dist/internal/testSeams.d.ts.map +1 -0
  105. package/dist/internal/testSeams.js +20 -0
  106. package/dist/internal/tree.d.ts +225 -0
  107. package/dist/internal/tree.d.ts.map +1 -0
  108. package/dist/internal/tree.js +385 -0
  109. package/dist/internal/turnstile.d.ts +8 -0
  110. package/dist/internal/turnstile.d.ts.map +1 -0
  111. package/dist/internal/turnstile.js +43 -0
  112. package/dist/internal/virtualFileSystem.d.ts +41 -491
  113. package/dist/internal/virtualFileSystem.d.ts.map +1 -1
  114. package/dist/internal/virtualFileSystem.js +2118 -2471
  115. package/dist/internal/volumeState.d.ts +110 -0
  116. package/dist/internal/volumeState.d.ts.map +1 -0
  117. package/dist/internal/volumeState.js +204 -0
  118. package/dist/internal/watchHub.d.ts +25 -0
  119. package/dist/internal/watchHub.d.ts.map +1 -0
  120. package/dist/internal/watchHub.js +68 -0
  121. package/package.json +53 -9
  122. package/dist/VirtualFileSystemError.d.ts +0 -54
  123. package/dist/VirtualFileSystemError.d.ts.map +0 -1
  124. package/dist/VirtualFileSystemError.js +0 -49
  125. package/dist/internal/virtualFileSystem/errors.d.ts +0 -12
  126. package/dist/internal/virtualFileSystem/errors.d.ts.map +0 -1
  127. package/dist/internal/virtualFileSystem/errors.js +0 -47
  128. package/dist/internal/virtualFileSystem/fixture.d.ts.map +0 -1
  129. package/dist/internal/virtualFileSystem/fixture.js +0 -150
  130. package/dist/internal/virtualFileSystem/overlayContent.d.ts +0 -15
  131. package/dist/internal/virtualFileSystem/overlayContent.d.ts.map +0 -1
  132. package/dist/internal/virtualFileSystem/overlayContent.js +0 -29
  133. package/dist/internal/virtualFileSystem/overlayDiff.d.ts.map +0 -1
  134. package/dist/internal/virtualFileSystem/path.d.ts.map +0 -1
  135. package/dist/internal/virtualFileSystem/stagedState.d.ts +0 -22
  136. package/dist/internal/virtualFileSystem/stagedState.d.ts.map +0 -1
  137. package/dist/internal/virtualFileSystem/stagedState.js +0 -62
  138. package/dist/internal/virtualFileSystem/testHooks.d.ts +0 -20
  139. package/dist/internal/virtualFileSystem/testHooks.d.ts.map +0 -1
  140. package/dist/internal/virtualFileSystem/testHooks.js +0 -23
  141. package/dist/internal/virtualFileSystem/watchHub.d.ts +0 -16
  142. package/dist/internal/virtualFileSystem/watchHub.d.ts.map +0 -1
  143. package/dist/internal/virtualFileSystem/watchHub.js +0 -54
@@ -1,187 +1,157 @@
1
- // Snapshot validation and serialization used by VirtualFileSystem.
2
- import * as ByteSize from "effect/ByteSize";
3
1
  import * as Effect from "effect/Effect";
4
- import * as Match from "effect/Match";
2
+ import * as Option from "effect/Option";
3
+ import * as Predicate from "effect/Predicate";
5
4
  import * as Result from "effect/Result";
6
5
  import * as Schema from "effect/Schema";
7
- import { DecodeLimits, ImageError, SnapshotTypeId } from "../Snapshot.js";
8
- import { CanonicalBase64 } from "./canonicalBase64.js";
9
- import { StoredMetadata, WireStoredMetadata } from "./metadata.js";
6
+ import * as Stream from "effect/Stream";
7
+ import { decodeOption } from "../BytePath.js";
8
+ import { SnapshotTypeId } from "../Snapshot.js";
9
+ import { make as makeBytePath } from "./bytePath.js";
10
+ import { fsFailure, imageFailure } from "./errors.js";
11
+ import * as Lines from "./lines.js";
12
+ import { DOT_DOT_HEX, DOT_HEX, joinPath, MAX_SYMLINK_TRAVERSALS, nameBytes, ownedPath, preparePath } from "./path.js";
13
+ import * as Tree from "./tree.js";
14
+ import { assemble, byEntryName, getNode, ROOT_INO, storedMetadata } from "./volumeState.js";
15
+ // Volume values are immutable, so snapshots share them without exposing their state.
10
16
  class SnapshotImpl {
11
17
  [SnapshotTypeId] = SnapshotTypeId;
18
+ #value;
19
+ constructor(value) {
20
+ this.#value = value;
21
+ }
22
+ static valueOf(snapshot) {
23
+ return #value in snapshot ? snapshot.#value : undefined;
24
+ }
12
25
  }
13
26
  /** @internal */
14
- export { StoredMetadata };
15
- const id = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128));
27
+ export const make = (value) => Object.freeze(new SnapshotImpl(value));
16
28
  /** @internal */
17
- export const Record = Schema.TaggedUnion({
18
- directory: {
19
- id,
20
- metadata: StoredMetadata,
21
- entries: Schema.Array(Schema.Struct({ name: CanonicalBase64.Encoded, target: id }))
22
- },
23
- file: { id, metadata: StoredMetadata, data: CanonicalBase64.Encoded },
24
- symlink: { id, metadata: StoredMetadata, target: CanonicalBase64.Encoded }
29
+ export const valueOf = (snapshot) => Effect.suspend(() => {
30
+ const value = SnapshotImpl.valueOf(snapshot);
31
+ return value === undefined
32
+ ? imageFailure("decodeSnapshot", "InvalidStructure", { field: "snapshot" })
33
+ : Effect.succeed(value);
25
34
  });
35
+ const HEADER = JSON.stringify(Tree.SnapshotHeader.make({ format: "effect-vfs", version: 1 }));
36
+ const nodeText = Schema.encodeResult(Schema.fromJsonString(Tree.TreeNode));
37
+ // Charge encoded lines by decoder rules so any successful output fits the same budget.
26
38
  /** @internal */
27
- export const Document = Schema.Struct({
28
- format: Schema.Literal("effect-vfs"),
29
- version: Schema.Literal(1),
30
- root: id,
31
- records: Schema.Array(Record)
32
- });
33
- const WireRecord = Schema.TaggedUnion({
34
- directory: {
35
- id,
36
- metadata: WireStoredMetadata,
37
- entries: Schema.Array(Schema.Struct({ name: Schema.String, target: id }))
38
- },
39
- file: { id, metadata: WireStoredMetadata, data: Schema.String },
40
- symlink: { id, metadata: WireStoredMetadata, target: Schema.String }
41
- });
42
- const WireDocument = Schema.Struct({
43
- format: Schema.Literal("effect-vfs"),
44
- version: Schema.Literal(1),
45
- root: id,
46
- records: Schema.Array(WireRecord)
47
- });
48
- const VersionProbe = Schema.Struct({
49
- format: Schema.Literal("effect-vfs"),
50
- version: Schema.Unknown
51
- });
52
- const snapshots = new WeakMap();
39
+ export const encodeSnapshotStream = (snapshot, budget) => Stream.unwrap(Effect.gen(function* () {
40
+ const nodes = yield* Tree.treeNodes(yield* valueOf(snapshot));
41
+ const operation = "encodeSnapshot";
42
+ return Tree.writeTree({
43
+ operation,
44
+ documentField: "text",
45
+ header: HEADER,
46
+ node: Tree.treeNode,
47
+ text: nodeText,
48
+ meter: budget === undefined ? undefined : Tree.meter(budget, operation)
49
+ }, nodes);
50
+ }));
53
51
  /** @internal */
54
- export const inspect = (snapshot) => Effect.suspend(() => {
55
- const document = snapshots.get(snapshot);
56
- return document === undefined
57
- ? new ImageError({ code: "InvalidStructure", field: "snapshot" })
58
- : Effect.succeed(document);
59
- });
52
+ export const encodeSnapshot = (snapshot, budget) => Lines.collect(encodeSnapshotStream(snapshot, budget));
60
53
  /** @internal */
61
- export const capture = Effect.fnUntraced(function* (input, limits, trusted = false) {
62
- const wire = trusted ? undefined : Schema.decodeUnknownResult(WireDocument, { onExcessProperty: "error" })(input);
63
- if (wire !== undefined && Result.isFailure(wire)) {
64
- return yield* new ImageError({ code: "InvalidStructure", field: "document" });
65
- }
66
- const decoded = trusted ? undefined : Schema.decodeUnknownResult(Document, { onExcessProperty: "error" })(input);
67
- if (decoded !== undefined && Result.isFailure(decoded)) {
68
- return yield* new ImageError({ code: "InvalidEncoding", field: "document" });
69
- }
70
- // SAFETY: a trusted caller passes a Document it built; an untrusted one has just been decoded.
71
- const document = decoded === undefined ? input : decoded.success;
72
- const records = new Map();
73
- let entries = 0;
74
- let payload = ByteSize.zero;
75
- if (limits !== undefined && document.records.length > limits.maxRecords) {
76
- return yield* new ImageError({ code: "LimitExceeded", field: "records" });
77
- }
78
- for (const record of document.records) {
79
- if (records.has(record.id))
80
- return yield* new ImageError({ code: "InvalidStructure", field: "id" });
81
- records.set(record.id, record);
82
- Match.value(record).pipe(Match.tag("directory", (record) => {
83
- entries += record.entries.length;
84
- }), Match.undefined);
85
- }
86
- if (limits !== undefined && entries > limits.maxEntries) {
87
- return yield* new ImageError({ code: "LimitExceeded", field: "entries" });
88
- }
89
- // Count every payload before allocating any decoded payload buffer. Trusted records carry
90
- // base64 this module produced, so the scan only runs when a limit depends on the tally.
91
- if (!trusted || limits !== undefined) {
92
- for (const record of document.records) {
93
- const values = Match.value(record).pipe(Match.tag("directory", (record) => record.entries.map((entry) => entry.name)), Match.tag("file", (record) => [record.data]), Match.tag("symlink", (record) => [record.target]), Match.exhaustive);
94
- for (const value of values) {
95
- if (!CanonicalBase64.is(value)) {
96
- return yield* new ImageError({ code: "InvalidEncoding", field: record.id });
97
- }
98
- payload = ByteSize.sum(payload, ByteSize.bytes(CanonicalBase64.decodedLength(value)));
99
- if (limits !== undefined && ByteSize.isGreaterThan(payload, limits.maxDecodedBytes)) {
100
- return yield* new ImageError({ code: "LimitExceeded", field: "bytes" });
101
- }
102
- }
103
- }
104
- }
105
- const root = records.get(document.root);
106
- if (root === undefined || !Record.guards.directory(root)) {
107
- return yield* new ImageError({ code: "InvalidStructure", field: "root" });
108
- }
109
- const parents = new Map();
110
- for (const record of document.records) {
111
- if (Record.guards.directory(record)) {
112
- const names = new Set();
113
- for (const entry of record.entries) {
114
- if (names.has(entry.name) || CanonicalBase64.decodedLength(entry.name) < 1 ||
115
- CanonicalBase64.decodedLength(entry.name) > 255)
116
- return yield* new ImageError({ code: "InvalidStructure", field: "name" });
117
- names.add(entry.name);
118
- const name = yield* CanonicalBase64.decode(entry.name);
119
- if (name.includes(0) || name.includes(47) || (name.length === 1 && name[0] === 46) ||
120
- (name.length === 2 && name[0] === 46 && name[1] === 46))
121
- return yield* new ImageError({ code: "InvalidStructure", field: "name" });
122
- const target = records.get(entry.target);
123
- if (target === undefined)
124
- return yield* new ImageError({ code: "InvalidStructure", field: "target" });
125
- parents.set(target.id, (parents.get(target.id) ?? 0) + 1);
126
- }
54
+ export const decodeSnapshotSink = (budget) => {
55
+ const operation = "decodeSnapshot";
56
+ return Lines.sink({ operation, inputField: "input", textField: "text" }, () => {
57
+ const meter = Tree.meter(budget, operation);
58
+ const reader = Tree.snapshotReader({ operation, documentField: "document", meter });
59
+ return {
60
+ meter,
61
+ fold: { line: reader.line, end: () => Result.map(reader.end(), (read) => make(assemble(read.specs))) }
62
+ };
63
+ });
64
+ };
65
+ const OPERATION = "snapshotEntries";
66
+ const encoder = new TextEncoder();
67
+ // Match privileged lookup: follow intermediate links, honor trailing slashes, and leave the final link intact.
68
+ const resolve = (value, input) => {
69
+ const fail = (code) => Result.fail(fsFailure(code, OPERATION, { path: input }));
70
+ const prepared = preparePath(input, OPERATION, undefined);
71
+ if (Result.isFailure(prepared))
72
+ return Result.fail(prepared.failure);
73
+ const root = getNode(value, ROOT_INO);
74
+ let work = prepared.success;
75
+ let current = root;
76
+ let traversals = 0;
77
+ for (let index = 0; index < work.components.length; index++) {
78
+ if (current.kind !== "directory")
79
+ return fail("NotDirectory");
80
+ const component = work.components[index];
81
+ if (component === DOT_HEX)
82
+ continue;
83
+ if (component === DOT_DOT_HEX) {
84
+ current = getNode(value, current.parent);
85
+ continue;
127
86
  }
128
- else if (Record.guards.symlink(record) && (yield* CanonicalBase64.decode(record.target)).includes(0)) {
129
- return yield* new ImageError({ code: "InvalidStructure", field: "symlink" });
87
+ const ino = current.entries.get(component);
88
+ const child = ino === undefined ? undefined : getNode(value, ino);
89
+ if (child === undefined)
90
+ return fail("NotFound");
91
+ if (child.kind !== "symlink" || (index === work.components.length - 1 && !work.trailingSlash)) {
92
+ current = child;
93
+ continue;
130
94
  }
95
+ if (child.target.length === 0)
96
+ return fail("NotFound");
97
+ if (++traversals > MAX_SYMLINK_TRAVERSALS)
98
+ return fail("SymlinkLoop");
99
+ const suffix = work.suffixes[index] ?? new Uint8Array(0);
100
+ const expansion = new Uint8Array(child.target.length + suffix.length);
101
+ expansion.set(child.target);
102
+ expansion.set(suffix, child.target.length);
103
+ const expanded = preparePath(ownedPath(expansion), OPERATION, undefined);
104
+ if (Result.isFailure(expanded))
105
+ return fail(expanded.failure.code);
106
+ work = expanded.success;
107
+ if (work.absolute)
108
+ current = root;
109
+ index = -1;
131
110
  }
132
- if (parents.has(root.id))
133
- return yield* new ImageError({ code: "InvalidStructure", field: "root" });
134
- for (const record of document.records) {
135
- const invalidParent = Match.value(record).pipe(Match.tag("directory", (record) => parents.get(record.id) !== 1), Match.tag("file", (record) => !parents.has(record.id)), Match.tag("symlink", (record) => !parents.has(record.id)), Match.exhaustive);
136
- if (record !== root && invalidParent) {
137
- return yield* new ImageError({ code: "InvalidStructure", field: "parent" });
111
+ return work.trailingSlash && current.kind !== "directory" ? fail("NotDirectory") : Result.succeed(current);
112
+ };
113
+ const text = (bytes) => Option.getOrUndefined(decodeOption(bytes));
114
+ const childPath = (parent, name) => {
115
+ const named = text(name);
116
+ if (Predicate.isString(parent) && named !== undefined)
117
+ return parent === "/" ? `/${named}` : `${parent}/${named}`;
118
+ return joinPath(Predicate.isString(parent) ? encoder.encode(parent) : parent, name);
119
+ };
120
+ const pathInput = (path) => (Predicate.isString(path) ? path : makeBytePath(path));
121
+ // Emit the first hard-link path as content and later paths as links; copy file bytes only on pull.
122
+ function* walk(value, node) {
123
+ const pending = [[node, "/"]];
124
+ const first = new Map();
125
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
126
+ const [current, walked] = next;
127
+ const path = pathInput(walked);
128
+ const metadata = storedMetadata(current.metadata);
129
+ if (current.kind === "directory") {
130
+ yield { kind: "directory", path, metadata };
131
+ const children = [...current.entries].sort(byEntryName).reverse();
132
+ for (const [name, ino] of children)
133
+ pending.push([getNode(value, ino), childPath(walked, nameBytes(name))]);
134
+ continue;
138
135
  }
139
- }
140
- const visited = new Set();
141
- const pending = [root.id];
142
- while (pending.length > 0) {
143
- const next = pending.pop();
144
- if (next === undefined || visited.has(next))
136
+ const alias = current.links.length > 1 ? first.get(current.ino) : undefined;
137
+ if (alias !== undefined) {
138
+ yield { kind: "hardLink", path, target: alias };
145
139
  continue;
146
- visited.add(next);
147
- const record = records.get(next);
148
- if (record !== undefined) {
149
- Match.value(record).pipe(Match.tag("directory", (record) => {
150
- for (const entry of record.entries)
151
- pending.push(entry.target);
152
- }), Match.tag("file", () => undefined), Match.tag("symlink", () => undefined), Match.exhaustive);
140
+ }
141
+ if (current.links.length > 1)
142
+ first.set(current.ino, path);
143
+ if (current.kind === "file")
144
+ yield { kind: "file", path, bytes: current.data.slice(), metadata };
145
+ else {
146
+ const target = text(current.target);
147
+ yield { kind: "symlink", path, target: target ?? makeBytePath(current.target.slice()), metadata };
153
148
  }
154
149
  }
155
- if (visited.size !== records.size)
156
- return yield* new ImageError({ code: "InvalidStructure", field: "reachability" });
157
- const snapshot = Object.freeze(new SnapshotImpl());
158
- snapshots.set(snapshot, document);
159
- return snapshot;
160
- });
161
- /** @internal */
162
- export const encodeSnapshot = Effect.fn("VirtualFileSystem.encodeSnapshot")(function* (snapshot) {
163
- const text = yield* Schema.encodeEffect(Schema.fromJsonString(Document))(yield* inspect(snapshot)).pipe(Effect.mapError(() => new ImageError({ code: "InvalidStructure", field: "text" })));
164
- return new TextEncoder().encode(text);
165
- });
150
+ }
166
151
  /** @internal */
167
- export const decodeSnapshot = Effect.fn("VirtualFileSystem.decodeSnapshot")(function* (input, limits) {
168
- const checked = Schema.decodeResult(DecodeLimits, { onExcessProperty: "error" })(limits);
169
- if (Result.isFailure(checked))
170
- return yield* new ImageError({ code: "InvalidStructure", field: "limits" });
171
- if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer)) {
172
- return yield* new ImageError({ code: "InvalidEncoding", field: "input" });
173
- }
174
- if (ByteSize.isGreaterThan(ByteSize.bytes(input.byteLength), checked.success.maxEncodedBytes)) {
175
- return yield* new ImageError({ code: "LimitExceeded", field: "encodedBytes" });
176
- }
177
- const text = yield* Effect.try({
178
- try: () => new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(input)),
179
- catch: () => new ImageError({ code: "InvalidEncoding", field: "text" })
180
- });
181
- const value = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe(Effect.mapError(() => new ImageError({ code: "InvalidEncoding", field: "text" })));
182
- const version = Schema.decodeUnknownResult(VersionProbe)(value);
183
- if (Result.isSuccess(version) && version.success.version !== 1) {
184
- return yield* new ImageError({ code: "UnsupportedVersion", field: "version" });
185
- }
186
- return yield* capture(value, checked.success);
187
- });
152
+ export const snapshotEntries = (snapshot, root) => Stream.unwrap(Effect.gen(function* () {
153
+ const value = yield* valueOf(snapshot);
154
+ const node = yield* Effect.fromResult(resolve(value, root));
155
+ // Bound each pulled chunk to one file's copied bytes.
156
+ return Stream.fromIterable({ [Symbol.iterator]: () => walk(value, node) }, { chunkSize: 1 });
157
+ }));
@@ -0,0 +1,20 @@
1
+ /** @internal */
2
+ export type Owner = symbol;
3
+ interface Slots<A> {
4
+ readonly owner: Owner | undefined;
5
+ readonly items: Array<Slots<A> | A | undefined>;
6
+ }
7
+ /** @internal */
8
+ export interface InodeTable<A> {
9
+ readonly root: Slots<A>;
10
+ readonly levels: number;
11
+ readonly capacity: number;
12
+ }
13
+ /** @internal */
14
+ export declare const empty: <A>() => InodeTable<A>;
15
+ /** @internal */
16
+ export declare const get: <A>(table: InodeTable<A>, key: number) => A | undefined;
17
+ /** @internal */
18
+ export declare const set: <A>(table: InodeTable<A>, key: number, value: A | undefined, owner?: Owner) => InodeTable<A>;
19
+ export {};
20
+ //# sourceMappingURL=inodeTable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inodeTable.d.ts","sourceRoot":"","sources":["../../src/internal/inodeTable.ts"],"names":[],"mappings":"AAQA,gBAAgB;AAChB,MAAM,MAAM,KAAK,GAAG,MAAM,CAAA;AAE1B,UAAU,KAAK,CAAC,CAAC;IACf,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,CAAA;IACjC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC,CAAA;CAChD;AAED,gBAAgB;AAChB,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,gBAAgB;AAChB,eAAO,MAAM,KAAK,GAAI,CAAC,OAAK,UAAU,CAAC,CAAC,CAA4E,CAAA;AAKpH,gBAAgB;AAChB,eAAO,MAAM,GAAG,GAAI,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,OAAO,MAAM,KAAG,CAAC,GAAG,SAgB9D,CAAA;AAsBD,gBAAgB;AAChB,eAAO,MAAM,GAAG,GAAI,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,OAAO,MAAM,SAAS,CAAC,GAAG,SAAS,UAAU,KAAK,KAAG,UAAU,CAAC,CAAC,CAU3G,CAAA"}
@@ -0,0 +1,48 @@
1
+ // A 32-way persistent trie. One owner may mutate paths it has already copied within a batch.
2
+ const BITS = 5;
3
+ const WIDTH = 1 << BITS;
4
+ const MASK = WIDTH - 1;
5
+ const SHIFTABLE = 0x100000000; // Shifts wrap keys at 2^32 and shift counts at 32.
6
+ const MAX_SHIFT = 32;
7
+ /** @internal */
8
+ export const empty = () => ({ root: { owner: undefined, items: [] }, levels: 1, capacity: WIDTH });
9
+ const index = (key, shift) => key < SHIFTABLE && shift < MAX_SHIFT ? (key >>> shift) & MASK : Math.floor(key / 2 ** shift) % WIDTH;
10
+ /** @internal */
11
+ export const get = (table, key) => {
12
+ if (key >= table.capacity)
13
+ return undefined;
14
+ let node = table.root;
15
+ let shift = (table.levels - 1) * BITS;
16
+ while (shift > 0) {
17
+ const next = node.items[index(key, shift)];
18
+ if (next === undefined)
19
+ return undefined;
20
+ // SAFETY: every slot above the leaf level holds a Slots value or nothing.
21
+ node = next;
22
+ shift -= BITS;
23
+ }
24
+ // SAFETY: leaf level slots hold values or nothing.
25
+ return node.items[key & MASK];
26
+ };
27
+ const setIn = (node, key, value, shift, owner) => {
28
+ const target = owner !== undefined && node.owner === owner ? node : { owner, items: node.items.slice() };
29
+ if (shift === 0)
30
+ target.items[key & MASK] = value;
31
+ else {
32
+ const at = index(key, shift);
33
+ // SAFETY: every slot above the leaf level holds a Slots value or nothing.
34
+ const child = node.items[at] ?? { owner: undefined, items: [] };
35
+ target.items[at] = setIn(child, key, value, shift - BITS, owner);
36
+ }
37
+ return target;
38
+ };
39
+ /** @internal */
40
+ export const set = (table, key, value, owner) => {
41
+ let { capacity, levels, root } = table;
42
+ while (key >= capacity) {
43
+ root = { owner, items: [root] };
44
+ levels += 1;
45
+ capacity *= WIDTH;
46
+ }
47
+ return { root: setIn(root, key, value, (levels - 1) * BITS, owner), levels, capacity };
48
+ };
@@ -0,0 +1,36 @@
1
+ import * as ByteSize from "effect/ByteSize";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Result from "effect/Result";
4
+ import type * as Schema from "effect/Schema";
5
+ import * as Sink from "effect/Sink";
6
+ import * as Stream from "effect/Stream";
7
+ import type { ImageFailure } from "../VfsError.js";
8
+ /** @internal */
9
+ export interface Framing {
10
+ readonly operation: string;
11
+ readonly inputField: string;
12
+ readonly textField: string;
13
+ }
14
+ /** @internal */
15
+ export interface FrameMeter {
16
+ readonly line: (bytes: number, ended: boolean) => Result.Result<void, ImageFailure>;
17
+ }
18
+ /** @internal */
19
+ export interface LineFold<A> {
20
+ readonly line: (value: typeof Schema.Unknown.Type, index: number) => Result.Result<void, ImageFailure>;
21
+ readonly end: () => Result.Result<A, ImageFailure>;
22
+ }
23
+ /** @internal */
24
+ export declare const byteLimit: (limit: ByteSize.ByteSize) => number;
25
+ /** @internal */
26
+ export declare const sink: <A>(framing: Framing, start: () => {
27
+ readonly meter: FrameMeter;
28
+ readonly fold: LineFold<A>;
29
+ }) => Sink.Sink<A, Uint8Array, never, ImageFailure>;
30
+ /** @internal */
31
+ export declare const frame: (lines: ReadonlyArray<Uint8Array>) => Uint8Array;
32
+ /** @internal */
33
+ export declare const encodeLine: (text: string) => Uint8Array;
34
+ /** @internal */
35
+ export declare const collect: <E, R>(stream: Stream.Stream<Uint8Array, E, R>) => Effect.Effect<Uint8Array, E, R>;
36
+ //# sourceMappingURL=lines.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lines.d.ts","sourceRoot":"","sources":["../../src/internal/lines.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAA;AAC3C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,KAAK,MAAM,MAAM,eAAe,CAAA;AAC5C,OAAO,KAAK,IAAI,MAAM,aAAa,CAAA;AACnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAQlD,gBAAgB;AAChB,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,gBAAgB;AAChB,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAA;CACpF;AAED,gBAAgB;AAChB,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,OAAO,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAA;IACtG,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,YAAY,CAAC,CAAA;CACnD;AAED,gBAAgB;AAChB,eAAO,MAAM,SAAS,UAAW,QAAQ,CAAC,QAAQ,KAAG,MAIpD,CAAA;AAkGD,gBAAgB;AAChB,eAAO,MAAM,IAAI,GAAI,CAAC,WACX,OAAO,SACT,MAAM;IAAE,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;CAAE,KACtE,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,YAAY,CAUsB,CAAA;AAIrE,gBAAgB;AAChB,eAAO,MAAM,KAAK,UAAW,aAAa,CAAC,UAAU,CAAC,KAAG,UAWxD,CAAA;AAED,gBAAgB;AAChB,eAAO,MAAM,UAAU,SAAU,MAAM,KAAG,UAAkC,CAAA;AAE5E,gBAAgB;AAChB,eAAO,MAAM,OAAO,GAAI,CAAC,EAAE,CAAC,UAAU,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC,KAAG,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC,CAInG,CAAA"}
@@ -0,0 +1,115 @@
1
+ // Tree images require bounded, newline-terminated JSON lines with strict UTF-8. Text stream operators cannot
2
+ // enforce these byte limits or reject malformed UTF-8 and lone carriage returns.
3
+ import * as ByteSize from "effect/ByteSize";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Result from "effect/Result";
6
+ import * as Sink from "effect/Sink";
7
+ import * as Stream from "effect/Stream";
8
+ import { imageFailure } from "./errors.js";
9
+ import { isAttachedBytes } from "./path.js";
10
+ import { WALK_YIELD_INTERVAL } from "./volumeState.js";
11
+ const NEWLINE = 0x0a;
12
+ const CARRIAGE_RETURN = 0x0d;
13
+ /** @internal */
14
+ export const byteLimit = (limit) => {
15
+ const bytes = ByteSize.toBigInt(limit);
16
+ return bytes > BigInt(Number.MAX_SAFE_INTEGER) ? Number.POSITIVE_INFINITY : Number(bytes);
17
+ };
18
+ const concat = (pieces, length) => {
19
+ const out = new Uint8Array(length);
20
+ let offset = 0;
21
+ for (const piece of pieces) {
22
+ out.set(piece, offset);
23
+ offset += piece.length;
24
+ }
25
+ return out;
26
+ };
27
+ const EMPTY = new Uint8Array(0);
28
+ const makeReader = (framing, meter, fold) => {
29
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
30
+ const invalid = (cause) => imageFailure(framing.operation, "InvalidEncoding", { field: framing.textField, cause });
31
+ // Chunks may be reused after reading, so an unfinished line owns its bytes.
32
+ let pending = EMPTY;
33
+ let pendingBytes = 0;
34
+ let lines = 0;
35
+ const hold = (piece, length) => {
36
+ if (length > pending.length) {
37
+ const grown = new Uint8Array(Math.max(length, pending.length * 2));
38
+ grown.set(pending.subarray(0, pendingBytes));
39
+ pending = grown;
40
+ }
41
+ pending.set(piece, pendingBytes);
42
+ pendingBytes = length;
43
+ };
44
+ const parse = (bytes) => {
45
+ if (bytes.length === 0 || bytes[bytes.length - 1] === CARRIAGE_RETURN)
46
+ return Result.fail(invalid());
47
+ try {
48
+ return Result.succeed(JSON.parse(decoder.decode(bytes)));
49
+ }
50
+ catch (cause) {
51
+ return Result.fail(invalid(cause));
52
+ }
53
+ };
54
+ const scan = (chunk, from) => {
55
+ for (let start = from, batch = 0; start < chunk.length; batch++) {
56
+ if (batch === WALK_YIELD_INTERVAL)
57
+ return Result.succeed(start);
58
+ const newline = chunk.indexOf(NEWLINE, start);
59
+ const stop = newline < 0 ? chunk.length : newline;
60
+ const length = pendingBytes + stop - start;
61
+ const bounded = meter.line(length, newline >= 0);
62
+ if (Result.isFailure(bounded))
63
+ return Result.fail(bounded.failure);
64
+ if (newline < 0) {
65
+ hold(chunk.subarray(start), length);
66
+ break;
67
+ }
68
+ let bytes = chunk.subarray(start, stop);
69
+ if (pendingBytes > 0) {
70
+ hold(bytes, length);
71
+ bytes = pending.subarray(0, length);
72
+ pending = EMPTY;
73
+ pendingBytes = 0;
74
+ }
75
+ start = newline + 1;
76
+ const read = Result.flatMap(parse(bytes), (value) => fold.line(value, lines++));
77
+ if (Result.isFailure(read))
78
+ return Result.fail(read.failure);
79
+ }
80
+ return Result.succeed(chunk.length);
81
+ };
82
+ const push = Effect.fnUntraced(function* (chunk) {
83
+ if (!isAttachedBytes(chunk)) {
84
+ return yield* imageFailure(framing.operation, "InvalidEncoding", { field: framing.inputField });
85
+ }
86
+ // Yield during large chunks so other fibers run and interruption is observed.
87
+ for (let start = yield* Effect.fromResult(scan(chunk, 0)); start < chunk.length;) {
88
+ yield* Effect.yieldNow;
89
+ start = yield* Effect.fromResult(scan(chunk, start));
90
+ }
91
+ });
92
+ const end = () => (pendingBytes > 0 ? Result.fail(invalid()) : fold.end());
93
+ return { push, end };
94
+ };
95
+ /** @internal */
96
+ export const sink = (framing, start) => Sink.foldArray(() => {
97
+ const { fold, meter } = start();
98
+ return makeReader(framing, meter, fold);
99
+ }, () => true, (reader, chunks) => Effect.as(Effect.forEach(chunks, (chunk) => reader.push(chunk), { discard: true }), reader)).pipe(Sink.mapEffect((reader) => Effect.fromResult(reader.end())));
100
+ const encoder = new TextEncoder();
101
+ /** @internal */
102
+ export const frame = (lines) => {
103
+ const out = new Uint8Array(lines.reduce((sum, line) => sum + line.length + 1, 0));
104
+ let offset = 0;
105
+ for (const line of lines) {
106
+ out.set(line, offset);
107
+ offset += line.length;
108
+ out[offset++] = NEWLINE;
109
+ }
110
+ return out;
111
+ };
112
+ /** @internal */
113
+ export const encodeLine = (text) => encoder.encode(text);
114
+ /** @internal */
115
+ export const collect = (stream) => Effect.map(Stream.runCollect(stream), (chunks) => concat(chunks, chunks.reduce((sum, chunk) => sum + chunk.length, 0)));