@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,8 +9,8 @@ var fastGlob = require('fast-glob');
9
9
  var core = require('@valbuild/core');
10
10
  var server = require('@valbuild/server');
11
11
  var internal = require('@valbuild/shared/internal');
12
- var ts = require('typescript');
13
12
  var fs = require('fs');
13
+ var ts = require('typescript');
14
14
  var JSZip = require('jszip');
15
15
  var readline = require('readline');
16
16
 
@@ -39,8 +39,8 @@ var chalk__default = /*#__PURE__*/_interopDefault(chalk);
39
39
  var path__default = /*#__PURE__*/_interopDefault(path);
40
40
  var pc__default = /*#__PURE__*/_interopDefault(pc);
41
41
  var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
42
- var ts__default = /*#__PURE__*/_interopDefault(ts);
43
42
  var fs__default = /*#__PURE__*/_interopDefault(fs);
43
+ var ts__default = /*#__PURE__*/_interopDefault(ts);
44
44
  var JSZip__default = /*#__PURE__*/_interopDefault(JSZip);
45
45
  var readline__default = /*#__PURE__*/_interopDefault(readline);
46
46
 
@@ -59,543 +59,6 @@ function info(message, opts = {}) {
59
59
  console.log(chalk__default["default"].blue("️ℹ️ : ") + message);
60
60
  }
61
61
 
