@valbuild/cli 0.102.0 → 0.103.1

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.
@@ -9,7 +9,7 @@ export default c.define(
9
9
  image2: s.image(basicGallery2Val),
10
10
  }),
11
11
  {
12
- image1: c.image("/public/val/images/image.png"),
13
- image2: c.image("/public/val/images2/image.png"),
12
+ image1: { path: "/public/val/images/image.png" },
13
+ image2: { path: "/public/val/images2/image.png" },
14
14
  },
15
15
  );
@@ -7,6 +7,6 @@ export default c.define(
7
7
  image: s.image(basicGalleryVal),
8
8
  }),
9
9
  {
10
- image: c.image("/public/val/images/image.png"),
10
+ image: { path: "/public/val/images/image.png" },
11
11
  },
12
12
  );
@@ -1,7 +1,5 @@
1
1
  import { c, s } from "../val.config";
2
2
 
3
- export default c.define(
4
- "/content/basic-image.val.ts",
5
- s.image(),
6
- c.image("/public/val/image.png"),
7
- );
3
+ export default c.define("/content/basic-image.val.ts", s.image(), {
4
+ path: "/public/val/image.png",
5
+ });
@@ -1,19 +1,16 @@
1
1
  {
2
2
  "patch": [
3
3
  {
4
- "op": "remove",
5
- "path": ["BBL", "summary", "services", "2"]
4
+ "op": "replace",
5
+ "path": ["BBL", "title"],
6
+ "value": "BBL Housing"
6
7
  }
7
8
  ],
8
- "patchId": "22222222-2222-4222-8222-222222222222",
9
- "parentRef": {
10
- "type": "patch",
11
- "patchId": "11111111-1111-4111-8111-111111111111"
12
- },
9
+ "patchId": "11111111-1111-4111-8111-111111111111",
13
10
  "path": "/content/projects.val.ts",
14
- "authorId": "author-b",
11
+ "authorId": "author-a",
15
12
  "sessionId": null,
16
13
  "baseSha": "0000000000000000000000000000000000000000000000000000000000000000",
17
14
  "coreVersion": null,
18
- "createdAt": "2026-08-11T09:00:00.000Z"
15
+ "createdAt": "2026-08-10T12:00:00.000Z"
19
16
  }
@@ -1,20 +1,15 @@
1
1
  {
2
2
  "patch": [
3
3
  {
4
- "op": "replace",
5
- "path": ["BBL", "summary", "services", "2"],
6
- "value": "Development U"
4
+ "op": "remove",
5
+ "path": ["BBL", "summary", "services", "2"]
7
6
  }
8
7
  ],
9
- "patchId": "33333333-3333-4333-8333-333333333333",
10
- "parentRef": {
11
- "type": "patch",
12
- "patchId": "22222222-2222-4222-8222-222222222222"
13
- },
8
+ "patchId": "22222222-2222-4222-8222-222222222222",
14
9
  "path": "/content/projects.val.ts",
15
- "authorId": "author-a",
10
+ "authorId": "author-b",
16
11
  "sessionId": null,
17
12
  "baseSha": "0000000000000000000000000000000000000000000000000000000000000000",
18
13
  "coreVersion": null,
19
- "createdAt": "2026-08-11T09:00:05.000Z"
14
+ "createdAt": "2026-08-11T09:00:00.000Z"
20
15
  }
@@ -0,0 +1,16 @@
1
+ {
2
+ "patch": [
3
+ {
4
+ "op": "replace",
5
+ "path": ["BBL", "summary", "services", "2"],
6
+ "value": "Development U"
7
+ }
8
+ ],
9
+ "patchId": "33333333-3333-4333-8333-333333333333",
10
+ "path": "/content/projects.val.ts",
11
+ "authorId": "author-a",
12
+ "sessionId": null,
13
+ "baseSha": "0000000000000000000000000000000000000000000000000000000000000000",
14
+ "coreVersion": null,
15
+ "createdAt": "2026-08-11T09:00:05.000Z"
16
+ }
@@ -0,0 +1,4 @@
1
+ val-patch-log v1
2
+ 11111111-1111-4111-8111-111111111111 2026-08-10T12:00:00.000Z /content/projects.val.ts
3
+ 22222222-2222-4222-8222-222222222222 2026-08-11T09:00:00.000Z /content/projects.val.ts
4
+ 33333333-3333-4333-8333-333333333333 2026-08-11T09:00:05.000Z /content/projects.val.ts
@@ -22,8 +22,6 @@ export type SnapshotPatch = {
22
22
  authorId: string | null;
23
23
  baseSha: string;
24
24
  appliedAt: { commitSha: string } | null;
25
- /** Parent in the chain: null means it is the first ("head"). */
26
- parentPatchId: PatchId | null;
27
25
  patch: unknown;
28
26
  };
29
27
 
