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