62
- function getFileExt(filePath) {
63
- // NOTE: We do not import the path module. This code is copied in different projects. We want the same implementation and which means that this might running in browser where path is not available).
64
- return filePath.split(".").pop() || "";
65
- }
66
-
67
- const textEncoder = new TextEncoder();
68
-
69
- // Types for handler system
70
-
71
- // Handler functions
72
- async function handleFileMetadata(ctx) {
73
- var _fileSource$source;
74
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
75
- if (!ctx.valModule.source || !ctx.valModule.schema) {
76
- return {
77
- success: false,
78
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
79
- };
80
- }
81
- const fileSource = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
82
-
83
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
- const fileRefProp = (_fileSource$source = fileSource.source) === null || _fileSource$source === void 0 ? void 0 : _fileSource$source[core.FILE_REF_PROP];
85
- if (!fileRefProp) {
86
- return {
87
- success: false,
88
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
89
- };
90
- }
91
- const filePath = path__default["default"].join(ctx.projectRoot, fileRefProp);
92
- if (!ctx.fs.fileExists(filePath)) {
93
- return {
94
- success: false,
95
- errorMessage: `File ${filePath} does not exist`
96
- };
97
- }
98
- return {
99
- success: true,
100
- shouldApplyPatch: true
101
- };
102
- }
103
-
104
- // Shared upload core used by both the single-field (handleRemoteFileUpload)
105
- // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
106
- // how they derive the local file ref, metadata and serialized image/file
107
- // schema; everything from auth through upload is identical.
108
- async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
109
- const filePath = path__default["default"].join(ctx.projectRoot, fileRef);
110
- if (!ctx.fs.fileExists(filePath)) {
111
- return {
112
- success: false,
113
- errorMessage: `File ${filePath} does not exist`
114
- };
115
- }
116
- const patFile = server.getPersonalAccessTokenPath(ctx.projectRoot);
117
- if (!ctx.fs.fileExists(patFile)) {
118
- return {
119
- success: false,
120
- errorMessage: `File: ${path__default["default"].join(ctx.projectRoot, ctx.file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`
121
- };
122
- }
123
- const patFileContent = ctx.fs.readFile(patFile);
124
- if (patFileContent === undefined) {
125
- return {
126
- success: false,
127
- errorMessage: `Could not read personal access token file at ${patFile}`
128
- };
129
- }
130
- const parsedPatFile = server.parsePersonalAccessTokenFile(patFileContent);
131
- if (!parsedPatFile.success) {
132
- return {
133
- success: false,
134
- errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`
135
- };
136
- }
137
- const {
138
- pat
139
- } = parsedPatFile.data;
140
- if (ctx.remoteFiles[ctx.sourcePath]) {
141
- return {
142
- success: true,
143
- events: [{
144
- type: "remote-already-uploaded",
145
- filePath
146
- }]
147
- };
148
- }
149
- const projectName = ctx.project;
150
- let publicProjectId = ctx.publicProjectId;
151
- let remoteFileBuckets = ctx.remoteFileBuckets;
152
- let remoteFilesCounter = ctx.remoteFilesCounter;
153
- if (!publicProjectId || !remoteFileBuckets) {
154
- if (!projectName) {
155
- return {
156
- success: false,
157
- errorMessage: "Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable"
158
- };
159
- }
160
- const settingsRes = await ctx.remote.getSettings(projectName, {
161
- pat
162
- });
163
- if (!settingsRes.success) {
164
- return {
165
- success: false,
166
- errorMessage: `Could not get public project id: ${settingsRes.message}.`
167
- };
168
- }
169
- publicProjectId = settingsRes.data.publicProjectId;
170
- remoteFileBuckets = settingsRes.data.remoteFileBuckets.map(b => b.bucket);
171
- }
172
- if (!publicProjectId) {
173
- return {
174
- success: false,
175
- errorMessage: "Could not get public project id"
176
- };
177
- }
178
- if (!projectName) {
179
- return {
180
- success: false,
181
- errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
182
- };
183
- }
184
- remoteFilesCounter += 1;
185
- const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
186
- if (!bucket) {
187
- return {
188
- success: false,
189
- errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`
190
- };
191
- }
192
- const fileBuffer = ctx.fs.readBuffer(filePath);
193
- if (fileBuffer === undefined) {
194
- return {
195
- success: false,
196
- errorMessage: `Error reading file: ${filePath}`
197
- };
198
- }
199
- const relativeFilePath = path__default["default"].relative(ctx.projectRoot, filePath).split(path__default["default"].sep).join("/");
200
- if (!relativeFilePath.startsWith("public/")) {
201
- return {
202
- success: false,
203
- errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
204
- };
205
- }
206
- const fileHash = core.Internal.remote.getFileHash(fileBuffer);
207
- const coreVersion = core.Internal.VERSION.core || "unknown";
208
- const fileExt = getFileExt(filePath);
209
- const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
210
- publicProjectId,
211
- coreVersion,
212
- bucket,
213
- validationHash: core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata, fileHash, textEncoder),
214
- fileHash,
215
- filePath: relativeFilePath
216
- });
217
- const remoteFileUpload = await ctx.remote.uploadFile(projectName, bucket, fileHash, fileExt, fileBuffer, {
218
- pat
219
- });
220
- if (!remoteFileUpload.success) {
221
- return {
222
- success: false,
223
- errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`
224
- };
225
- }
226
- ctx.remoteFiles[ctx.sourcePath] = {
227
- ref,
228
- metadata
229
- };
230
- return {
231
- success: true,
232
- shouldApplyPatch: true,
233
- publicProjectId,
234
- remoteFileBuckets,
235
- remoteFilesCounter,
236
- events: [{
237
- type: "remote-uploading",
238
- ref
239
- }, {
240
- type: "remote-uploaded",
241
- ref
242
- }]
243
- };
244
- }
245
- async function handleRemoteFileUpload(ctx) {
246
- var _resolvedRemoteFileAt;
247
- if (!ctx.fix) {
248
- return {
249
- success: false,
250
- // No sourcePath in the message: the reported location already points at it.
251
- errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
252
- };
253
- }
254
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
255
- if (!ctx.valModule.source || !ctx.valModule.schema) {
256
- return {
257
- success: false,
258
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
259
- };
260
- }
261
- const resolvedRemoteFileAtSourcePath = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
262
-
263
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
- const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[core.FILE_REF_PROP];
265
- if (!fileRefProp) {
266
- return {
267
- success: false,
268
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
269
- };
270
- }
271
- const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
272
- if (!resolveRemoteFileSchema) {
273
- return {
274
- success: false,
275
- errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
276
- };
277
- }
278
- if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
279
- return {
280
- success: false,
281
- errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
282
- };
283
- }
284
- const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
285
- const fileSourceMetadata = core.Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
286
- return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
287
- }
288
-
289
- // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
290
- // Unlike a single image/file field, a gallery entry is keyed by its local file
291
- // path and the value is bare metadata (no FileSource), so we derive the file
292
- // ref from the key and synthesize the image/file schema from the record's
293
- // media options.
294
- async function handleRemoteGalleryFileUpload(ctx) {
295
- var _ctx$validationError$;
296
- if (!ctx.fix) {
297
- return {
298
- success: false,
299
- // No sourcePath in the message: the reported location already points at it.
300
- errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
301
- };
302
- }
303
- const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
304
- const mediaType = fix === "files:upload-remote" ? "file" : "image";
305
-
306
- // The gallery entry is keyed by its local file path; validateMediaKey carries
307
- // that key as the error value.
308
- const fileRef = ctx.validationError.value;
309
- if (typeof fileRef !== "string") {
310
- return {
311
- success: false,
312
- errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
313
- };
314
- }
315
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
316
- if (!ctx.valModule.source || !ctx.valModule.schema) {
317
- return {
318
- success: false,
319
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
320
- };
321
- }
322
- const resolved = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
323
- const entrySource = resolved.source;
324
- const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
325
-
326
- // The gallery item schema is an ObjectSchema, not an image/file schema, so
327
- // synthesize the serialized image/file schema (matching how single fields
328
- // serialize) for the remote ref's validation hash.
329
- //
330
- // `accept`/`directory` come from the RECORD that holds the entry, resolved
331
- // from the entry's PARENT path - not from the module root, which is only the
332
- // record when the gallery is the whole module. A nested gallery
333
- // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
334
- // with no options and bake a validation hash into the remote ref that can
335
- // never validate, so a mismatch fails fast instead of uploading.
336
- const [, parentModulePath] = core.Internal.splitModuleFilePathAndModulePath(core.Internal.parentOfSourcePath(ctx.sourcePath));
337
- const recordSchema = core.Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
338
- if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
339
- return {
340
- success: false,
341
- errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
342
- };
343
- }
344
- const {
345
- accept,
346
- directory
347
- } = recordSchema;
348
- const schema = mediaType === "image" ? {
349
- type: "image",
350
- opt: false,
351
- options: {
352
- ...(accept ? {
353
- accept
354
- } : {}),
355
- ...(directory ? {
356
- directory
357
- } : {})
358
- }
359
- } : {
360
- type: "file",
361
- opt: false,
362
- options: {
363
- ...(accept ? {
364
- accept
365
- } : {})
366
- }
367
- };
368
- return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
369
- }
370
- async function handleRemoteFileDownload(ctx) {
371
- if (ctx.fix) {
372
- return {
373
- success: true,
374
- shouldApplyPatch: true,
375
- events: [{
376
- type: "remote-downloading",
377
- sourcePath: ctx.sourcePath
378
- }]
379
- };
380
- } else {
381
- return {
382
- success: false,
383
- errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`
384
- };
385
- }
386
- }
387
- async function handleRemoteFileCheck() {
388
- // Skip - no action needed
389
- return {
390
- success: true,
391
- shouldApplyPatch: true
392
- };
393
- }
394
- async function handleUniqueFolderCheck(ctx) {
395
- const value = ctx.validationError.value;
396
- if (!value || typeof value.directory !== "string") {
397
- return {
398
- success: false,
399
- errorMessage: `Unexpected value in unique folder check for ${ctx.sourcePath}`
400
- };
401
- }
402
- const {
403
- directory
404
- } = value;
405
- const conflicts = [];
406
- for (const file of ctx.valFiles) {
407
- const otherModuleFilePath = `/${file}`;
408
- if (otherModuleFilePath === ctx.moduleFilePath) continue;
409
- const otherModule = await ctx.service.get(otherModuleFilePath, "", {
410
- validate: false
411
- });
412
- const schema = otherModule.schema;
413
- if ((schema === null || schema === void 0 ? void 0 : schema.type) === "record" && schema.directory === directory && schema.mediaType) {
414
- conflicts.push(otherModuleFilePath);
415
- }
416
- }
417
- if (conflicts.length > 0) {
418
- return {
419
- success: false,
420
- errorMessage: `Gallery directory '${directory}' in ${ctx.moduleFilePath} is also used by: ${conflicts.join(", ")}. Each gallery must use a unique directory.`
421
- };
422
- }
423
- return {
424
- success: true
425
- };
426
- }
427
-
428
- // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
429
- // entries by a remote URL while keeping the file on disk; everything else is
430
- // already a local path and is returned unchanged.
431
- function remoteKeyToLocalPath(key) {
432
- const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
433
- if (remoteRefRes.status === "success") {
434
- return `/${remoteRefRes.filePath}`;
435
- }
436
- return key;
437
- }
438
- async function handleCheckAllFiles(ctx) {
439
- const value = ctx.validationError.value;
440
- if (!value || typeof value.directory !== "string") {
441
- return {
442
- success: false,
443
- errorMessage: `Unexpected value in check-all-files for ${ctx.sourcePath}`
444
- };
445
- }
446
- const {
447
- directory
448
- } = value;
449
- const source = ctx.valModule.source;
450
- if (!source || typeof source !== "object" || Array.isArray(source)) {
451
- return {
452
- success: false,
453
- errorMessage: `Could not get source for ${ctx.sourcePath}`
454
- };
455
- }
456
- // Gallery entries are keyed by their file path. Remote galleries key uploaded
457
- // entries by a remote URL, but the file is kept on disk at its local path, so
458
- // normalize remote-URL keys back to that local path for the on-disk checks.
459
- const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
460
-
461
- // Check that all tracked files exist on disk
462
- const missingTrackedFiles = [...trackedFiles].filter(f => {
463
- return !ctx.fs.fileExists(path__default["default"].join(ctx.projectRoot, f));
464
- });
465
- if (missingTrackedFiles.length > 0) {
466
- if (!ctx.fix) {
467
- return {
468
- success: false,
469
- errorMessage: `Gallery in ${ctx.moduleFilePath} has tracked files that do not exist on disk: ${missingTrackedFiles.join(", ")}. Add the files or remove them from the gallery.`
470
- };
471
- }
472
- // fix: true — let createFixPatch remove the missing entries
473
- return {
474
- success: true,
475
- shouldApplyPatch: true
476
- };
477
- }
478
- const dirPath = path__default["default"].join(ctx.projectRoot, directory);
479
- const filesInDir = [];
480
- try {
481
- const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
482
- for (const entry of entries) {
483
- const relPath = "/" + path__default["default"].relative(ctx.projectRoot, entry).split(path__default["default"].sep).join("/");
484
- filesInDir.push(relPath);
485
- }
486
- } catch {
487
- // directory doesn't exist — no untracked files possible
488
- }
489
- const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
490
- if (untrackedFiles.length > 0) {
491
- return {
492
- success: false,
493
- errorMessage: `Gallery in ${ctx.moduleFilePath} has files not tracked: ${untrackedFiles.join(", ")}. Add these files to the gallery or remove them from the directory.`
494
- };
495
- }
496
-
497
- // All files accounted for — trigger metadata verification via createFixPatch
498
- return {
499
- success: true,
500
- shouldApplyPatch: true
501
- };
502
- }
503
- async function handleJsonValuesExtractEntry(ctx) {
504
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
505
- const parts = core.Internal.splitModulePath(modulePath);
506
- // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
507
- // direct child of the module's root record/router, so the path is one segment.
508
- if (parts.length !== 1) {
509
- return {
510
- success: false,
511
- errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`
512
- };
513
- }
514
- const entryKey = parts[0];
515
- const source = ctx.valModule.source;
516
- if (source === null || typeof source !== "object" || Array.isArray(source)) {
517
- return {
518
- success: false,
519
- errorMessage: `Could not get source for ${ctx.moduleFilePath}`
520
- };
521
- }
522
- const content = source[entryKey];
523
- if (content === undefined) {
524
- return {
525
- success: false,
526
- errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`
527
- };
528
- }
529
- if (!ctx.fix) {
530
- return {
531
- success: true,
532
- fixableErrorMessage: ctx.validationError.message
533
- };
534
- }
535
- try {
536
- server.extractJsonValuesEntry(ctx.moduleFilePath, ctx.projectRoot, entryKey, content, ctx.service.sourceFileHandler);
537
- } catch (err) {
538
- return {
539
- success: false,
540
- errorMessage: err instanceof Error ? err.message : String(err)
541
- };
542
- }
543
- return {
544
- success: true,
545
- appliedFix: true
546
- };
547
- }
548
-
549
- // Fix handler registry. `keyof:check-keys` and `router:check-route` are
550
- // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
551
- // this registry, so they're excluded from the key set.
552
- const currentFixHandlers = {
553
- "image:check-metadata": handleFileMetadata,
554
- "image:add-metadata": handleFileMetadata,
555
- "file:check-metadata": handleFileMetadata,
556
- "file:add-metadata": handleFileMetadata,
557
- "image:upload-remote": handleRemoteFileUpload,
558
- "file:upload-remote": handleRemoteFileUpload,
559
- "images:upload-remote": handleRemoteGalleryFileUpload,
560
- "files:upload-remote": handleRemoteGalleryFileUpload,
561
- "image:download-remote": handleRemoteFileDownload,
562
- "file:download-remote": handleRemoteFileDownload,
563
- "image:check-remote": handleRemoteFileCheck,
564
- "images:check-remote": handleRemoteFileCheck,
565
- "file:check-remote": handleRemoteFileCheck,
566
- "files:check-remote": handleRemoteFileCheck,
567
- "images:check-unique-folder": handleUniqueFolderCheck,
568
- "files:check-unique-folder": handleUniqueFolderCheck,
569
- "images:check-all-files": handleCheckAllFiles,
570
- "files:check-all-files": handleCheckAllFiles,
571
- "jsonValues:extract-entry": handleJsonValuesExtractEntry
572
- };
573
- const deprecatedFixHandlers = {
574
- "image:replace-metadata": handleFileMetadata
575
- };
576
- const fixHandlers = {
577
- ...deprecatedFixHandlers,
578
- ...currentFixHandlers
579
- };
580
- function createDefaultValFSHost() {
581
- return {
582
- ...ts__default["default"].sys,
583
- writeFile: (fileName, data, encoding) => {
584
- fs__default["default"].mkdirSync(path__default["default"].dirname(fileName), {
585
- recursive: true
586
- });
587
- fs__default["default"].writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
588
- },
589
- rmFile: fs__default["default"].rmSync,
590
- readBuffer: fileName => {
591
- try {
592
- return fs__default["default"].readFileSync(fileName);
593
- } catch {
594
- return undefined;
595
- }
596
- }
597
- };
598
- }
599
62
  async function* runValidation({
600
63
  root,
601
64
  fix,
@@ -682,7 +145,7 @@ async function* runValidation({
682
145
 
683
146
  // Find and execute appropriate handler
684
147
  const fixType = v.fixes[0]; // Take first fix
685
- const handler = fixHandlers[fixType];
148
+ const handler = server.fixHandlers[fixType];
686
149
  if (!handler) {
687
150
  yield {
688
151
  type: "unknown-fix",
@@ -1136,7 +599,7 @@ async function validate({
1136
599
  getSettings: (projectName, options) => server.getSettings(projectName, options),
1137
600
  uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => server.uploadRemoteFile(process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST, project, bucket, fileHash, fileExt ?? "", fileBuffer, options)
1138
601
  },
1139
- fs: createDefaultValFSHost()
602
+ fs: server.createDefaultValFSHost()
1140
603
  })) {
1141
604
  switch (event.type) {
1142
605
  case "file-valid":
@@ -1413,7 +876,7 @@ async function listUnusedFiles({
1413
876
  for (const error of validationError) {
1414
877
  const value = error.value;
1415
878
  if (isFileRef(value)) {
1416
- const absoluteFilePathUsedByVal = path__default["default"].join(projectRoot, ...value[core.FILE_REF_PROP].split("/"));
879
+ const absoluteFilePathUsedByVal = path__default["default"].join(projectRoot, ...value.path.split("/"));
1417
880
  filesUsedByVal.push(absoluteFilePathUsedByVal);
1418
881
  }
1419
882
  }
@@ -1438,15 +901,15 @@ async function listUnusedFiles({
1438
901
  service.dispose();
1439
902
  return;
1440
903
  }
904
+
905
+ /**
906
+ * Whether a flagged validation value names a file.
907
+ *
908
+ * A `ValidationError` carries no schema, so the shape is all there is to go on —
909
+ * the same heuristic this whole function documents as a TODO.
910
+ */
1441
911
  function isFileRef(value) {
1442
- if (!value) return false;
1443
- if (typeof value !== "object") return false;
1444
- if (core.FILE_REF_PROP in value && core.VAL_EXTENSION in value) {
1445
- if (value[core.VAL_EXTENSION] === "file" && typeof value[core.FILE_REF_PROP] === "string") {
1446
- return true;
1447
- }
1448
- }
1449
- return false;
912
+ return !!value && typeof value === "object" && "path" in value && typeof value.path === "string";
1450
913
  }
1451
914
 
1452
915
  const getVersions = () => {
@@ -2037,8 +1500,11 @@ async function buildSnapshot(ctx, options = {}) {
2037
1500
  path: p
2038
1501
  });
2039
1502
  }
2040
- entries[`.val/patches/${patch.parentPatchId ?? "head"}/patch.json`] = JSON.stringify(toFsPatch(patch), null, 2);
1503
+ entries[`.val/patches/${patch.patchId}/patch.json`] = JSON.stringify(toFsPatch(patch), null, 2);
2041
1504
  }
1505
+ // The order lives in the log, not in the records, so it has to be written too
1506
+ // or the unzipped snapshot is a store with no order at all.
1507
+ entries[".val/patches/patches.log"] = ["val-patch-log v1", ...patches.map(patch => `${patch.patchId} ${patch.createdAt} ${patch.path}`), ""].join("\n");
2042
1508
  let includesBinaryFiles = false;
2043
1509
  if (options.includeFiles) {
2044
1510
  includesBinaryFiles = await writeBinaryFiles(ctx, analysis.fileLastUpdatedByPatchId, patches, entries);
@@ -2105,34 +1571,29 @@ async function validateSnapshotSources(ctx, analysis) {
2105
1571
  return validationRes.errors;
2106
1572
  }
2107
1573
  function toSnapshotPatches(orderedPatches) {
2108
- return orderedPatches.map((patch, i) => ({
1574
+ return orderedPatches.map(patch => ({
2109
1575
  patchId: patch.patchId,
2110
1576
  path: patch.path,
2111
1577
  createdAt: patch.createdAt,
2112
1578
  authorId: patch.authorId,
2113
1579
  baseSha: patch.baseSha,
2114
1580
  appliedAt: patch.appliedAt,
2115
- parentPatchId: i === 0 ? null : orderedPatches[i - 1].patchId,
2116
1581
  patch: patch.patch
2117
1582
  }));
2118
1583
  }
2119
1584
 
2120
1585
  /**
2121
1586
  * The shape ValOpsFS writes per patch, so an unzipped snapshot is a patch store
2122
- * a plain ValOpsFS can read. The directory is named after the PARENT, and
2123
- * createPatchChain walks the linked list from "head".
1587
+ * a plain ValOpsFS can read.
1588
+ *
1589
+ * The directory is named after the patch itself and the record points at
1590
+ * nothing: the order is in `patches.log` beside it. A record that named its
1591
+ * parent is how a snapshot could carry a chain that had already lost a link.
2124
1592
  */
2125
1593
  function toFsPatch(patch) {
2126
1594
  return {
2127
1595
  patch: patch.patch,
2128
1596
  patchId: patch.patchId,
2129
- parentRef: patch.parentPatchId === null ? {
2130
- type: "head",
2131
- headBaseSha: patch.baseSha
2132
- } : {
2133
- type: "patch",
2134
- patchId: patch.parentPatchId
2135
- },
2136
1597
  path: patch.path,
2137
1598
  authorId: patch.authorId,
2138
1599
  sessionId: null,
@@ -2142,14 +1603,13 @@ function toFsPatch(patch) {
2142
1603
  };
2143
1604
  }
2144
1605
  async function writeBinaryFiles(ctx, fileLastUpdatedByPatchId, patches, entries) {
2145
- const parentByPatchId = new Map(patches.map(p => [p.patchId, p.parentPatchId ?? "head"]));
1606
+ const known = new Set(patches.map(p => p.patchId));
2146
1607
  let wrote = false;
2147
1608
  for (const [filePath, data] of Object.entries(fileLastUpdatedByPatchId)) {
2148
1609
  if (data.isDelete) {
2149
1610
  continue;
2150
1611
  }
2151
- const parentPatchId = parentByPatchId.get(data.patchId);
2152
- if (parentPatchId === undefined) {
1612
+ if (!known.has(data.patchId)) {
2153
1613
  continue;
2154
1614
  }
2155
1615
  const buffer = await ctx.serverOps.getBase64EncodedBinaryFileFromPatch(filePath, data.patchId, data.remote);
@@ -2157,7 +1617,7 @@ async function writeBinaryFiles(ctx, fileLastUpdatedByPatchId, patches, entries)
2157
1617
  continue;
2158
1618
  }
2159
1619
  // Base64 so the snapshot stays a text-only entry map; the replay decodes it.
2160
- entries[`.val/patches/${parentPatchId}/files${filePath}/${path__default["default"].posix.basename(filePath)}.base64`] = buffer.toString("base64");
1620
+ entries[`.val/patches/${data.patchId}/files${filePath}/${path__default["default"].posix.basename(filePath)}.base64`] = buffer.toString("base64");
2161
1621
  wrote = true;
2162
1622
  }
2163
1623
  return wrote;