@@ -199,9 +197,21 @@ export async function buildSnapshot(
199
197
  for (const p of elided.elided) {
200
198
  elidedPatchValues.push({ patchId: patch.patchId, path: p });
201
199
  }
202
- entries[`.val/patches/${patch.parentPatchId ?? "head"}/patch.json`] =
203
- JSON.stringify(toFsPatch(patch), null, 2);
200
+ entries[`.val/patches/${patch.patchId}/patch.json`] = JSON.stringify(
201
+ toFsPatch(patch),
202
+ null,
203
+ 2,
204
+ );
204
205
  }
206
+ // The order lives in the log, not in the records, so it has to be written too
207
+ // or the unzipped snapshot is a store with no order at all.
208
+ entries[".val/patches/patches.log"] = [
209
+ "val-patch-log v1",
210
+ ...patches.map(
211
+ (patch) => `${patch.patchId} ${patch.createdAt} ${patch.path}`,
212
+ ),
213
+ "",
214
+ ].join("\n");
205
215
 
206
216
  let includesBinaryFiles = false;
207
217
  if (options.includeFiles) {
@@ -296,31 +306,29 @@ function toSnapshotPatches(
296
306
  appliedAt: { commitSha: string } | null;
297
307
  }[],
298
308
  ): SnapshotPatch[] {
299
- return orderedPatches.map((patch, i) => ({
309
+ return orderedPatches.map((patch) => ({
300
310
  patchId: patch.patchId,
301
311
  path: patch.path,
302
312
  createdAt: patch.createdAt,
303
313
  authorId: patch.authorId,
304
314
  baseSha: patch.baseSha,
305
315
  appliedAt: patch.appliedAt,
306
- parentPatchId: i === 0 ? null : orderedPatches[i - 1].patchId,
307
316
  patch: patch.patch,
308
317
  }));
309
318
  }
310
319
 
311
320
  /**
312
321
  * The shape ValOpsFS writes per patch, so an unzipped snapshot is a patch store
313
- * a plain ValOpsFS can read. The directory is named after the PARENT, and
314
- * createPatchChain walks the linked list from "head".
322
+ * a plain ValOpsFS can read.
323
+ *
324
+ * The directory is named after the patch itself and the record points at
325
+ * nothing: the order is in `patches.log` beside it. A record that named its
326
+ * parent is how a snapshot could carry a chain that had already lost a link.
315
327
  */
316
328
  function toFsPatch(patch: SnapshotPatch) {
317
329
  return {
318
330
  patch: patch.patch,
319
331
  patchId: patch.patchId,
320
- parentRef:
321
- patch.parentPatchId === null
322
- ? { type: "head", headBaseSha: patch.baseSha }
323
- : { type: "patch", patchId: patch.parentPatchId },
324
332
  path: patch.path,
325
333
  authorId: patch.authorId,
326
334
  sessionId: null,
@@ -339,16 +347,13 @@ async function writeBinaryFiles(
339
347
  patches: SnapshotPatch[],
340
348
  entries: Record<string, string>,
341
349
  ): Promise<boolean> {
342
- const parentByPatchId = new Map(
343
- patches.map((p) => [p.patchId, p.parentPatchId ?? "head"]),
344
- );
350
+ const known = new Set(patches.map((p) => p.patchId));
345
351
  let wrote = false;
346
352
  for (const [filePath, data] of Object.entries(fileLastUpdatedByPatchId)) {
347
353
  if (data.isDelete) {
348
354
  continue;
349
355
  }
350
- const parentPatchId = parentByPatchId.get(data.patchId);
351
- if (parentPatchId === undefined) {
356
+ if (!known.has(data.patchId)) {
352
357
  continue;
353
358
  }
354
359
  const buffer = await ctx.serverOps.getBase64EncodedBinaryFileFromPatch(
@@ -361,7 +366,7 @@ async function writeBinaryFiles(
361
366
  }
362
367
  // Base64 so the snapshot stays a text-only entry map; the replay decodes it.
363
368
  entries[
364
- `.val/patches/${parentPatchId}/files${filePath}/${path.posix.basename(filePath)}.base64`
369
+ `.val/patches/${data.patchId}/files${filePath}/${path.posix.basename(filePath)}.base64`
365
370
  ] = buffer.toString("base64");
366
371
  wrote = true;
367
372
  }
package/src/debug.test.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import fs from "fs";
2
+ import os from "os";
1
3
  import path from "path";
2
4
  import { ModuleFilePath } from "@valbuild/core";
5
+ import { readPatchStore } from "@valbuild/server";
3
6
  import { createDebugContext } from "./debug/context";
4
7
  import { buildSnapshot } from "./debug/snapshot";
5
8
 
@@ -71,25 +74,43 @@ describe("val debug snapshot", () => {
71
74
  expect(entries["val.modules.original.ts"]).toContain("unrelated");
72
75
  });
73
76
 
74
- test("writes the patch chain in the layout ValOpsFS reads back", async () => {
77
+ test("writes a patch store ValOpsFS can actually read back", async () => {
75
78
  const { entries } = await build();
76
79
 
77
- // The directory is named after the PARENT patch; the first is "head".
78
- const head = JSON.parse(entries[".val/patches/head/patch.json"]);
79
- expect(head.patchId).toBe("11111111-1111-4111-8111-111111111111");
80
- expect(head.parentRef.type).toBe("head");
81
- // A head ref without headBaseSha does not parse, so the snapshot would be
82
- // unreadable.
83
- expect(typeof head.parentRef.headBaseSha).toBe("string");
84
-
85
- const second = JSON.parse(
86
- entries[".val/patches/11111111-1111-4111-8111-111111111111/patch.json"],
87
- );
88
- expect(second.patchId).toBe("22222222-2222-4222-8222-222222222222");
89
- expect(second.parentRef).toEqual({
90
- type: "patch",
91
- patchId: "11111111-1111-4111-8111-111111111111",
92
- });
80
+ // Materialise it and read it with the real store, rather than asserting on
81
+ // path strings: "the layout ValOpsFS reads back" is the property, and a
82
+ // snapshot that merely looks right is exactly what a debug tool must not
83
+ // produce.
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "val-snapshot-"));
85
+ for (const [name, content] of Object.entries(entries)) {
86
+ const file = path.join(dir, name);
87
+ fs.mkdirSync(path.dirname(file), { recursive: true });
88
+ fs.writeFileSync(file, content);
89
+ }
90
+
91
+ const read = readPatchStore(path.join(dir, ".val", "patches"));
92
+ if (read.status !== "ok") {
93
+ throw new Error(`expected ok, got ${read.status}`);
94
+ }
95
+ expect(read.problems).toEqual([]);
96
+ expect(read.entries.map((entry) => entry.patchId)).toEqual([
97
+ "11111111-1111-4111-8111-111111111111",
98
+ "22222222-2222-4222-8222-222222222222",
99
+ "33333333-3333-4333-8333-333333333333",
100
+ ]);
101
+
102
+ // Each record sits under its own id and points at nothing. A record that
103
+ // named its parent is how a snapshot could carry a chain that had already
104
+ // lost a link.
105
+ for (const entry of read.entries) {
106
+ const raw = JSON.parse(
107
+ entries[`.val/patches/${entry.patchId}/patch.json`],
108
+ );
109
+ expect(raw.patchId).toBe(entry.patchId);
110
+ expect(raw).not.toHaveProperty("parentRef");
111
+ }
112
+
113
+ fs.rmSync(dir, { recursive: true, force: true });
93
114
  });
94
115
 
95
116
  test("records why the snapshot may be incomplete rather than failing silently", async () => {
@@ -1,10 +1,4 @@
1
- import {
2
- FILE_REF_PROP,
3
- ModuleFilePath,
4
- ModulePath,
5
- SourcePath,
6
- VAL_EXTENSION,
7
- } from "@valbuild/core";
1
+ import { ModuleFilePath, ModulePath, SourcePath } from "@valbuild/core";
8
2
  import { createService } from "@valbuild/server";
9
3
  import { glob } from "fast-glob";
10
4
  import path from "path";
@@ -49,7 +43,7 @@ export async function listUnusedFiles({ root }: { root?: string }) {
49
43
  if (isFileRef(value)) {
50
44
  const absoluteFilePathUsedByVal = path.join(
51
45
  projectRoot,
52
- ...value[FILE_REF_PROP].split("/"),
46
+ ...value.path.split("/"),
53
47
  );
54
48
  filesUsedByVal.push(absoluteFilePathUsedByVal);
55
49
  }
@@ -78,18 +72,17 @@ export async function listUnusedFiles({ root }: { root?: string }) {
78
72
  return;
79
73
  }
80
74
 
81
- function isFileRef(
82
- value: unknown,
83
- ): value is { [FILE_REF_PROP]: string; [VAL_EXTENSION]: "file" } {
84
- if (!value) return false;
85
- if (typeof value !== "object") return false;
86
- if (FILE_REF_PROP in value && VAL_EXTENSION in value) {
87
- if (
88
- value[VAL_EXTENSION] === "file" &&
89
- typeof value[FILE_REF_PROP] === "string"
90
- ) {
91
- return true;
92
- }
93
- }
94
- return false;
75
+ /**
76
+ * Whether a flagged validation value names a file.
77
+ *
78
+ * A `ValidationError` carries no schema, so the shape is all there is to go on —
79
+ * the same heuristic this whole function documents as a TODO.
80
+ */
81
+ function isFileRef(value: unknown): value is { path: string } {
82
+ return (
83
+ !!value &&
84
+ typeof value === "object" &&
85
+ "path" in value &&
86
+ typeof value.path === "string"
87
+ );
95
88
  }
@@ -372,12 +372,12 @@ describe("runValidation", () => {
372
372
  .flatMap((e) => e.fixes ?? []);
373
373
  expect(allFixes).not.toContain("image:add-metadata");
374
374
  }
375
+ // The fix writes the fields it read from the bytes next to `path`.
375
376
  expect(result.source).toMatchObject({
376
- metadata: {
377
- width: 1,
378
- height: 1,
379
- mimeType: "image/png",
380
- },
377
+ path: "/public/val/image.png",
378
+ width: 1,
379
+ height: 1,
380
+ mimeType: "image/png",
381
381
  });
382
382
  } finally {
383
383
  service.dispose();