@effect-vfs/core 0.5.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 (130) hide show
  1. package/README.md +34 -31
  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 +11 -21
  15. package/dist/LiveVolume.d.ts.map +1 -1
  16. package/dist/LiveVolume.js +17 -14
  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 +448 -115
  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 +47 -5
  60. package/dist/internal/errors.d.ts.map +1 -1
  61. package/dist/internal/errors.js +80 -36
  62. package/dist/internal/fixture.d.ts +2 -2
  63. package/dist/internal/fixture.d.ts.map +1 -1
  64. package/dist/internal/fixture.js +57 -74
  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/overlayDiff.d.ts +2 -2
  90. package/dist/internal/overlayDiff.d.ts.map +1 -1
  91. package/dist/internal/overlayDiff.js +0 -3
  92. package/dist/internal/path.d.ts +17 -5
  93. package/dist/internal/path.d.ts.map +1 -1
  94. package/dist/internal/path.js +38 -24
  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 +11 -15
  101. package/dist/internal/stagedState.d.ts.map +1 -1
  102. package/dist/internal/stagedState.js +15 -59
  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 +39 -489
  113. package/dist/internal/virtualFileSystem.d.ts.map +1 -1
  114. package/dist/internal/virtualFileSystem.js +2112 -2465
  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 +15 -6
  119. package/dist/internal/watchHub.d.ts.map +1 -1
  120. package/dist/internal/watchHub.js +56 -42
  121. package/package.json +48 -4
  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/overlayContent.d.ts +0 -15
  126. package/dist/internal/overlayContent.d.ts.map +0 -1
  127. package/dist/internal/overlayContent.js +0 -29
  128. package/dist/internal/testHooks.d.ts +0 -20
  129. package/dist/internal/testHooks.d.ts.map +0 -1
  130. package/dist/internal/testHooks.js +0 -23
@@ -1,622 +1,730 @@
1
- // Exact snapshot delta construction, inspection, serialization, and application.
2
1
  import * as ByteSize from "effect/ByteSize";
3
- import * as Crypto from "effect/Crypto";
4
2
  import * as Effect from "effect/Effect";
5
3
  import * as Encoding from "effect/Encoding";
6
- import * as Fn from "effect/Function";
7
- import * as Match from "effect/Match";
8
- import * as Predicate from "effect/Predicate";
9
4
  import * as Result from "effect/Result";
10
5
  import * as Schema from "effect/Schema";
11
- import { ImageError } from "../Snapshot.js";
12
- import { SnapshotDeltaError, SnapshotDifference, SnapshotNodeKind } from "../SnapshotDelta.js";
6
+ import { SnapshotChange, SnapshotDifference, SnapshotNodeKind } from "../SnapshotDelta.js";
13
7
  import { make as makeBytePath } from "./bytePath.js";
14
- import { bytesOrder, sameBytes } from "./bytes.js";
8
+ import { bytesOrder, decodeUtf8, sameBytes } from "./bytes.js";
15
9
  import { CanonicalBase64 } from "./canonicalBase64.js";
10
+ import { imageFailure, isEncodingIssue, issueSite, VfsError } from "./errors.js";
16
11
  import * as Image from "./image.js";
17
- import { WireStoredMetadata } from "./metadata.js";
12
+ import * as InodeTable from "./inodeTable.js";
13
+ import * as Merkle from "./merkle.js";
14
+ import { StoredMetadata } from "./metadata.js";
15
+ import { isAttachedBytes, isNameBytes, joinPath, nameBytes, NUL_BYTE, ROOT_PATH, SLASH_BYTE } from "./path.js";
18
16
  import * as SnapshotDeltaModel from "./snapshotDeltaModel.js";
17
+ import { UNCHECKED } from "./tree.js";
18
+ import { byEntryName, getNode, Ino, MAX_INO, payloadOf, ROOT_INO, storedMetadata } from "./volumeState.js";
19
19
  const FORMAT = "effect-vfs-delta";
20
- const ALGORITHM = "effect-vfs-semantic-sha256-v1";
21
- const SLASH_BYTE = 47;
22
- const DOT_BYTE = 46;
23
- const MAX_NAME_BYTES = 255;
24
- const NUL_BYTE = 0;
25
- const SHA256_BYTES = 32;
26
- const ROOT_PATH = new Uint8Array([SLASH_BYTE]);
20
+ const OPERATION = "snapshotDelta";
21
+ const RULE_CHECK = "@effect-vfs/core/deltaRuleCheck";
27
22
  const Path = CanonicalBase64.Encoded;
28
- const InlinePayload = Schema.TaggedStruct("Inline", { bytes: CanonicalBase64.Encoded });
29
- const BasePayload = Schema.TaggedStruct("Base", { path: Path });
30
- const Payload = Schema.Union([InlinePayload, BasePayload]);
31
- const DeltaRecord = Schema.Union([
32
- Schema.Struct({ kind: Schema.Literal("directory"), paths: Schema.Array(Path), metadata: Image.StoredMetadata }),
33
- Schema.Struct({
34
- kind: Schema.Literal("file"),
35
- paths: Schema.Array(Path),
36
- metadata: Image.StoredMetadata,
37
- payload: Payload
38
- }),
39
- Schema.Struct({
40
- kind: Schema.Literal("symlink"),
41
- paths: Schema.Array(Path),
42
- metadata: Image.StoredMetadata,
43
- payload: Payload
44
- })
45
- ]);
46
- const AddedChange = Schema.TaggedStruct("Added", { path: Path, kind: SnapshotNodeKind });
47
- const RemovedChange = Schema.TaggedStruct("Removed", { path: Path, kind: SnapshotNodeKind });
48
- const UpdatedChange = Schema.TaggedStruct("Updated", {
49
- path: Path,
50
- beforeKind: SnapshotNodeKind,
51
- afterKind: SnapshotNodeKind,
52
- differences: Schema.Array(SnapshotDifference).check(Schema.isMinLength(1))
23
+ // Unchanged payloads come from the base; a new hard link points to the group's first changed path.
24
+ const InlineContent = Schema.TaggedStruct("Inline", { bytes: CanonicalBase64.Encoded });
25
+ const DeltaNode = Schema.TaggedUnion({
26
+ directory: { metadata: StoredMetadata },
27
+ file: { metadata: StoredMetadata, content: Schema.optionalKey(InlineContent) },
28
+ symlink: { metadata: StoredMetadata, target: Schema.optionalKey(CanonicalBase64.Encoded) },
29
+ link: { to: Path }
53
30
  });
54
- const Change = Schema.Union([AddedChange, RemovedChange, UpdatedChange]);
55
- const WirePayload = Schema.Union([
56
- Schema.TaggedStruct("Inline", { bytes: Schema.String }),
57
- Schema.TaggedStruct("Base", { path: Schema.String })
58
- ]);
59
- const WireDeltaRecord = Schema.Union([
60
- Schema.Struct({
61
- kind: Schema.Literal("directory"),
62
- paths: Schema.Array(Schema.String),
63
- metadata: WireStoredMetadata
64
- }),
65
- Schema.Struct({
66
- kind: Schema.Literal("file"),
67
- paths: Schema.Array(Schema.String),
68
- metadata: WireStoredMetadata,
69
- payload: WirePayload
70
- }),
71
- Schema.Struct({
72
- kind: Schema.Literal("symlink"),
73
- paths: Schema.Array(Schema.String),
74
- metadata: WireStoredMetadata,
75
- payload: WirePayload
76
- })
77
- ]);
78
- const WireChange = Schema.Union([
79
- Schema.TaggedStruct("Added", { path: Schema.String, kind: SnapshotNodeKind }),
80
- Schema.TaggedStruct("Removed", { path: Schema.String, kind: SnapshotNodeKind }),
81
- Schema.TaggedStruct("Updated", {
82
- path: Schema.String,
31
+ const Change = Schema.TaggedUnion({
32
+ Added: { path: Path, kind: SnapshotNodeKind, node: DeltaNode },
33
+ Removed: { path: Path, kind: SnapshotNodeKind },
34
+ Updated: {
35
+ path: Path,
83
36
  beforeKind: SnapshotNodeKind,
84
37
  afterKind: SnapshotNodeKind,
85
- differences: Schema.Array(SnapshotDifference).check(Schema.isMinLength(1))
86
- })
87
- ]);
88
- const sameChanges = Schema.toEquivalence(Schema.Array(Change));
89
- const VersionProbe = Schema.Struct({
90
- format: Schema.Literal(FORMAT),
91
- version: Schema.Unknown
38
+ differences: Schema.Array(SnapshotDifference).check(Schema.isMinLength(1)),
39
+ node: DeltaNode
40
+ }
92
41
  });
93
42
  const Document = Schema.Struct({
94
43
  format: Schema.Literal(FORMAT),
95
44
  version: Schema.Literal(1),
96
- base: Schema.Struct({ algorithm: Schema.Literal(ALGORITHM), digest: CanonicalBase64.Encoded }),
97
- records: Schema.Array(DeltaRecord),
45
+ base: CanonicalBase64.Encoded,
46
+ target: CanonicalBase64.Encoded,
98
47
  changes: Schema.Array(Change)
99
48
  });
100
- const WireDocument = Schema.Struct({
49
+ const VersionProbe = Schema.Struct({
101
50
  format: Schema.Literal(FORMAT),
102
- version: Schema.Literal(1),
103
- base: Schema.Struct({ algorithm: Schema.Literal(ALGORITHM), digest: Schema.String }),
104
- records: Schema.Array(WireDeltaRecord),
105
- changes: Schema.Array(WireChange)
51
+ version: Schema.Unknown
106
52
  });
107
- const timestampFields = new Set(["atimeNs", "mtimeNs", "ctimeNs", "birthtimeNs"]);
108
53
  const differenceOrder = SnapshotDifference.literals;
109
- const encoder = new TextEncoder();
110
- const Json = Schema.fromJsonString(Schema.Unknown);
111
- const JsonDocument = Schema.fromJsonString(Document);
54
+ const timestampFields = new Set(["atimeNs", "mtimeNs", "ctimeNs", "birthtimeNs"]);
112
55
  const key = Encoding.encodeHex;
113
- const join = (parent, name) => {
114
- const out = new Uint8Array(parent.length + (parent.length === 1 ? 0 : 1) + name.length);
115
- out.set(parent);
116
- let offset = parent.length;
117
- if (parent.length !== 1)
118
- out[offset++] = SLASH_BYTE;
119
- out.set(name, offset);
120
- return out;
121
- };
122
- const parentKey = (path) => {
123
- let slash = path.length - 1;
124
- while (slash > 0 && path[slash] !== SLASH_BYTE)
125
- slash--;
126
- return key(slash === 0 ? ROOT_PATH : path.subarray(0, slash));
127
- };
128
- const basename = (path) => {
129
- let slash = path.length - 1;
130
- while (slash > 0 && path[slash] !== SLASH_BYTE)
131
- slash--;
132
- return path.subarray(slash + 1);
133
- };
134
- const safeAdd = (a, b) => {
135
- const n = a + b;
136
- return Number.isSafeInteger(n) ? n : undefined;
137
- };
138
- const exceeds = (value, limit) => ByteSize.isGreaterThan(ByteSize.bytes(value), limit);
139
56
  const validPath = (path) => {
140
- if (path.length === 0 || path[0] !== SLASH_BYTE || path.includes(NUL_BYTE) ||
141
- (path.length > 1 && path.at(-1) === SLASH_BYTE))
57
+ if (path[0] !== SLASH_BYTE)
142
58
  return false;
143
- if (path.length === 1)
144
- return true;
145
59
  let start = 1;
146
- for (let i = 1; i <= path.length; i++) {
60
+ for (let i = 1; i <= path.length && path.length > 1; i++) {
147
61
  if (i === path.length || path[i] === SLASH_BYTE) {
148
- const length = i - start;
149
- if (length < 1 || length > MAX_NAME_BYTES || (length === 1 && path[start] === DOT_BYTE) ||
150
- (length === 2 && path[start] === DOT_BYTE && path[start + 1] === DOT_BYTE))
62
+ if (!isNameBytes(path.subarray(start, i)))
151
63
  return false;
152
64
  start = i + 1;
153
65
  }
154
66
  }
155
67
  return true;
156
68
  };
157
- const roleBudget = (limits, role) => role === "base"
158
- ? {
159
- records: limits.maxBaseRecords,
160
- recordsField: "baseRecords",
161
- pathBytes: limits.maxIdentityBytes,
162
- pathBytesField: "identityBytes",
163
- payloadBytes: limits.maxIdentityBytes,
164
- payloadBytesField: "identityBytes"
165
- }
166
- : {
167
- records: limits.maxTargetRecords,
168
- recordsField: "targetRecords",
169
- pathBytes: limits.maxDecodedDeltaBytes,
170
- pathBytesField: "decodedDeltaBytes",
171
- payloadBytes: limits.maxOutputBytes,
172
- payloadBytesField: "outputBytes"
173
- };
174
- const encodedPayload = (record) => Match.value(record).pipe(Match.tag("directory", () => undefined), Match.tag("file", (record) => record.data), Match.tag("symlink", (record) => record.target), Match.exhaustive);
175
- const normalize = Effect.fnUntraced(function* (snapshot, limits, role) {
176
- const doc = yield* Image.inspect(snapshot);
177
- const budget = roleBudget(limits, role);
178
- if (doc.records.length > budget.records) {
179
- return yield* new ImageError({ code: "LimitExceeded", field: budget.recordsField });
69
+ const splitNames = (path) => {
70
+ const names = [];
71
+ if (path.length === 1)
72
+ return names;
73
+ let start = 1;
74
+ for (let i = 1; i <= path.length; i++) {
75
+ if (i === path.length || path[i] === SLASH_BYTE) {
76
+ names.push(key(path.subarray(start, i)));
77
+ start = i + 1;
78
+ }
180
79
  }
181
- const records = new Map(doc.records.map((record) => [record.id, record]));
182
- const pathsById = new Map();
183
- const pending = [[doc.root, ROOT_PATH]];
184
- let entries = 0;
185
- let pathBytes = ByteSize.bytes(ROOT_PATH.length);
186
- // Index loop: `pending` grows while it is being walked.
187
- for (let i = 0; i < pending.length; i++) {
188
- const [recordId, path] = pending[i];
189
- const record = records.get(recordId);
190
- if (record === undefined)
191
- return yield* new ImageError({ code: "InvalidStructure", field: role });
192
- const paths = pathsById.get(recordId);
193
- if (paths === undefined)
194
- pathsById.set(recordId, [path]);
195
- else
196
- paths.push(path);
197
- if (Image.Record.guards.directory(record)) {
198
- for (const entry of record.entries) {
199
- if (++entries > limits.maxEntries)
200
- return yield* new ImageError({ code: "LimitExceeded", field: "entries" });
201
- const separator = path.length === ROOT_PATH.length ? 0 : 1;
202
- const pathLength = path.length + separator + CanonicalBase64.decodedLength(entry.name);
203
- pathBytes = ByteSize.sum(pathBytes, ByteSize.bytes(pathLength));
204
- if (ByteSize.isGreaterThan(pathBytes, budget.pathBytes)) {
205
- return yield* new ImageError({ code: "LimitExceeded", field: budget.pathBytesField });
206
- }
207
- pending.push([entry.target, join(path, yield* CanonicalBase64.decode(entry.name))]);
80
+ return names;
81
+ };
82
+ const kindOf = (change) => Change.guards.Updated(change) ? change.afterKind : change.kind;
83
+ const payloadDifference = (kind) => (kind === "file" ? "content" : "target");
84
+ const carriesPayload = (change) => Change.guards.Added(change) ||
85
+ (Change.guards.Updated(change) &&
86
+ (change.differences.includes("kind") || change.differences.includes(payloadDifference(change.afterKind))));
87
+ const carriedPayload = (node) => DeltaNode.guards.file(node) ? node.content?.bytes : DeltaNode.guards.symlink(node) ? node.target : undefined;
88
+ const changesIssue = (changes) => {
89
+ const at = (path, issue) => ({ path: ["changes", ...path], issue });
90
+ const heads = new Map();
91
+ let previous;
92
+ for (const [index, change] of changes.entries()) {
93
+ const path = CanonicalBase64.toBytes(change.path);
94
+ if (!validPath(path) || (previous !== undefined && bytesOrder(previous, path) >= 0)) {
95
+ return at([], "changes ascend by path bytes, each a valid path");
96
+ }
97
+ previous = path;
98
+ const root = path.length === 1;
99
+ if (Change.guards.Removed(change)) {
100
+ if (root)
101
+ return at([index], "the root is never removed");
102
+ continue;
103
+ }
104
+ if (Change.guards.Added(change) && root)
105
+ return at([index], "the root is never added");
106
+ if (Change.guards.Updated(change)) {
107
+ const positions = change.differences.map((field) => differenceOrder.indexOf(field));
108
+ if (positions.some((position, place) => place > 0 && position <= positions[place - 1])) {
109
+ return at([index, "differences"], "differences are listed once each in their canonical order");
110
+ }
111
+ if (change.differences.includes("kind") !== (change.beforeKind !== change.afterKind)) {
112
+ return at([index, "differences"], "an update lists kind exactly when the kind changes");
113
+ }
114
+ if (root && (change.beforeKind !== "directory" || change.afterKind !== "directory")) {
115
+ return at([index], "the root stays a directory");
208
116
  }
209
117
  }
210
- }
211
- const objects = [];
212
- const byPath = new Map();
213
- let payloadBytes = ByteSize.zero;
214
- for (const record of doc.records) {
215
- const paths = pathsById.get(record.id);
216
- if (paths === undefined)
217
- return yield* new ImageError({ code: "InvalidStructure", field: role });
218
- paths.sort(bytesOrder);
219
- const encoded = encodedPayload(record);
220
- if (encoded !== undefined) {
221
- payloadBytes = ByteSize.sum(payloadBytes, ByteSize.bytes(CanonicalBase64.decodedLength(encoded)));
222
- if (ByteSize.isGreaterThan(payloadBytes, budget.payloadBytes)) {
223
- return yield* new ImageError({ code: "LimitExceeded", field: budget.payloadBytesField });
118
+ const kind = kindOf(change);
119
+ const node = change.node;
120
+ if (DeltaNode.guards.link(node)) {
121
+ if (heads.get(node.to) !== kind) {
122
+ return at([index, "node", "to"], "a link names an earlier change's file or symbolic link of its kind");
224
123
  }
124
+ continue;
225
125
  }
226
- const object = {
227
- kind: record._tag,
228
- metadata: record.metadata,
229
- paths,
230
- pathIdentity: paths.map(key).join("/"),
231
- payload: encoded === undefined ? undefined : yield* CanonicalBase64.decode(encoded)
232
- };
233
- objects.push(object);
234
- for (const path of paths)
235
- byPath.set(key(path), { object, path });
126
+ if (node._tag !== kind)
127
+ return at([index, "node"], "a node matches its change's kind");
128
+ if (DeltaNode.guards.directory(node))
129
+ continue;
130
+ if ((carriedPayload(node) !== undefined) !== carriesPayload(change)) {
131
+ return at([index, "node"], "a node carries its payload exactly when the change alters it");
132
+ }
133
+ if (DeltaNode.guards.symlink(node) && node.target !== undefined &&
134
+ CanonicalBase64.toBytes(node.target).includes(NUL_BYTE)) {
135
+ return at([index, "node", "target"], "a symbolic link's target holds no NUL");
136
+ }
137
+ heads.set(change.path, kind);
236
138
  }
237
- objects.sort((a, b) => bytesOrder(a.paths[0], b.paths[0]));
238
- return { objects, byPath };
239
- });
240
- const U64_BYTES = 8;
241
- const EMPTY = new Uint8Array();
242
- const IDENTITY_PREFIX = encoder.encode(`${ALGORITHM}\0`);
243
- const KIND_BYTE = { directory: 0, file: 1, symlink: 2 };
244
- const u64 = (n) => {
245
- const out = new Uint8Array(U64_BYTES);
246
- new DataView(out.buffer).setBigUint64(0, BigInt(n), false);
247
- return out;
248
139
  };
249
- const framedLength = (bytes) => U64_BYTES + bytes.length;
250
- const timestampBytes = (metadata) => [metadata.atimeNs, metadata.mtimeNs, metadata.ctimeNs, metadata.birthtimeNs].map((value) => encoder.encode(String(value)));
251
- // This is the versioned semantic identity encoding, not a generic byte builder.
252
- // Field framing, ordering, and the domain prefix are part of the persisted delta contract.
253
- const identityBytes = (view, limits) => {
254
- const timestamps = view.objects.map((object) => timestampBytes(object.metadata));
255
- let total = ByteSize.bytes(IDENTITY_PREFIX.length + U64_BYTES);
256
- for (const [index, object] of view.objects.entries()) {
257
- const objectBytes = U64_BYTES +
258
- object.paths.reduce((sum, path) => sum + framedLength(path), 0) +
259
- 1 + 3 * U64_BYTES +
260
- timestamps[index].reduce((sum, stamp) => sum + framedLength(stamp), 0) +
261
- framedLength(object.payload ?? EMPTY);
262
- total = ByteSize.sum(total, ByteSize.bytes(objectBytes));
263
- }
264
- if (ByteSize.isGreaterThan(total, limits.maxIdentityBytes)) {
265
- return Result.fail(new ImageError({ code: "LimitExceeded", field: "identityBytes" }));
140
+ const ValidDocument = Document.check(Schema.makeFilter((document) => changesIssue(document.changes) ?? true, {
141
+ identifier: "ValidSnapshotDelta",
142
+ [RULE_CHECK]: true
143
+ }));
144
+ const JsonDocument = Schema.fromJsonString(Document);
145
+ const Json = Schema.fromJsonString(Schema.Unknown);
146
+ const decodeDocument = Schema.decodeUnknownEffect(ValidDocument, { onExcessProperty: "error" });
147
+ // Preserve the codec's stable error fields for digest, path, and payload failures.
148
+ const failureField = (path) => {
149
+ const [head, , field] = path;
150
+ if (head === "base" || head === "target")
151
+ return "digest";
152
+ if (head === "changes" && path.length === 3 && field === "path")
153
+ return "changePath";
154
+ if (head === "changes" && field === "node" && (path[3] === "target" || path.at(-1) === "bytes"))
155
+ return "payload";
156
+ return path.length === 0 ? "document" : path.map(String).join(".");
157
+ };
158
+ const decodeFailure = (error) => {
159
+ const site = issueSite(error.issue);
160
+ const rule = site.checks.some((check) => check.annotations?.[RULE_CHECK] === true);
161
+ return rule
162
+ ? imageFailure(OPERATION, "InvalidStructure", { field: site.path.map(String).join("."), cause: error })
163
+ : imageFailure(OPERATION, isEncodingIssue(site) ? "InvalidEncoding" : "InvalidStructure", {
164
+ field: failureField(site.path),
165
+ cause: error
166
+ });
167
+ };
168
+ const exceeds = (value, limit) => ByteSize.isGreaterThan(ByteSize.bytes(value), limit);
169
+ const validate = (document, limits) => {
170
+ const fail = (code, field) => Result.fail(imageFailure(OPERATION, code, { field }));
171
+ if (document.changes.length > limits.records)
172
+ return fail("LimitExceeded", "deltaRecords");
173
+ for (const digest of [document.base, document.target]) {
174
+ if (CanonicalBase64.decodedLength(digest) !== Merkle.DIGEST_BYTES)
175
+ return fail("InvalidEncoding", "digest");
266
176
  }
267
- const output = new Uint8Array(Number(ByteSize.toBigInt(total)));
268
- let offset = 0;
269
- const put = (bytes) => {
270
- output.set(bytes, offset);
271
- offset += bytes.length;
272
- };
273
- const frame = (bytes) => {
274
- put(u64(bytes.length));
275
- put(bytes);
276
- };
277
- put(IDENTITY_PREFIX);
278
- put(u64(view.objects.length));
279
- for (const [index, object] of view.objects.entries()) {
280
- put(u64(object.paths.length));
281
- for (const path of object.paths)
282
- frame(path);
283
- put(new Uint8Array([KIND_BYTE[object.kind]]));
284
- put(u64(object.metadata.uid));
285
- put(u64(object.metadata.gid));
286
- put(u64(object.metadata.mode));
287
- for (const stamp of timestamps[index])
288
- frame(stamp);
289
- frame(object.payload ?? EMPTY);
177
+ let decoded = 2 * Merkle.DIGEST_BYTES;
178
+ let entries = 0;
179
+ let payloadBytes = 0;
180
+ for (const change of document.changes) {
181
+ const pathBytes = CanonicalBase64.decodedLength(change.path);
182
+ decoded += pathBytes;
183
+ if (Change.guards.Removed(change))
184
+ continue;
185
+ if (pathBytes > 1)
186
+ entries++;
187
+ if (DeltaNode.guards.link(change.node))
188
+ decoded += CanonicalBase64.decodedLength(change.node.to);
189
+ else {
190
+ const payload = carriedPayload(change.node);
191
+ const length = payload === undefined ? 0 : CanonicalBase64.decodedLength(payload);
192
+ decoded += length;
193
+ payloadBytes += length;
194
+ }
290
195
  }
291
- return Result.succeed(output);
196
+ if (exceeds(decoded, limits.decodedBytes))
197
+ return fail("LimitExceeded", "decodedDeltaBytes");
198
+ if (entries > limits.entries)
199
+ return fail("LimitExceeded", "entries");
200
+ if (exceeds(payloadBytes, limits.outputBytes))
201
+ return fail("LimitExceeded", "outputBytes");
202
+ return Result.void;
292
203
  };
293
- const digest = Effect.fnUntraced(function* (view, limits) {
294
- const crypto = yield* Crypto.Crypto;
295
- return yield* crypto.digest("SHA-256", yield* Effect.fromResult(identityBytes(view, limits)));
204
+ const baseBudget = (limits) => ({
205
+ operation: OPERATION,
206
+ records: limits.baseRecords,
207
+ recordsField: "baseRecords",
208
+ entries: limits.entries,
209
+ identityBytes: limits.identityBytes
296
210
  });
297
- const samePaths = (a, b) => a.pathIdentity === b.pathIdentity;
298
- const differences = (before, after) => differenceOrder.filter((field) => {
211
+ const targetBudget = (limits) => ({
212
+ operation: OPERATION,
213
+ records: limits.targetRecords,
214
+ recordsField: "targetRecords",
215
+ entries: limits.entries,
216
+ identityBytes: limits.identityBytes,
217
+ payloadBytes: { limit: limits.outputBytes, field: "outputBytes" }
218
+ });
219
+ // Check target bounds first so applying and diffing report the same limit.
220
+ const outputIssue = (limits, output) => {
221
+ const fail = (field) => Result.fail(imageFailure(OPERATION, "LimitExceeded", { field }));
222
+ if (output.records > limits.targetRecords)
223
+ return fail("targetRecords");
224
+ if (output.entries > limits.entries)
225
+ return fail("entries");
226
+ if (output.records > limits.outputRecords)
227
+ return fail("outputRecords");
228
+ if (exceeds(output.payloadBytes, limits.outputBytes))
229
+ return fail("outputBytes");
230
+ if (output.records - output.written > limits.inheritedRecords)
231
+ return fail("inheritedRecords");
232
+ return Result.void;
233
+ };
234
+ const groupKey = (paths) => paths.map(key).join("/");
235
+ const side = (value, identity) => {
236
+ const groupOf = new Map();
237
+ const firstOf = new Map();
238
+ for (const [ino, paths] of identity.groups) {
239
+ const group = groupKey(paths);
240
+ firstOf.set(ino, paths[0]);
241
+ for (const path of paths)
242
+ groupOf.set(key(path), group);
243
+ }
244
+ return { value, identity, groupOf, firstOf };
245
+ };
246
+ const groupAt = (side, pathKey) => side.groupOf.get(pathKey) ?? pathKey;
247
+ const differencesOf = (before, after, hardLinks) => differenceOrder.filter((field) => {
299
248
  if (field === "kind")
300
249
  return before.kind !== after.kind;
301
- if (field === "content") {
302
- return before.kind === after.kind && after.kind === "file" && !sameBytes(before.payload, after.payload);
303
- }
304
- if (field === "target") {
305
- return before.kind === after.kind && after.kind === "symlink" && !sameBytes(before.payload, after.payload);
250
+ if (field === "content" || field === "target") {
251
+ return before.kind === after.kind && after.kind !== "directory" && payloadDifference(after.kind) === field &&
252
+ !sameBytes(payloadOf(before), payloadOf(after));
306
253
  }
307
254
  if (field === "hardLinks")
308
- return !samePaths(before, after);
255
+ return hardLinks;
309
256
  return before.metadata[field] !== after.metadata[field];
310
257
  });
258
+ const deltaNode = (target, path, node, withPayload) => {
259
+ const first = target.firstOf.get(node.ino);
260
+ if (node.kind !== "directory" && first !== undefined && !sameBytes(first, path)) {
261
+ return DeltaNode.cases.link.make({ to: CanonicalBase64.encode(first) }, UNCHECKED);
262
+ }
263
+ const metadata = storedMetadata(node.metadata);
264
+ if (node.kind === "directory")
265
+ return DeltaNode.cases.directory.make({ metadata }, UNCHECKED);
266
+ if (!withPayload) {
267
+ return node.kind === "file"
268
+ ? DeltaNode.cases.file.make({ metadata }, UNCHECKED)
269
+ : DeltaNode.cases.symlink.make({ metadata }, UNCHECKED);
270
+ }
271
+ const bytes = CanonicalBase64.encode(payloadOf(node));
272
+ return node.kind === "file"
273
+ ? DeltaNode.cases.file.make({ metadata, content: InlineContent.make({ bytes }, UNCHECKED) }, UNCHECKED)
274
+ : DeltaNode.cases.symlink.make({ metadata, target: bytes }, UNCHECKED);
275
+ };
276
+ const lookup = (get, entriesOf, path) => {
277
+ let parent = ROOT_INO;
278
+ let node = get(ROOT_INO);
279
+ let name = "";
280
+ for (const component of splitNames(path)) {
281
+ if (node?.kind !== "directory")
282
+ return undefined;
283
+ const ino = entriesOf(node).get(component);
284
+ if (ino === undefined)
285
+ return undefined;
286
+ parent = node.ino;
287
+ name = component;
288
+ node = get(ino);
289
+ }
290
+ return node === undefined ? undefined : { parent, name, node };
291
+ };
292
+ const valueEntries = (directory) => directory.entries;
293
+ // Equal digests skip a subtree; hard-link groups still need a separate path comparison.
311
294
  const compare = (base, target) => {
312
295
  const changes = [];
313
- const add = (change, path) => changes.push({ change, path });
314
- // Memoized per object pair so hard-link groups compare each pair once.
315
- const cachedDifferences = Fn.memoize((before) => Fn.memoize((after) => differences(before, after)));
316
- for (const [pathKey, before] of base.byPath) {
317
- const after = target.byPath.get(pathKey);
318
- if (after === undefined) {
319
- add(RemovedChange.make({ path: CanonicalBase64.encode(before.path), kind: before.object.kind }), before.path);
296
+ const listed = new Set();
297
+ const pending = [];
298
+ const encode = CanonicalBase64.encode;
299
+ const children = (path, before, after) => {
300
+ const names = new Set([
301
+ ...(before?.kind === "directory" ? before.entries.keys() : []),
302
+ ...(after?.kind === "directory" ? after.entries.keys() : [])
303
+ ]);
304
+ for (const name of names) {
305
+ const b = before?.kind === "directory" ? before.entries.get(name) : undefined;
306
+ const a = after?.kind === "directory" ? after.entries.get(name) : undefined;
307
+ pending.push([
308
+ joinPath(path, nameBytes(name)),
309
+ b === undefined ? undefined : getNode(base.value, b),
310
+ a === undefined ? undefined : getNode(target.value, a)
311
+ ]);
320
312
  }
321
- else {
322
- const diff = cachedDifferences(before.object)(after.object);
323
- if (diff.length > 0) {
324
- add(UpdatedChange.make({
325
- path: CanonicalBase64.encode(before.path),
326
- beforeKind: before.object.kind,
327
- afterKind: after.object.kind,
328
- differences: diff
329
- }), before.path);
330
- }
313
+ };
314
+ const updated = (path, before, after, differences) => changes.push({
315
+ path,
316
+ change: Change.cases.Updated.make({
317
+ path: encode(path),
318
+ beforeKind: before.kind,
319
+ afterKind: after.kind,
320
+ differences,
321
+ node: deltaNode(target, path, after, differences.includes("kind") || differences.includes(payloadDifference(after.kind)))
322
+ }, UNCHECKED)
323
+ });
324
+ pending.push([ROOT_PATH, getNode(base.value, ROOT_INO), getNode(target.value, ROOT_INO)]);
325
+ while (pending.length > 0) {
326
+ const [path, before, after] = pending.pop();
327
+ const pathKey = key(path);
328
+ if (base.groupOf.has(pathKey) || target.groupOf.has(pathKey))
329
+ listed.add(pathKey);
330
+ if (before === undefined && after !== undefined) {
331
+ changes.push({
332
+ path,
333
+ change: Change.cases.Added.make({ path: encode(path), kind: after.kind, node: deltaNode(target, path, after, true) }, UNCHECKED)
334
+ });
335
+ children(path, undefined, after);
336
+ }
337
+ else if (before !== undefined && after === undefined) {
338
+ changes.push({ path, change: Change.cases.Removed.make({ path: encode(path), kind: before.kind }, UNCHECKED) });
339
+ children(path, before, undefined);
340
+ }
341
+ else if (before !== undefined && after !== undefined) {
342
+ const same = sameBytes(base.identity.digests.get(before.ino), target.identity.digests.get(after.ino));
343
+ const hardLinks = groupAt(base, pathKey) !== groupAt(target, pathKey);
344
+ if (same && !hardLinks)
345
+ continue;
346
+ const differences = differencesOf(before, after, hardLinks);
347
+ if (differences.length > 0)
348
+ updated(path, before, after, differences);
349
+ if (!same)
350
+ children(path, before, after);
331
351
  }
332
352
  }
333
- for (const [pathKey, after] of target.byPath) {
334
- if (!base.byPath.has(pathKey)) {
335
- add(AddedChange.make({ path: CanonicalBase64.encode(after.path), kind: after.object.kind }), after.path);
353
+ for (const s of [base, target]) {
354
+ for (const paths of s.identity.groups.values()) {
355
+ for (const path of paths) {
356
+ const pathKey = key(path);
357
+ if (listed.has(pathKey))
358
+ continue;
359
+ listed.add(pathKey);
360
+ if (groupAt(base, pathKey) === groupAt(target, pathKey))
361
+ continue;
362
+ const before = lookup((ino) => getNode(base.value, ino), valueEntries, path).node;
363
+ const after = lookup((ino) => getNode(target.value, ino), valueEntries, path).node;
364
+ updated(path, before, after, ["hardLinks"]);
365
+ }
336
366
  }
337
367
  }
338
- changes.sort((a, b) => bytesOrder(a.path, b.path));
339
- return changes.map(({ change }) => change);
368
+ return changes.sort((a, b) => bytesOrder(a.path, b.path)).map(({ change }) => change);
340
369
  };
341
370
  const getDocument = (delta) => Effect.suspend(() => {
342
371
  const value = SnapshotDeltaModel.value(delta);
343
372
  return value !== undefined && Schema.is(Document)(value)
344
373
  ? Effect.succeed(value)
345
- : Effect.fail(new ImageError({ code: "InvalidStructure", field: "delta" }));
374
+ : Effect.fail(imageFailure(OPERATION, "InvalidStructure", { field: "delta" }));
346
375
  });
347
- const byEntryName = (a, b) => bytesOrder(a.name, b.name);
348
- // Every recorded path must be the root directory or the child of a recorded directory.
349
- // Together with `validPath` and the per-record path rules in `validate`, this subsumes the
350
- // structural checks `Image.capture` would otherwise perform on the applied image.
351
- const linkTree = (paths) => {
352
- const rootKey = key(ROOT_PATH);
353
- const root = paths.get(rootKey);
354
- if (root?.kind !== "directory")
355
- return Result.fail(new ImageError({ code: "InvalidStructure", field: "root" }));
356
- const entriesById = new Map();
357
- for (const entry of paths.values()) {
358
- if (entry.kind === "directory")
359
- entriesById.set(entry.id, []);
360
- }
361
- for (const [pathKey, child] of paths) {
362
- if (pathKey === rootKey)
376
+ // Detach deepest paths first, then attach from the root. Check each claimed change against the base: a matching
377
+ // target digest alone cannot detect unreachable children or forged hard-link groups.
378
+ const fold = (base, identity, document) => {
379
+ const fail = (index, field) => Result.fail(imageFailure(OPERATION, "InvalidStructure", {
380
+ field: field === undefined ? `changes.${index}` : `changes.${index}.${field}`
381
+ }));
382
+ const owner = Symbol();
383
+ const revision = base.revision + 1n;
384
+ let inodes = base.inodes;
385
+ let nextInode = base.nextInode;
386
+ let entries = base.entries;
387
+ let usedBytes = base.usedBytes;
388
+ let records = identity.records;
389
+ let payloadBytes = identity.payloadBytes;
390
+ let written = 0;
391
+ const drafts = new Map();
392
+ const dirty = new Set();
393
+ const removed = new Set();
394
+ const created = new Map();
395
+ const get = (ino) => InodeTable.get(inodes, ino);
396
+ const put = (ino, node) => {
397
+ inodes = InodeTable.set(inodes, ino, node, owner);
398
+ };
399
+ const entriesOf = (directory) => drafts.get(directory.ino) ?? directory.entries;
400
+ const editable = (directory) => {
401
+ let draft = drafts.get(directory);
402
+ if (draft === undefined) {
403
+ const node = get(directory);
404
+ draft = new Map(node?.kind === "directory" ? node.entries : []);
405
+ drafts.set(directory, draft);
406
+ }
407
+ dirty.add(directory);
408
+ return draft;
409
+ };
410
+ const placed = document.changes.map((change, index) => {
411
+ const path = CanonicalBase64.toBytes(change.path);
412
+ return { index, change, path, pathKey: key(path) };
413
+ });
414
+ const changed = new Map(placed.map(({ change, pathKey }) => [pathKey, change]));
415
+ const before = new Map();
416
+ for (const { change, index, path, pathKey } of placed) {
417
+ if (Change.guards.Added(change))
363
418
  continue;
364
- const parent = paths.get(parentKey(child.bytes));
365
- const entries = parent === undefined ? undefined : entriesById.get(parent.id);
366
- if (entries === undefined)
367
- return Result.fail(new ImageError({ code: "InvalidStructure", field: "parent" }));
368
- entries.push({ name: basename(child.bytes), target: child.id });
369
- }
370
- for (const entries of entriesById.values())
371
- entries.sort(byEntryName);
372
- return Result.succeed({ root: root.id, entriesById });
373
- };
374
- const inheritedPayload = Effect.fnUntraced(function* (base, kind, encodedPath) {
375
- const source = base.byPath.get(key(yield* CanonicalBase64.decode(encodedPath)))?.object;
376
- return source?.kind === kind ? source.payload : undefined;
377
- });
378
- // Expects a document that already passed `validate`.
379
- const buildImage = Effect.fnUntraced(function* (document, base, limits) {
380
- const paths = new Map();
381
- for (const [index, record] of document.records.entries()) {
382
- for (const encodedPath of record.paths) {
383
- const bytes = yield* CanonicalBase64.decode(encodedPath);
384
- paths.set(key(bytes), { id: String(index), kind: record.kind, bytes });
419
+ const found = lookup((ino) => getNode(base, ino), valueEntries, path);
420
+ if (found?.node.kind !== (Change.guards.Updated(change) ? change.beforeKind : change.kind))
421
+ return fail(index);
422
+ before.set(pathKey, found);
423
+ // Reject partial removal of hard links or directory contents.
424
+ for (const member of identity.groups.get(found.node.ino) ?? []) {
425
+ const other = changed.get(key(member));
426
+ if (other === undefined || Change.guards.Added(other))
427
+ return fail(index);
428
+ }
429
+ if (found.node.kind === "directory" && (Change.guards.Removed(change) || kindOf(change) !== "directory")) {
430
+ for (const name of found.node.entries.keys()) {
431
+ const child = changed.get(key(joinPath(path, nameBytes(name))));
432
+ if (child === undefined || !Change.guards.Removed(child))
433
+ return fail(index);
434
+ }
385
435
  }
386
436
  }
387
- const tree = yield* Effect.fromResult(linkTree(paths));
388
- const records = [];
389
- let outputBytes = ByteSize.zero;
390
- for (const [index, record] of document.records.entries()) {
391
- const id = String(index);
392
- if (record.kind === "directory") {
393
- records.push(Image.Record.cases.directory.make({
394
- id,
395
- metadata: record.metadata,
396
- entries: (tree.entriesById.get(id) ?? []).map((entry) => ({
397
- name: CanonicalBase64.encode(entry.name),
398
- target: entry.target
399
- }))
400
- }));
437
+ const inPlace = (change) => Change.guards.Updated(change) && change.beforeKind === "directory" && change.afterKind === "directory";
438
+ for (let index = placed.length - 1; index >= 0; index--) {
439
+ const { change, pathKey } = placed[index];
440
+ if (Change.guards.Added(change) || inPlace(change))
441
+ continue;
442
+ const { name, node, parent } = before.get(pathKey);
443
+ editable(parent).delete(name);
444
+ entries--;
445
+ const current = get(node.ino);
446
+ if (current === undefined)
447
+ continue;
448
+ if (current.kind === "directory") {
449
+ put(current.ino, undefined);
450
+ removed.add(current.ino);
451
+ records--;
401
452
  continue;
402
453
  }
403
- const payload = Predicate.isTagged("Inline")(record.payload)
404
- ? yield* CanonicalBase64.decode(record.payload.bytes)
405
- : yield* inheritedPayload(base, record.kind, record.payload.path);
406
- if (payload === undefined)
407
- return yield* new ImageError({ code: "InvalidStructure", field: "baseReference" });
408
- outputBytes = ByteSize.sum(outputBytes, ByteSize.bytes(payload.length));
409
- if (ByteSize.isGreaterThan(outputBytes, limits.maxOutputBytes)) {
410
- return yield* new ImageError({ code: "LimitExceeded", field: "outputBytes" });
454
+ const links = current.links.filter((link) => !(link.parent === parent && link.name === name));
455
+ if (links.length > 0) {
456
+ put(current.ino, { ...current, links, metadata: { ...current.metadata, nlink: links.length } });
457
+ continue;
411
458
  }
412
- records.push(record.kind === "file"
413
- ? Image.Record.cases.file.make({ id, metadata: record.metadata, data: CanonicalBase64.encode(payload) })
414
- : Image.Record.cases.symlink.make({
415
- id,
416
- metadata: record.metadata,
417
- target: CanonicalBase64.encode(payload)
418
- }));
419
- }
420
- return yield* Image.capture({ format: "effect-vfs", version: 1, root: tree.root, records }, undefined, true);
421
- });
422
- const validate = Effect.fnUntraced(function* (document, limits) {
423
- const deltaRecords = safeAdd(document.records.length, document.changes.length);
424
- if (deltaRecords === undefined || deltaRecords > limits.maxDeltaRecords) {
425
- return yield* new ImageError({ code: "LimitExceeded", field: "deltaRecords" });
426
- }
427
- if (document.records.length > limits.maxOutputRecords) {
428
- return yield* new ImageError({ code: "LimitExceeded", field: "outputRecords" });
429
- }
430
- if (!CanonicalBase64.is(document.base.digest) ||
431
- CanonicalBase64.decodedLength(document.base.digest) !== SHA256_BYTES) {
432
- return yield* new ImageError({ code: "InvalidEncoding", field: "digest" });
459
+ const size = payloadOf(current).length;
460
+ put(current.ino, undefined);
461
+ removed.add(current.ino);
462
+ records--;
463
+ payloadBytes -= size;
464
+ usedBytes -= BigInt(size);
433
465
  }
434
- let decoded = ByteSize.bytes(SHA256_BYTES);
435
- let outputBytes = ByteSize.zero;
436
- let entries = -1;
437
- let inherited = 0;
438
- const paths = new Map();
439
- const charge = (encoded) => {
440
- decoded = ByteSize.sum(decoded, ByteSize.bytes(CanonicalBase64.decodedLength(encoded)));
441
- return ByteSize.isGreaterThan(decoded, limits.maxDecodedDeltaBytes)
442
- ? Effect.fail(new ImageError({ code: "LimitExceeded", field: "decodedDeltaBytes" }))
443
- : Effect.void;
444
- };
445
- for (const [index, record] of document.records.entries()) {
446
- if (record.paths.length < 1 || (record.kind === "directory" && record.paths.length !== 1)) {
447
- return yield* new ImageError({ code: "InvalidStructure", field: "paths" });
466
+ const after = new Map();
467
+ for (const { change, index, path, pathKey } of placed) {
468
+ if (Change.guards.Removed(change))
469
+ continue;
470
+ const node = change.node;
471
+ if (!DeltaNode.guards.link(node))
472
+ written++;
473
+ if (inPlace(change)) {
474
+ const current = get(before.get(pathKey).node.ino);
475
+ if (current?.kind !== "directory" || !DeltaNode.guards.directory(node))
476
+ return fail(index);
477
+ put(current.ino, { ...current, metadata: { ...current.metadata, ...node.metadata }, revision });
478
+ dirty.add(current.ino);
479
+ after.set(pathKey, current.ino);
480
+ continue;
448
481
  }
449
- for (const encodedPath of record.paths) {
450
- if (!CanonicalBase64.is(encodedPath)) {
451
- return yield* new ImageError({ code: "InvalidEncoding", field: "path" });
452
- }
453
- yield* charge(encodedPath);
454
- entries++;
455
- if (entries > limits.maxEntries)
456
- return yield* new ImageError({ code: "LimitExceeded", field: "entries" });
457
- const bytes = yield* CanonicalBase64.decode(encodedPath);
458
- const pathKey = key(bytes);
459
- if (!validPath(bytes) || paths.has(pathKey)) {
460
- return yield* new ImageError({ code: "InvalidStructure", field: "path" });
461
- }
462
- paths.set(pathKey, { id: String(index), kind: record.kind, bytes });
482
+ const name = splitNames(path).at(-1);
483
+ const parentPath = path.subarray(0, Math.max(1, path.length - name.length / 2 - 1));
484
+ const parent = lookup(get, entriesOf, parentPath)?.node;
485
+ if (parent?.kind !== "directory") {
486
+ return Result.fail(imageFailure(OPERATION, "InvalidStructure", { field: "parent" }));
463
487
  }
464
- if (record.kind === "directory")
465
- continue;
466
- if (Predicate.isTagged("Inline")(record.payload)) {
467
- if (!CanonicalBase64.is(record.payload.bytes)) {
468
- return yield* new ImageError({ code: "InvalidEncoding", field: "payload" });
469
- }
470
- yield* charge(record.payload.bytes);
471
- outputBytes = ByteSize.sum(outputBytes, ByteSize.bytes(CanonicalBase64.decodedLength(record.payload.bytes)));
488
+ const draft = editable(parent.ino);
489
+ if (draft.has(name))
490
+ return fail(index);
491
+ const link = { parent: parent.ino, name };
492
+ let ino;
493
+ if (DeltaNode.guards.link(node)) {
494
+ const head = after.get(key(CanonicalBase64.toBytes(node.to)));
495
+ const target = head === undefined ? undefined : get(head);
496
+ if (target === undefined || target.kind === "directory")
497
+ return fail(index);
498
+ ino = target.ino;
499
+ const links = [...target.links, link];
500
+ put(ino, { ...target, links, metadata: { ...target.metadata, nlink: links.length } });
501
+ created.get(ino)?.push(path);
472
502
  }
473
503
  else {
474
- if (!CanonicalBase64.is(record.payload.path)) {
475
- return yield* new ImageError({ code: "InvalidEncoding", field: "basePath" });
504
+ if (nextInode > MAX_INO)
505
+ return Result.fail(imageFailure(OPERATION, "LimitExceeded", { field: "inodes" }));
506
+ ino = nextInode;
507
+ nextInode = Ino(nextInode + 1);
508
+ const numbered = { ...node.metadata, ino: BigInt(ino) };
509
+ if (DeltaNode.guards.directory(node)) {
510
+ const metadata = { ...numbered, kind: "directory", nlink: 2, size: 0n };
511
+ put(ino, { kind: "directory", ino, parent: parent.ino, name, entries: new Map(), metadata, revision });
476
512
  }
477
- yield* charge(record.payload.path);
478
- inherited++;
479
- if (inherited > limits.maxInheritedRecords) {
480
- return yield* new ImageError({ code: "LimitExceeded", field: "inheritedRecords" });
481
- }
482
- if (!validPath(yield* CanonicalBase64.decode(record.payload.path)) || !record.paths.includes(record.payload.path)) {
483
- return yield* new ImageError({ code: "InvalidStructure", field: "basePath" });
513
+ else {
514
+ const carried = carriedPayload(node);
515
+ const kept = before.get(pathKey)?.node;
516
+ const bytes = carried !== undefined
517
+ ? CanonicalBase64.toBytes(carried)
518
+ : kept === undefined
519
+ ? undefined
520
+ : payloadOf(kept);
521
+ if (bytes === undefined)
522
+ return fail(index);
523
+ const counted = { ...numbered, kind: node._tag, nlink: 1, size: BigInt(bytes.length) };
524
+ put(ino, DeltaNode.guards.file(node)
525
+ ? { kind: "file", ino, data: bytes, links: [link], metadata: counted, revision }
526
+ : { kind: "symlink", ino, target: bytes, links: [link], metadata: counted, revision });
527
+ payloadBytes += bytes.length;
528
+ usedBytes += BigInt(bytes.length);
484
529
  }
530
+ records++;
531
+ created.set(ino, [path]);
532
+ dirty.add(ino);
485
533
  }
534
+ draft.set(name, ino);
535
+ entries++;
536
+ after.set(pathKey, ino);
486
537
  }
487
- let previous;
488
- for (const change of document.changes) {
489
- if (!CanonicalBase64.is(change.path)) {
490
- return yield* new ImageError({ code: "InvalidEncoding", field: "changePath" });
491
- }
492
- yield* charge(change.path);
493
- const path = yield* CanonicalBase64.decode(change.path);
494
- if (!validPath(path) || (previous !== undefined && bytesOrder(previous, path) >= 0)) {
495
- return yield* new ImageError({ code: "InvalidStructure", field: "changes" });
538
+ const baseGroup = (ino, pathKey) => {
539
+ const paths = identity.groups.get(ino);
540
+ return paths === undefined ? pathKey : groupKey(paths);
541
+ };
542
+ const targetGroup = (ino, pathKey) => {
543
+ const paths = created.get(ino);
544
+ return paths === undefined || paths.length < 2 ? pathKey : groupKey([...paths].sort(bytesOrder));
545
+ };
546
+ for (const { change, index, pathKey } of placed) {
547
+ if (!Change.guards.Updated(change))
548
+ continue;
549
+ const was = before.get(pathKey).node;
550
+ const now = get(after.get(pathKey));
551
+ const found = differencesOf(was, now, baseGroup(was.ino, pathKey) !== targetGroup(now.ino, pathKey));
552
+ if (found.length !== change.differences.length || found.some((field, at) => field !== change.differences[at])) {
553
+ return fail(index, "differences");
496
554
  }
497
- previous = path;
498
555
  }
499
- if (ByteSize.isGreaterThan(outputBytes, limits.maxOutputBytes)) {
500
- return yield* new ImageError({ code: "LimitExceeded", field: "outputBytes" });
501
- }
502
- yield* Effect.fromResult(linkTree(paths));
503
- // Inline symlink targets are the only applied payloads a base snapshot has not already vetted.
504
- for (const record of document.records) {
505
- if (record.kind !== "symlink" || !Predicate.isTagged("Inline")(record.payload))
556
+ for (const [ino, draft] of drafts) {
557
+ const node = get(ino);
558
+ if (node?.kind !== "directory")
506
559
  continue;
507
- if (!CanonicalBase64.is(record.payload.bytes)) {
508
- return yield* new ImageError({ code: "InvalidEncoding", field: "payload" });
560
+ const sorted = [...draft].sort(byEntryName);
561
+ const subdirectories = sorted.filter(([, child]) => get(child)?.kind === "directory").length;
562
+ const metadata = { ...node.metadata, nlink: 2 + subdirectories };
563
+ put(ino, { ...node, entries: new Map(sorted), metadata, revision });
564
+ }
565
+ for (const ino of dirty) {
566
+ let node = get(ino);
567
+ if (node === undefined)
568
+ dirty.delete(ino);
569
+ while (node !== undefined && node.ino !== ROOT_INO) {
570
+ const parent = node.kind === "directory" ? node.parent : node.links[0].parent;
571
+ dirty.add(node.ino);
572
+ if (dirty.has(parent))
573
+ break;
574
+ dirty.add(parent);
575
+ node = get(parent);
509
576
  }
510
- if ((yield* CanonicalBase64.decode(record.payload.bytes)).includes(NUL_BYTE)) {
511
- return yield* new ImageError({ code: "InvalidStructure", field: "symlink" });
577
+ }
578
+ const groups = new Map();
579
+ for (const [ino, paths] of identity.groups)
580
+ if (get(ino) !== undefined)
581
+ groups.set(ino, paths);
582
+ for (const [ino, paths] of created)
583
+ if (paths.length > 1)
584
+ groups.set(ino, [...paths].sort(bytesOrder));
585
+ const value = {
586
+ inodes,
587
+ open: new Map(),
588
+ nextInode,
589
+ revision: placed.length === 0 ? base.revision : revision,
590
+ entries,
591
+ usedBytes
592
+ };
593
+ return Result.succeed({ value, dirty, removed, groups, records, entries, payloadBytes, written });
594
+ };
595
+ // Reuse base digests for untouched nodes, but charge the meter for the whole target.
596
+ const foldedIdentity = Effect.fnUntraced(function* (base, folded, identity, limits) {
597
+ const digests = new Map();
598
+ const measure = Merkle.meter(targetBudget(limits));
599
+ let inherited = identity.nodeBytes;
600
+ for (const ino of new Set([...folded.dirty, ...folded.removed])) {
601
+ const was = identity.digests.has(ino) ? getNode(base, ino) : undefined;
602
+ if (was !== undefined)
603
+ inherited -= Merkle.nodeBytes(was);
604
+ }
605
+ yield* measure.charge(inherited);
606
+ const digestOf = (ino) => digests.get(ino) ?? identity.digests.get(ino);
607
+ const nodes = [...folded.dirty].map((ino) => getNode(folded.value, ino));
608
+ const depths = new Map([[ROOT_INO, 0]]);
609
+ const depth = (node) => {
610
+ const chain = [];
611
+ let current = node;
612
+ while (current?.kind === "directory" && !depths.has(current.ino)) {
613
+ chain.push(current.ino);
614
+ current = getNode(folded.value, current.parent);
512
615
  }
616
+ let found = current === undefined ? -1 : depths.get(current.ino) ?? -1;
617
+ for (const ino of chain.reverse())
618
+ depths.set(ino, ++found);
619
+ return depths.get(node.ino);
620
+ };
621
+ const directories = nodes.filter((node) => node.kind === "directory");
622
+ // Digest children before their directories.
623
+ const ordered = [
624
+ ...nodes.filter((node) => node.kind !== "directory"),
625
+ ...directories.sort((a, b) => depth(b) - depth(a))
626
+ ];
627
+ for (const node of ordered)
628
+ digests.set(node.ino, yield* Merkle.nodeDigest(node, digestOf, measure));
629
+ return yield* Merkle.identityDigest(digestOf(ROOT_INO), folded.groups.values(), measure);
630
+ });
631
+ const verify = Effect.fnUntraced(function* (base, document, limits) {
632
+ yield* Effect.fromResult(validate(document, limits));
633
+ const value = yield* Image.valueOf(base);
634
+ const identity = yield* Merkle.identify(value, baseBudget(limits));
635
+ if (!sameBytes(identity.digest, CanonicalBase64.toBytes(document.base))) {
636
+ return yield* new VfsError({ code: "BaseMismatch", operation: OPERATION });
513
637
  }
638
+ const folded = yield* Effect.fromResult(fold(value, identity, document));
639
+ yield* Effect.fromResult(outputIssue(limits, folded));
640
+ if (!sameBytes(yield* foldedIdentity(value, folded, identity, limits), CanonicalBase64.toBytes(document.target))) {
641
+ return yield* imageFailure(OPERATION, "InvalidStructure", { field: "changes" });
642
+ }
643
+ return folded.value;
514
644
  });
515
645
  /** @internal */
516
646
  export const diffSnapshots = Effect.fnUntraced(function* (base, target, limits) {
517
- const before = yield* normalize(base, limits, "base");
518
- const after = yield* normalize(target, limits, "target");
519
- const changes = [...compare(before, after)];
520
- const deltaRecords = safeAdd(after.objects.length, changes.length);
521
- if (deltaRecords === undefined || deltaRecords > limits.maxDeltaRecords) {
522
- return yield* new ImageError({ code: "LimitExceeded", field: "deltaRecords" });
647
+ const baseValue = yield* Image.valueOf(base);
648
+ const targetValue = yield* Image.valueOf(target);
649
+ const before = side(baseValue, yield* Merkle.identify(baseValue, baseBudget(limits)));
650
+ const after = side(targetValue, yield* Merkle.identify(targetValue, targetBudget(limits)));
651
+ const changes = compare(before, after);
652
+ if (changes.length > limits.records) {
653
+ return yield* imageFailure(OPERATION, "LimitExceeded", { field: "deltaRecords" });
523
654
  }
524
- const samePayload = Fn.memoize((source) => Fn.memoize((candidate) => sameBytes(source.payload, candidate.payload)));
525
- const records = after.objects.map((object) => {
526
- const paths = object.paths.map(CanonicalBase64.encode);
527
- if (object.kind === "directory")
528
- return { kind: object.kind, paths, metadata: object.metadata };
529
- const inherited = object.paths.find((path) => {
530
- const source = before.byPath.get(key(path));
531
- return source?.object.kind === object.kind && samePayload(source.object)(object);
532
- });
533
- return {
534
- kind: object.kind,
535
- paths,
536
- metadata: object.metadata,
537
- payload: inherited === undefined
538
- ? InlinePayload.make({ bytes: CanonicalBase64.encode(object.payload ?? new Uint8Array()) })
539
- : BasePayload.make({ path: CanonicalBase64.encode(inherited) })
540
- };
541
- });
655
+ const written = changes.filter((change) => !Change.guards.Removed(change) && !DeltaNode.guards.link(change.node));
656
+ yield* Effect.fromResult(outputIssue(limits, { ...after.identity, entries: targetValue.entries, written: written.length }));
542
657
  const document = {
543
658
  format: FORMAT,
544
659
  version: 1,
545
- base: { algorithm: ALGORITHM, digest: CanonicalBase64.encode(yield* digest(before, limits)) },
546
- records,
660
+ base: CanonicalBase64.encode(before.identity.digest),
661
+ target: CanonicalBase64.encode(after.identity.digest),
547
662
  changes
548
663
  };
549
- yield* validate(document, limits);
664
+ yield* Effect.fromResult(validate(document, limits));
550
665
  return SnapshotDeltaModel.make(document);
551
666
  });
552
- /** @internal */
553
- const verify = Effect.fnUntraced(function* (base, document, limits) {
554
- yield* validate(document, limits);
555
- const before = yield* normalize(base, limits, "base");
556
- if (!sameBytes(yield* digest(before, limits), yield* CanonicalBase64.decode(document.base.digest))) {
557
- return yield* new SnapshotDeltaError({ code: "BaseMismatch" });
558
- }
559
- const target = yield* buildImage(document, before, limits);
560
- const changes = compare(before, yield* normalize(target, limits, "target"));
561
- if (!sameChanges(changes, document.changes)) {
562
- return yield* new ImageError({ code: "InvalidStructure", field: "changes" });
667
+ const [AddedSummary, RemovedSummary, UpdatedSummary] = SnapshotChange.members;
668
+ const publicChange = (change, options) => {
669
+ const path = makeBytePath(CanonicalBase64.toBytes(change.path));
670
+ if (Change.guards.Added(change))
671
+ return Object.freeze(AddedSummary.make({ path, kind: change.kind }, UNCHECKED));
672
+ if (Change.guards.Removed(change)) {
673
+ return Object.freeze(RemovedSummary.make({ path, kind: change.kind }, UNCHECKED));
563
674
  }
564
- return { changes, target };
565
- });
675
+ const differences = change.differences.filter((field) => options.includeTimestamps === true || !timestampFields.has(field));
676
+ if (differences.length === 0)
677
+ return undefined;
678
+ return Object.freeze(UpdatedSummary.make({
679
+ path,
680
+ beforeKind: change.beforeKind,
681
+ afterKind: change.afterKind,
682
+ differences: Object.freeze(differences)
683
+ }, UNCHECKED));
684
+ };
566
685
  /** @internal */
567
686
  export const inspectSnapshotDelta = Effect.fnUntraced(function* (base, delta, options, limits) {
687
+ const document = yield* getDocument(delta);
688
+ yield* verify(base, document, limits);
568
689
  const output = [];
569
- const { changes } = yield* verify(base, yield* getDocument(delta), limits);
570
- for (const change of changes) {
571
- const path = makeBytePath(yield* CanonicalBase64.decode(change.path));
572
- if (!Predicate.isTagged("Updated")(change))
573
- output.push(Object.freeze({ ...change, path }));
574
- else {
575
- const fields = change.differences.filter((field) => options.includeTimestamps === true || !timestampFields.has(field));
576
- if (fields.length > 0)
577
- output.push(Object.freeze({ ...change, path, differences: Object.freeze([...fields]) }));
578
- }
690
+ for (const change of document.changes) {
691
+ const shown = publicChange(change, options);
692
+ if (shown !== undefined)
693
+ output.push(shown);
579
694
  }
580
695
  return Object.freeze(output);
581
696
  });
582
697
  /** @internal */
698
+ export const applySnapshotDelta = Effect.fnUntraced(function* (base, delta, limits) {
699
+ return Image.make(yield* verify(base, yield* getDocument(delta), limits));
700
+ });
701
+ const encoder = new TextEncoder();
702
+ /** @internal */
583
703
  export const encodeSnapshotDelta = Effect.fnUntraced(function* (delta, limits) {
584
704
  const document = yield* getDocument(delta);
585
- yield* validate(document, limits);
586
- const text = yield* Schema.encodeEffect(JsonDocument)(document).pipe(Effect.mapError(() => new ImageError({ code: "InvalidStructure" })));
705
+ yield* Effect.fromResult(validate(document, limits));
706
+ const text = yield* Schema.encodeEffect(JsonDocument)(document).pipe(Effect.mapError((cause) => imageFailure(OPERATION, "InvalidStructure", { cause })));
587
707
  const bytes = encoder.encode(text);
588
- if (exceeds(bytes.length, limits.maxEncodedBytes)) {
589
- return yield* new ImageError({ code: "LimitExceeded", field: "encodedBytes" });
708
+ if (exceeds(bytes.length, limits.encodedBytes)) {
709
+ return yield* imageFailure(OPERATION, "LimitExceeded", { field: "encodedBytes" });
590
710
  }
591
711
  return bytes;
592
712
  });
593
713
  /** @internal */
594
714
  export const decodeSnapshotDelta = Effect.fnUntraced(function* (input, limits) {
595
- if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer)) {
596
- return yield* new ImageError({ code: "InvalidEncoding" });
715
+ if (!isAttachedBytes(input)) {
716
+ return yield* imageFailure(OPERATION, "InvalidEncoding");
597
717
  }
598
- if (exceeds(input.byteLength, limits.maxEncodedBytes)) {
599
- return yield* new ImageError({ code: "LimitExceeded", field: "encodedBytes" });
718
+ if (exceeds(input.byteLength, limits.encodedBytes)) {
719
+ return yield* imageFailure(OPERATION, "LimitExceeded", { field: "encodedBytes" });
600
720
  }
601
- const text = yield* Effect.try({
602
- try: () => new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(input)),
603
- catch: () => new ImageError({ code: "InvalidEncoding" })
604
- });
605
- const value = yield* Schema.decodeEffect(Json)(text).pipe(Effect.mapError(() => new ImageError({ code: "InvalidEncoding" })));
721
+ const text = yield* decodeUtf8(input, (cause) => imageFailure(OPERATION, "InvalidEncoding", { cause }));
722
+ const value = yield* Schema.decodeEffect(Json)(text).pipe(Effect.mapError((cause) => imageFailure(OPERATION, "InvalidEncoding", { cause })));
606
723
  const version = Schema.decodeUnknownResult(VersionProbe)(value);
607
724
  if (Result.isSuccess(version) && version.success.version !== 1) {
608
- return yield* new ImageError({ code: "UnsupportedVersion" });
725
+ return yield* imageFailure(OPERATION, "UnsupportedVersion");
609
726
  }
610
- const wire = Schema.decodeUnknownResult(WireDocument, { onExcessProperty: "error" })(value);
611
- if (Result.isFailure(wire))
612
- return yield* new ImageError({ code: "InvalidStructure" });
613
- yield* validate(wire.success, limits);
614
- const parsed = Schema.decodeUnknownResult(Document, { onExcessProperty: "error" })(value);
615
- if (Result.isFailure(parsed))
616
- return yield* new ImageError({ code: "InvalidEncoding" });
617
- return SnapshotDeltaModel.make(parsed.success);
618
- });
619
- /** @internal */
620
- export const applySnapshotDelta = Effect.fnUntraced(function* (base, delta, limits) {
621
- return (yield* verify(base, yield* getDocument(delta), limits)).target;
727
+ const document = yield* Effect.mapError(decodeDocument(value), decodeFailure);
728
+ yield* Effect.fromResult(validate(document, limits));
729
+ return SnapshotDeltaModel.make(document);
622
730
  });