@valbuild/server 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.
@@ -4,8 +4,8 @@ Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var ts = require('typescript');
6
6
  var fp = require('@valbuild/core/fp');
7
- var core = require('@valbuild/core');
8
7
  var patch = require('@valbuild/core/patch');
8
+ var core = require('@valbuild/core');
9
9
  var path = require('path');
10
10
  var fs = require('fs');
11
11
  var vm = require('node:vm');
@@ -16,6 +16,7 @@ var crypto$1 = require('crypto');
16
16
  var z = require('zod');
17
17
  var sizeOf = require('image-size');
18
18
  var zodValidationError = require('zod-validation-error');
19
+ var os = require('os');
19
20
  var sucrase = require('sucrase');
20
21
  var http = require('http');
21
22
  var https = require('https');
@@ -48,6 +49,7 @@ var vm__default = /*#__PURE__*/_interopDefault(vm);
48
49
  var crypto__default = /*#__PURE__*/_interopDefault(crypto$1);
49
50
  var z__default = /*#__PURE__*/_interopDefault(z);
50
51
  var sizeOf__default = /*#__PURE__*/_interopDefault(sizeOf);
52
+ var os__default = /*#__PURE__*/_interopDefault(os);
51
53
  var http__default = /*#__PURE__*/_interopDefault(http);
52
54
  var https__default = /*#__PURE__*/_interopDefault(https);
53
55
  var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
@@ -102,7 +104,7 @@ function validateObjectProperties(nodes) {
102
104
  * validating its children.
103
105
  */
104
106
  function shallowValidateExpression(value) {
105
- return ts__default["default"].isStringLiteralLike(value) || ts__default["default"].isNumericLiteral(value) || value.kind === ts__default["default"].SyntaxKind.TrueKeyword || value.kind === ts__default["default"].SyntaxKind.FalseKeyword || value.kind === ts__default["default"].SyntaxKind.NullKeyword || ts__default["default"].isArrayLiteralExpression(value) || ts__default["default"].isObjectLiteralExpression(value) || isValFileMethodCall(value) || isValImageMethodCall(value) || isValRemoteMethodCall(value) ? undefined : new ValSyntaxError("Expression must be a literal or call c.file / c.image / c.remote", value);
107
+ return ts__default["default"].isStringLiteralLike(value) || ts__default["default"].isNumericLiteral(value) || value.kind === ts__default["default"].SyntaxKind.TrueKeyword || value.kind === ts__default["default"].SyntaxKind.FalseKeyword || value.kind === ts__default["default"].SyntaxKind.NullKeyword || ts__default["default"].isArrayLiteralExpression(value) || ts__default["default"].isObjectLiteralExpression(value) ? undefined : new ValSyntaxError("Expression must be a literal", value);
106
108
  }
107
109
 
108
110
  /**
@@ -128,55 +130,8 @@ function evaluateExpression(value) {
128
130
  return fp.result.all(value.elements.map(evaluateExpression));
129
131
  } else if (ts__default["default"].isObjectLiteralExpression(value)) {
130
132
  return fp.pipe(validateObjectProperties(value.properties), fp.result.flatMap(assignments => fp.pipe(assignments.map(assignment => fp.pipe(evaluateExpression(assignment.initializer), fp.result.map(value => [assignment.name.text, value]))), fp.result.all)), fp.result.map(Object.fromEntries));
131
- } else if (isValFileMethodCall(value)) {
132
- return fp.pipe(findValFileNodeArg(value), fp.result.flatMap(ref => {
133
- if (value.arguments.length === 2) {
134
- return fp.pipe(evaluateExpression(value.arguments[1]), fp.result.map(metadata => ({
135
- [core.FILE_REF_PROP]: ref.text,
136
- _type: "file",
137
- metadata
138
- })));
139
- } else {
140
- return fp.result.ok({
141
- [core.FILE_REF_PROP]: ref.text,
142
- _type: "file"
143
- });
144
- }
145
- }));
146
- } else if (isValImageMethodCall(value)) {
147
- return fp.pipe(findValImageNodeArg(value), fp.result.flatMap(ref => {
148
- if (value.arguments.length === 2) {
149
- return fp.pipe(evaluateExpression(value.arguments[1]), fp.result.map(metadata => ({
150
- [core.FILE_REF_PROP]: ref.text,
151
- _type: "file",
152
- [core.FILE_REF_SUBTYPE_TAG]: "image",
153
- metadata
154
- })));
155
- } else {
156
- return fp.result.ok({
157
- [core.FILE_REF_PROP]: ref.text,
158
- _type: "file",
159
- [core.FILE_REF_SUBTYPE_TAG]: "image"
160
- });
161
- }
162
- }));
163
- } else if (isValRemoteMethodCall(value)) {
164
- return fp.pipe(findValRemoteNodeArg(value), fp.result.flatMap(ref => {
165
- if (value.arguments.length === 2) {
166
- return fp.pipe(evaluateExpression(value.arguments[1]), fp.result.map(metadata => ({
167
- [core.FILE_REF_PROP]: ref.text,
168
- _type: "remote",
169
- metadata
170
- })));
171
- } else {
172
- return fp.result.ok({
173
- [core.FILE_REF_PROP]: ref.text,
174
- _type: "remote"
175
- });
176
- }
177
- }));
178
133
  } else {
179
- return fp.result.err(new ValSyntaxError("Expression must be a literal or call c.file / c.image / c.remote", value));
134
+ return fp.result.err(new ValSyntaxError("Expression must be a literal", value));
180
135
  }
181
136
  }
182
137
  function findObjectPropertyAssignment(value, key) {
@@ -190,93 +145,6 @@ function findObjectPropertyAssignment(value, key) {
190
145
  return fp.result.ok(assignment);
191
146
  }));
192
147
  }
193
- function isValFileMethodCall(node) {
194
- return ts__default["default"].isCallExpression(node) && ts__default["default"].isPropertyAccessExpression(node.expression) && ts__default["default"].isIdentifier(node.expression.expression) && node.expression.expression.text === "c" && node.expression.name.text === "file";
195
- }
196
- function isValImageMethodCall(node) {
197
- return ts__default["default"].isCallExpression(node) && ts__default["default"].isPropertyAccessExpression(node.expression) && ts__default["default"].isIdentifier(node.expression.expression) && node.expression.expression.text === "c" && node.expression.name.text === "image";
198
- }
199
- function isValRemoteMethodCall(node) {
200
- return ts__default["default"].isCallExpression(node) && ts__default["default"].isPropertyAccessExpression(node.expression) && ts__default["default"].isIdentifier(node.expression.expression) && node.expression.expression.text === "c" && node.expression.name.text === "remote";
201
- }
202
- function findValFileNodeArg(node) {
203
- if (node.arguments.length === 0) {
204
- return fp.result.err(new ValSyntaxError(`Invalid c.file() call: missing ref argument`, node));
205
- } else if (node.arguments.length > 2) {
206
- return fp.result.err(new ValSyntaxError(`Invalid c.file() call: too many arguments ${node.arguments.length}}`, node));
207
- } else if (!ts__default["default"].isStringLiteral(node.arguments[0])) {
208
- return fp.result.err(new ValSyntaxError(`Invalid c.file() call: ref must be a string literal`, node));
209
- }
210
- const refNode = node.arguments[0];
211
- return fp.result.ok(refNode);
212
- }
213
- function findValRemoteNodeArg(node) {
214
- if (node.arguments.length === 0) {
215
- return fp.result.err(new ValSyntaxError(`Invalid c.remote() call: missing ref argument`, node));
216
- } else if (node.arguments.length > 2) {
217
- return fp.result.err(new ValSyntaxError(`Invalid c.remote() call: too many arguments ${node.arguments.length}}`, node));
218
- } else if (!ts__default["default"].isStringLiteral(node.arguments[0])) {
219
- return fp.result.err(new ValSyntaxError(`Invalid c.remote() call: ref must be a string literal`, node));
220
- }
221
- const refNode = node.arguments[0];
222
- return fp.result.ok(refNode);
223
- }
224
- function findValImageNodeArg(node) {
225
- if (node.arguments.length === 0) {
226
- return fp.result.err(new ValSyntaxError(`Invalid c.image() call: missing ref argument`, node));
227
- } else if (node.arguments.length > 2) {
228
- return fp.result.err(new ValSyntaxError(`Invalid c.image() call: too many arguments ${node.arguments.length}}`, node));
229
- } else if (!ts__default["default"].isStringLiteral(node.arguments[0])) {
230
- return fp.result.err(new ValSyntaxError(`Invalid c.image() call: ref must be a string literal`, node));
231
- }
232
- const refNode = node.arguments[0];
233
- return fp.result.ok(refNode);
234
- }
235
- function findValFileMetadataArg(node) {
236
- if (node.arguments.length === 0) {
237
- return fp.result.err(new ValSyntaxError(`Invalid c.file() call: missing ref argument`, node));
238
- } else if (node.arguments.length > 2) {
239
- return fp.result.err(new ValSyntaxError(`Invalid c.file() call: too many arguments ${node.arguments.length}}`, node));
240
- } else if (node.arguments.length === 2) {
241
- if (!ts__default["default"].isObjectLiteralExpression(node.arguments[1])) {
242
- return fp.result.err(new ValSyntaxError(`Invalid c.file() call: metadata must be a object literal`, node));
243
- }
244
- return fp.result.ok(node.arguments[1]);
245
- }
246
- return fp.result.ok(undefined);
247
- }
248
- function findValImageMetadataArg(node) {
249
- if (node.arguments.length === 0) {
250
- return fp.result.err(new ValSyntaxError(`Invalid c.image() call: missing ref argument`, node));
251
- } else if (node.arguments.length > 2) {
252
- return fp.result.err(new ValSyntaxError(`Invalid c.image() call: too many arguments ${node.arguments.length}}`, node));
253
- } else if (node.arguments.length === 2) {
254
- if (!ts__default["default"].isObjectLiteralExpression(node.arguments[1])) {
255
- return fp.result.err(new ValSyntaxError(`Invalid c.image() call: metadata must be a object literal`, node));
256
- }
257
- return fp.result.ok(node.arguments[1]);
258
- }
259
- return fp.result.ok(undefined);
260
- }
261
- function findValRemoteMetadataArg(node) {
262
- if (node.arguments.length === 0) {
263
- return fp.result.err(new ValSyntaxError(`Invalid c.remote() call: missing ref argument`, node));
264
- } else if (node.arguments.length > 2) {
265
- return fp.result.err(new ValSyntaxError(`Invalid c.remote() call: too many arguments ${node.arguments.length}}`, node));
266
- } else if (node.arguments.length === 2) {
267
- if (!ts__default["default"].isObjectLiteralExpression(node.arguments[1])) {
268
- return fp.result.err(new ValSyntaxError(`Invalid c.remote() call: metadata must be a object literal`, node));
269
- }
270
- return fp.result.ok(node.arguments[1]);
271
- }
272
- return fp.result.ok(undefined);
273
- }
274
-
275
- /**
276
- * Given a list of expressions, validates that all the expressions are not
277
- * spread elements. In other words, it ensures that the expressions are the
278
- * initializers of the values at their respective indices in the evaluated list.
279
- */
280
148
  function validateInitializers(nodes) {
281
149
  for (const node of nodes) {
282
150
  if (ts__default["default"].isSpreadElement(node)) {
@@ -358,27 +226,6 @@ function isValidIdentifier(text) {
358
226
  function createPropertyAssignment(key, value) {
359
227
  return ts__default["default"].factory.createPropertyAssignment(isValidIdentifier(key) ? ts__default["default"].factory.createIdentifier(key) : ts__default["default"].factory.createStringLiteral(key), toExpression(value));
360
228
  }
361
- function createValFileReference(value) {
362
- const args = [ts__default["default"].factory.createStringLiteral(value[core.FILE_REF_PROP])];
363
- if (value.metadata) {
364
- args.push(toExpression(value.metadata));
365
- }
366
- return ts__default["default"].factory.createCallExpression(ts__default["default"].factory.createPropertyAccessExpression(ts__default["default"].factory.createIdentifier("c"), ts__default["default"].factory.createIdentifier("file")), undefined, args);
367
- }
368
- function createValImageReference(value) {
369
- const args = [ts__default["default"].factory.createStringLiteral(value[core.FILE_REF_PROP])];
370
- if (value.metadata) {
371
- args.push(toExpression(value.metadata));
372
- }
373
- return ts__default["default"].factory.createCallExpression(ts__default["default"].factory.createPropertyAccessExpression(ts__default["default"].factory.createIdentifier("c"), ts__default["default"].factory.createIdentifier("image")), undefined, args);
374
- }
375
- function createValRemoteReference(value) {
376
- const args = [ts__default["default"].factory.createStringLiteral(value[core.FILE_REF_PROP])];
377
- if (value.metadata) {
378
- args.push(toExpression(value.metadata));
379
- }
380
- return ts__default["default"].factory.createCallExpression(ts__default["default"].factory.createPropertyAccessExpression(ts__default["default"].factory.createIdentifier("c"), ts__default["default"].factory.createIdentifier("remote")), undefined, args);
381
- }
382
229
 
383
230
  /**
384
231
  * Builds the expression `c.json(() => import("<importPath>"))` used to reference
@@ -464,16 +311,12 @@ function toExpression(value) {
464
311
  } else if (Array.isArray(value)) {
465
312
  return ts__default["default"].factory.createArrayLiteralExpression(value.map(toExpression));
466
313
  } else if (typeof value === "object") {
467
- if (isValFileValue(value)) {
468
- return createValFileReference(value);
469
- }
470
- if (isValImageValue(value)) {
471
- return createValImageReference(value);
472
- }
473
- if (isValRemoteValue(value)) {
474
- return createValRemoteReference(value);
475
- }
476
- return ts__default["default"].factory.createObjectLiteralExpression(Object.entries(value).map(([key, value]) => createPropertyAssignment(key, value)));
314
+ return ts__default["default"].factory.createObjectLiteralExpression(Object.entries(value)
315
+ // `patch_id` is injected server-side onto a media source whose bytes are
316
+ // not committed yet. It describes the draft, not the content, and a
317
+ // whole-object write built from the client's optimistic view would
318
+ // otherwise print it into the user's file.
319
+ .filter(([key]) => key !== "patch_id").map(([key, value]) => createPropertyAssignment(key, value)));
477
320
  } else {
478
321
  return ts__default["default"].factory.createStringLiteral(value);
479
322
  }
@@ -606,63 +449,6 @@ function replaceInNode(document, node, key, value) {
606
449
  }
607
450
  return fp.result.ok(assignment);
608
451
  }), fp.result.map(assignment => replaceNodeValue(document, assignment.initializer, value)));
609
- } else if (isValFileMethodCall(node)) {
610
- if (key === core.FILE_REF_PROP) {
611
- if (typeof value !== "string") {
612
- return fp.result.err(new patch.PatchError("Cannot replace c.file reference with non-string value"));
613
- }
614
- return fp.pipe(findValFileNodeArg(node), fp.result.map(refNode => replaceNodeValue(document, refNode, value)));
615
- } else {
616
- return fp.pipe(findValFileMetadataArg(node), fp.result.flatMap(metadataArgNode => {
617
- if (!metadataArgNode) {
618
- return fp.result.err(new patch.PatchError("Cannot replace in c.file metadata when it does not exist"));
619
- }
620
- if (key !== "metadata") {
621
- return fp.result.err(new patch.PatchError(`Cannot replace c.file metadata key ${key} when it does not exist`));
622
- }
623
- return replaceInNode(document,
624
- // TODO: creating a fake object here might not be right - seems to work though
625
- ts__default["default"].factory.createObjectLiteralExpression([ts__default["default"].factory.createPropertyAssignment(key, metadataArgNode)]), key, value);
626
- }));
627
- }
628
- } else if (isValImageMethodCall(node)) {
629
- if (key === core.FILE_REF_PROP) {
630
- if (typeof value !== "string") {
631
- return fp.result.err(new patch.PatchError("Cannot replace c.image reference with non-string value"));
632
- }
633
- return fp.pipe(findValImageNodeArg(node), fp.result.map(refNode => replaceNodeValue(document, refNode, value)));
634
- } else {
635
- return fp.pipe(findValImageMetadataArg(node), fp.result.flatMap(metadataArgNode => {
636
- if (!metadataArgNode) {
637
- return fp.result.err(new patch.PatchError("Cannot replace in c.image metadata when it does not exist"));
638
- }
639
- if (key !== "metadata") {
640
- return fp.result.err(new patch.PatchError(`Cannot replace c.image metadata key ${key} when it does not exist`));
641
- }
642
- return replaceInNode(document,
643
- // TODO: creating a fake object here might not be right - seems to work though
644
- ts__default["default"].factory.createObjectLiteralExpression([ts__default["default"].factory.createPropertyAssignment(key, metadataArgNode)]), key, value);
645
- }));
646
- }
647
- } else if (isValRemoteMethodCall(node)) {
648
- if (key === core.FILE_REF_PROP) {
649
- if (typeof value !== "string") {
650
- return fp.result.err(new patch.PatchError("Cannot replace c.image reference with non-string value"));
651
- }
652
- return fp.pipe(findValRemoteNodeArg(node), fp.result.map(refNode => replaceNodeValue(document, refNode, value)));
653
- } else {
654
- return fp.pipe(findValRemoteMetadataArg(node), fp.result.flatMap(metadataArgNode => {
655
- if (!metadataArgNode) {
656
- return fp.result.err(new patch.PatchError("Cannot replace in c.remote metadata when it does not exist"));
657
- }
658
- if (key !== "metadata") {
659
- return fp.result.err(new patch.PatchError(`Cannot replace c.remote metadata key ${key} when it does not exist`));
660
- }
661
- return replaceInNode(document,
662
- // TODO: creating a fake object here might not be right - seems to work though
663
- ts__default["default"].factory.createObjectLiteralExpression([ts__default["default"].factory.createPropertyAssignment(key, metadataArgNode)]), key, value);
664
- }));
665
- }
666
452
  } else {
667
453
  return fp.result.err(shallowValidateExpression(node) ?? new patch.PatchError("Cannot replace in non-object/array"));
668
454
  }
@@ -679,21 +465,6 @@ function getFromNode(node, key) {
679
465
  return fp.pipe(parseAndValidateArrayInboundsIndex(key, node.elements), fp.result.map(index => node.elements[index]));
680
466
  } else if (ts__default["default"].isObjectLiteralExpression(node)) {
681
467
  return fp.pipe(findObjectPropertyAssignment(node, key), fp.result.map(assignment => assignment === null || assignment === void 0 ? void 0 : assignment.initializer));
682
- } else if (isValFileMethodCall(node)) {
683
- if (key === core.FILE_REF_PROP) {
684
- return findValFileNodeArg(node);
685
- }
686
- return findValFileMetadataArg(node);
687
- } else if (isValImageMethodCall(node)) {
688
- if (key === core.FILE_REF_PROP) {
689
- return findValImageNodeArg(node);
690
- }
691
- return findValImageMetadataArg(node);
692
- } else if (isValRemoteMethodCall(node)) {
693
- if (key === core.FILE_REF_PROP) {
694
- return findValRemoteNodeArg(node);
695
- }
696
- return findValRemoteMetadataArg(node);
697
468
  } else {
698
469
  return fp.result.err(shallowValidateExpression(node) ?? new patch.PatchError("Cannot access non-object/array"));
699
470
  }
@@ -726,39 +497,6 @@ function removeFromNode(document, node, key) {
726
497
  }
727
498
  return fp.result.ok(assignment);
728
499
  }), fp.result.map(assignment => [removeAt(document, node.properties, node.properties.indexOf(assignment))[0], assignment.initializer]));
729
- } else if (isValFileMethodCall(node)) {
730
- if (key === core.FILE_REF_PROP) {
731
- return fp.result.err(new patch.PatchError("Cannot remove a ref from c.file"));
732
- } else {
733
- return fp.pipe(findValFileMetadataArg(node), fp.result.flatMap(metadataArgNode => {
734
- if (!metadataArgNode) {
735
- return fp.result.err(new patch.PatchError("Cannot remove from c.file metadata when it does not exist"));
736
- }
737
- return removeFromNode(document, metadataArgNode, key);
738
- }));
739
- }
740
- } else if (isValImageMethodCall(node)) {
741
- if (key === core.FILE_REF_PROP) {
742
- return fp.result.err(new patch.PatchError("Cannot remove a ref from c.image"));
743
- } else {
744
- return fp.pipe(findValImageMetadataArg(node), fp.result.flatMap(metadataArgNode => {
745
- if (!metadataArgNode) {
746
- return fp.result.err(new patch.PatchError("Cannot remove from c.image metadata when it does not exist"));
747
- }
748
- return removeFromNode(document, metadataArgNode, key);
749
- }));
750
- }
751
- } else if (isValRemoteMethodCall(node)) {
752
- if (key === core.FILE_REF_PROP) {
753
- return fp.result.err(new patch.PatchError("Cannot remove a ref from c.remote"));
754
- } else {
755
- return fp.pipe(findValRemoteMetadataArg(node), fp.result.flatMap(metadataArgNode => {
756
- if (!metadataArgNode) {
757
- return fp.result.err(new patch.PatchError("Cannot remove from c.remote metadata when it does not exist"));
758
- }
759
- return removeFromNode(document, metadataArgNode, key);
760
- }));
761
- }
762
500
  } else {
763
501
  return fp.result.err(shallowValidateExpression(node) ?? new patch.PatchError("Cannot remove from non-object/array"));
764
502
  }
@@ -766,22 +504,10 @@ function removeFromNode(document, node, key) {
766
504
  function removeAtPath(document, rootNode, path) {
767
505
  return fp.pipe(getPointerFromPath(rootNode, path), fp.result.flatMap(([node, key]) => removeFromNode(document, node, key)));
768
506
  }
769
- function isValFileValue(value) {
770
- return !!(typeof value === "object" && value && core.VAL_EXTENSION in value && value[core.VAL_EXTENSION] === "file" && core.FILE_REF_PROP in value && typeof value[core.FILE_REF_PROP] === "string" && value[core.FILE_REF_SUBTYPE_TAG] !== "image");
771
- }
772
- function isValImageValue(value) {
773
- return !!(typeof value === "object" && value && core.VAL_EXTENSION in value && value[core.VAL_EXTENSION] === "file" && core.FILE_REF_PROP in value && typeof value[core.FILE_REF_PROP] === "string" && value[core.FILE_REF_SUBTYPE_TAG] === "image");
774
- }
775
- function isValRemoteValue(value) {
776
- return !!(typeof value === "object" && value && core.VAL_EXTENSION in value && value[core.VAL_EXTENSION] === "remote" && core.FILE_REF_PROP in value && typeof value[core.FILE_REF_PROP] === "string");
777
- }
778
507
  function addToNode(document, node, key, value) {
779
508
  if (ts__default["default"].isArrayLiteralExpression(node)) {
780
509
  return fp.pipe(parseAndValidateArrayInsertIndex(key, node.elements), fp.result.map(index => [insertAt(document, node.elements, index, toExpression(value))]));
781
510
  } else if (ts__default["default"].isObjectLiteralExpression(node)) {
782
- if (key === core.FILE_REF_PROP) {
783
- return fp.result.err(new patch.PatchError("Cannot add a key ref to object"));
784
- }
785
511
  return fp.pipe(findObjectPropertyAssignment(node, key), fp.result.map(assignment => {
786
512
  if (!assignment) {
787
513
  return [insertAt(document, node.properties, node.properties.length, createPropertyAssignment(key, value))];
@@ -789,57 +515,6 @@ function addToNode(document, node, key, value) {
789
515
  return replaceNodeValue(document, assignment.initializer, value);
790
516
  }
791
517
  }));
792
- } else if (isValFileMethodCall(node)) {
793
- if (key === core.FILE_REF_PROP) {
794
- if (typeof value !== "string") {
795
- return fp.result.err(new patch.PatchError(`Cannot add ${core.FILE_REF_PROP} key to c.file with non-string value`));
796
- }
797
- return fp.pipe(findValFileNodeArg(node), fp.result.map(arg => replaceNodeValue(document, arg, value)));
798
- } else {
799
- return fp.pipe(findValFileMetadataArg(node), fp.result.flatMap(metadataArgNode => {
800
- if (metadataArgNode) {
801
- return fp.result.err(new patch.PatchError("Cannot add metadata to c.file when it already exists"));
802
- }
803
- if (key !== "metadata") {
804
- return fp.result.err(new patch.PatchError(`Cannot add ${key} key to c.file: only metadata is allowed`));
805
- }
806
- return fp.result.ok([insertAt(document, node.arguments, node.arguments.length, toExpression(value))]);
807
- }));
808
- }
809
- } else if (isValImageMethodCall(node)) {
810
- if (key === core.FILE_REF_PROP) {
811
- if (typeof value !== "string") {
812
- return fp.result.err(new patch.PatchError(`Cannot add ${core.FILE_REF_PROP} key to c.image with non-string value`));
813
- }
814
- return fp.pipe(findValImageNodeArg(node), fp.result.map(arg => replaceNodeValue(document, arg, value)));
815
- } else {
816
- return fp.pipe(findValImageMetadataArg(node), fp.result.flatMap(metadataArgNode => {
817
- if (metadataArgNode) {
818
- return fp.result.err(new patch.PatchError("Cannot add metadata to c.image when it already exists"));
819
- }
820
- if (key !== "metadata") {
821
- return fp.result.err(new patch.PatchError(`Cannot add ${key} key to c.image: only metadata is allowed`));
822
- }
823
- return fp.result.ok([insertAt(document, node.arguments, node.arguments.length, toExpression(value))]);
824
- }));
825
- }
826
- } else if (isValRemoteMethodCall(node)) {
827
- if (key === core.FILE_REF_PROP) {
828
- if (typeof value !== "string") {
829
- return fp.result.err(new patch.PatchError(`Cannot add ${core.FILE_REF_PROP} key to c.remote with non-string value`));
830
- }
831
- return fp.pipe(findValRemoteNodeArg(node), fp.result.map(arg => replaceNodeValue(document, arg, value)));
832
- } else {
833
- return fp.pipe(findValRemoteMetadataArg(node), fp.result.flatMap(metadataArgNode => {
834
- if (metadataArgNode) {
835
- return fp.result.err(new patch.PatchError("Cannot add metadata to c.remote when it already exists"));
836
- }
837
- if (key !== "metadata") {
838
- return fp.result.err(new patch.PatchError(`Cannot add ${key} key to c.remote: only metadata is allowed`));
839
- }
840
- return fp.result.ok([insertAt(document, node.arguments, node.arguments.length, toExpression(value))]);
841
- }));
842
- }
843
518
  } else {
844
519
  return fp.result.err(shallowValidateExpression(node) ?? new patch.PatchError("Cannot add to non-object/array"));
845
520
  }
@@ -1421,7 +1096,10 @@ function rebaseContentOp(op, prefixLen) {
1421
1096
  from: op.from.slice(prefixLen)
1422
1097
  });
1423
1098
  case "file":
1424
- return fp.result.err(new patch.PatchError("Cannot apply a file op to a jsonValues entry"));
1099
+ // A file op carries bytes, not content. The binary goes through the
1100
+ // normal upload pipeline; what lands in the entry is the `patch_id` that
1101
+ // marks the bytes as not yet committed, which the caller writes.
1102
+ return fp.result.err(new patch.PatchError("A file op is not a content op: rebase it via its path"));
1425
1103
  }
1426
1104
  }
1427
1105
 
@@ -1455,9 +1133,6 @@ function applyJsonValuesEntryPatches(args) {
1455
1133
  } of patches) {
1456
1134
  let touched = false;
1457
1135
  for (const op of patch$1) {
1458
- if (op.op === "file") {
1459
- continue;
1460
- }
1461
1136
  const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1462
1137
  kind: "normal"
1463
1138
  };
@@ -1465,6 +1140,32 @@ function applyJsonValuesEntryPatches(args) {
1465
1140
  continue;
1466
1141
  }
1467
1142
  touched = true;
1143
+ // A file op does not edit the entry's content — its bytes go through the
1144
+ // upload pipeline. What the entry needs is the `patch_id` saying those
1145
+ // bytes are not committed yet, so `mediaUrl` serves them from the patch
1146
+ // directory instead of a `/public` path that holds nothing. Without this a
1147
+ // just-uploaded image inside an entry renders broken.
1148
+ if (op.op === "file") {
1149
+ if (op.value === null || content === undefined) {
1150
+ // A delete carries no bytes to point at, and there is nothing to mark
1151
+ // on an entry that does not exist.
1152
+ continue;
1153
+ }
1154
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$1, [{
1155
+ op: "add",
1156
+ path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1157
+ value: patchId
1158
+ }]);
1159
+ if (fp.result.isErr(applied)) {
1160
+ return {
1161
+ kind: "error",
1162
+ message: applied.error.message,
1163
+ patchId
1164
+ };
1165
+ }
1166
+ content = applied.value;
1167
+ continue;
1168
+ }
1468
1169
  if (cls.subPath.length === 0) {
1469
1170
  if (op.op === "add" || op.op === "replace") {
1470
1171
  content = op.value;
@@ -2437,6 +2138,17 @@ class ValOps {
2437
2138
  const serializedSchema = jsonValuesSchemaFor(path);
2438
2139
  for (const op of patchData.patch) {
2439
2140
  if (op.op === "file") {
2141
+ // A file op inside a `.jsonValues()` entry has nothing to inject
2142
+ // HERE: the entry is an opaque marker in the module source, so an
2143
+ // `add` reaching into it fails and poisons the rest of this
2144
+ // module's chain. `applyJsonValuesEntryPatches` writes the patch_id
2145
+ // into the entry's draft content instead.
2146
+ const fileCls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
2147
+ kind: "normal"
2148
+ };
2149
+ if (fileCls.kind === "entry" && fileCls.subPath.length > 0) {
2150
+ continue;
2151
+ }
2440
2152
  if (op.value !== null) {
2441
2153
  // NOTE: We insert the last patch_id that modify a file
2442
2154
  // when constructing the url we use the patch id (and the file path)
@@ -2737,7 +2449,7 @@ class ValOps {
2737
2449
  };
2738
2450
  }
2739
2451
  const type = schemaAtPath instanceof core.ImageSchema ? "image" : "file";
2740
- const filePath = value[core.FILE_REF_PROP];
2452
+ const filePath = value.path;
2741
2453
  const fileData = (fileLastUpdatedByPatchId === null || fileLastUpdatedByPatchId === void 0 ? void 0 : fileLastUpdatedByPatchId[filePath]) || null;
2742
2454
  let metadata;
2743
2455
  let metadataErrors;
@@ -2779,23 +2491,14 @@ class ValOps {
2779
2491
  }]
2780
2492
  };
2781
2493
  }
2782
- const metadataSourcePath = core.Internal.createValPathOfItem(sourcePath, "metadata");
2783
- if (!metadataSourcePath) {
2784
- throw new Error("Could not create metadata path");
2785
- }
2786
- const currentValueMetadata = value.metadata;
2787
- if (!currentValueMetadata) {
2788
- return {
2789
- [metadataSourcePath]: [{
2790
- message: "Missing metadata field: 'metadata'",
2791
- value
2792
- }]
2793
- };
2794
- }
2494
+ // The fields Val computes from the bytes sit next to `path` now, so the
2495
+ // error is reported at the field it is about rather than at a `metadata`
2496
+ // object that no longer exists.
2497
+ const currentValueMetadata = value;
2795
2498
  const fieldErrors = {};
2796
2499
  for (const field of getFieldsForType(type)) {
2797
2500
  const fieldMetadata = metadata[field];
2798
- const fieldSourcePath = core.Internal.createValPathOfItem(metadataSourcePath, field);
2501
+ const fieldSourcePath = core.Internal.createValPathOfItem(sourcePath, field);
2799
2502
  if (!fieldSourcePath) {
2800
2503
  throw new Error("Could not create field path");
2801
2504
  }
@@ -3387,11 +3090,15 @@ function isOnlyFileCheckValidationError(validationError) {
3387
3090
  }
3388
3091
  return false;
3389
3092
  }
3093
+
3094
+ /**
3095
+ * Whether a flagged validation value is media.
3096
+ *
3097
+ * The caller already knows the schema said image or file — this only guards
3098
+ * against a value that never got that far.
3099
+ */
3390
3100
  function isFileSource(value) {
3391
- if (typeof value === "object" && value !== null && core.FILE_REF_PROP in value && core.VAL_EXTENSION in value && value[core.VAL_EXTENSION] === "file" && core.FILE_REF_PROP) {
3392
- return true;
3393
- }
3394
- return false;
3101
+ return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
3395
3102
  }
3396
3103
  function formatPatchSourceError(error) {
3397
3104
  if ("message" in error) {
@@ -3596,119 +3303,1114 @@ class JsonEntryFilesFingerprint {
3596
3303
  }
3597
3304
 
3598
3305
  /**
3599
- * Computes the changed patch parent references based on the current patches and the patch IDs to be deleted.
3306
+ * The order of the pending patches, held in one flat, append-only text file.
3307
+ *
3308
+ * ## Why a log and not links between the patches
3600
3309
  *
3601
- * NOTE: patches that will be deleted are not included in the changed patches, since they will be deleted any how.
3310
+ * The store this replaces kept the order in the patches themselves: every record
3311
+ * carried a `parentRef`, and the directory a record lived in was named after that
3312
+ * parent. Reading the chain meant walking those links from `head`, and the walk
3313
+ * stopped dead — silently — at the first id nothing on disk answered for.
3602
3314
  *
3603
- * @param currentPatches - The array of current patches.
3604
- * @param deletePatchIds - The array of patch IDs to be deleted.
3605
- * @returns An object containing the changed patches with their corresponding parent references.
3315
+ * That is not a hypothetical. A store with 410 patches lost exactly one record,
3316
+ * and the 51 patches written after it became unreachable: `/stat` counted the
3317
+ * directories and announced 410, `GET /patches` walked the links and delivered
3318
+ * 359, and the studio waited forever for 51 ids that no longer had a path back to
3319
+ * `head`. One missing file cost every edit made after it.
3320
+ *
3321
+ * So the order lives in one place and the patches reference nothing. A patch
3322
+ * directory is self-contained data; this file says what order the directories go
3323
+ * in. Removing a line cannot orphan the lines below it, because there is nothing
3324
+ * below it to orphan — the entry after a dropped one is simply next.
3325
+ *
3326
+ * ## Why text
3327
+ *
3328
+ * It is read by people during exactly the incidents that make it interesting, and
3329
+ * `cat` should be enough. One entry per line, whitespace-separated, id first:
3330
+ *
3331
+ * ```
3332
+ * val-patch-log v1
3333
+ * 659b8cfa-065c-47d1-8d82-fc69a2ac72a9 2026-08-27T11:46:13.730Z /content/authors.val.ts
3334
+ * ```
3335
+ *
3336
+ * Position in the file IS the order. There is deliberately no sequence number:
3337
+ * a number stored beside the thing it describes is a number that can disagree
3338
+ * with it, and then something has to decide which one lies.
3339
+ *
3340
+ * The timestamp and path are there to make the file readable, not to be believed
3341
+ * — `patch.json` owns those fields. Nothing here is a second copy of state that
3342
+ * anything reads back.
3606
3343
  */
3607
- function computeChangedPatchParentRefs(currentPatches, deletePatchIds) {
3608
- let lastNonDeletedPatchIndex = -1;
3609
- const changedPatches = {};
3610
- for (let i = 0; i < currentPatches.length; i++) {
3611
- const current = currentPatches[i];
3612
- if (
3613
- // skip all patches that will be deleted:
3614
- deletePatchIds.includes(current.patchId)) {
3615
- var _currentPatches;
3616
- if (
3617
- // skip change if the patch after is deleted anyway:
3618
- !deletePatchIds.includes((_currentPatches = currentPatches[i + 1]) === null || _currentPatches === void 0 ? void 0 : _currentPatches.patchId)) {
3619
- if (
3620
- // set next patch to point to head if it exists:
3621
- lastNonDeletedPatchIndex === -1 && currentPatches[i + 1]) {
3622
- changedPatches[currentPatches[i + 1].patchId] = {
3623
- type: "head",
3624
- headBaseSha: current.baseSha
3625
- };
3626
- } else if (
3627
- // set next patch to point to the last non-deleted patch:
3628
- currentPatches[lastNonDeletedPatchIndex] && currentPatches[i + 1]) {
3629
- changedPatches[currentPatches[i + 1].patchId] = {
3630
- type: "patch",
3631
- patchId: currentPatches[lastNonDeletedPatchIndex].patchId
3632
- };
3633
- }
3634
- }
3635
- } else {
3636
- lastNonDeletedPatchIndex = i;
3637
- }
3344
+
3345
+ const PATCH_LOG_FILE_NAME = "patches.log";
3346
+ const HEADER = "val-patch-log v1";
3347
+
3348
+ /**
3349
+ * Something that is wrong with the file but does not stop it being read.
3350
+ *
3351
+ * Reported rather than thrown, because a log that is 99% intact is worth reading
3352
+ * and then repairing. Only a file that cannot be understood at all is fatal, and
3353
+ * that is a `status` on the read result, not a problem in this list.
3354
+ */
3355
+
3356
+ function isModuleFilePath(path) {
3357
+ return path.startsWith("/") && path.includes(".val.");
3358
+ }
3359
+ function isPatchId(patchId) {
3360
+ return patchId.length > 0 && !/\s/.test(patchId);
3361
+ }
3362
+ function formatPatchLogLine(entry) {
3363
+ return `${entry.patchId} ${entry.createdAt} ${entry.path}`;
3364
+ }
3365
+
3366
+ /**
3367
+ * Split on the first two runs of whitespace only: a module file path may contain
3368
+ * spaces, and it is last precisely so that it can.
3369
+ */
3370
+ function parsePatchLogLine(line) {
3371
+ const trimmed = line.trim();
3372
+ if (trimmed === "") {
3373
+ return null;
3374
+ }
3375
+ const match = /^(\S+)\s+(\S+)\s+(.+)$/.exec(trimmed);
3376
+ if (!match) {
3377
+ return null;
3378
+ }
3379
+ const [, patchId, createdAt, path] = match;
3380
+ if (!isPatchId(patchId) || !isModuleFilePath(path)) {
3381
+ return null;
3638
3382
  }
3639
3383
  return {
3640
- changedPatches
3384
+ patchId,
3385
+ createdAt,
3386
+ path
3641
3387
  };
3642
3388
  }
3643
-
3644
- async function uploadRemoteFile(contentHost, project, bucket, fileHash, fileExt, fileBuffer, auth) {
3645
- const authHeader = "apiKey" in auth ? {
3646
- Authorization: `Bearer ${auth.apiKey}`
3647
- } : {
3648
- "x-val-pat": auth.pat
3649
- };
3650
- const res = await fetch(`${contentHost}/v1/${project}/remote/files/b/${bucket}/f/${fileHash}.${fileExt}`, {
3651
- method: "PUT",
3652
- headers: {
3653
- ...authHeader,
3654
- "Content-Type": "application/octet-stream"
3655
- },
3656
- body: new Uint8Array(fileBuffer)
3657
- });
3658
- if (!res.ok) {
3659
- var _res$headers$get;
3660
- if (res.status === 409) {
3661
- // File already exists
3662
- return {
3663
- success: true
3664
- };
3665
- }
3666
- if ((_res$headers$get = res.headers.get("content-type")) !== null && _res$headers$get !== void 0 && _res$headers$get.includes("application/json")) {
3667
- const json = await res.json();
3668
- const message = internal.getErrorMessageFromUnknownJson(json, JSON.stringify(json));
3389
+ function serializePatchLog(entries) {
3390
+ return [HEADER, ...entries.map(formatPatchLogLine), ""].join("\n");
3391
+ }
3392
+ function readPatchLog(logFilePath) {
3393
+ let raw;
3394
+ try {
3395
+ raw = fs__default["default"].readFileSync(logFilePath, "utf-8");
3396
+ } catch (err) {
3397
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
3669
3398
  return {
3670
- success: false,
3671
- error: `Failed to upload remote file: ${message}.`
3399
+ status: "absent"
3672
3400
  };
3673
3401
  }
3674
3402
  return {
3675
- success: false,
3676
- error: `An unexpected error occurred while uploading remote file. HTTP status was: ${await res.text()}.`
3403
+ status: "unreadable",
3404
+ message: `Could not read ${logFilePath}: ${err instanceof Error ? err.message : "unknown error"}`
3677
3405
  };
3678
3406
  }
3679
- return {
3680
- success: true
3681
- };
3407
+ return parsePatchLog(raw);
3682
3408
  }
3409
+ function parsePatchLog(raw) {
3410
+ const problems = [];
3411
+ // A zero-byte file is a store that was created and then interrupted before the
3412
+ // header landed. Empty, not broken: there are no patches either way.
3413
+ if (raw === "") {
3414
+ return {
3415
+ status: "ok",
3416
+ entries: [],
3417
+ problems
3418
+ };
3419
+ }
3420
+ const endsWithNewline = raw.endsWith("\n");
3421
+ const lines = raw.split("\n");
3422
+ // `split` always leaves a trailing "" when the text ends in a newline; when it
3423
+ // does not, the last element is a line whose write never finished.
3424
+ const tornFinalLine = endsWithNewline ? null : lines[lines.length - 1];
3425
+ lines.pop();
3426
+ if (tornFinalLine !== null && tornFinalLine.trim() !== "") {
3427
+ problems.push({
3428
+ type: "torn-final-line",
3429
+ line: tornFinalLine
3430
+ });
3431
+ }
3683
3432
 
3684
- function getFileExt(filePath) {
3685
- // 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).
3686
- return filePath.split(".").pop() || "";
3433
+ // Only consume the first line when it really is the header. A headerless file
3434
+ // is reported, but its first line is still offered to the entry parser rather
3435
+ // than eaten: dropping a real patch to pay for a missing header would be the
3436
+ // same silent data loss this store exists to stop.
3437
+ const firstLine = (lines[0] ?? "").replace(/\r$/, "");
3438
+ let firstEntryIndex = 0;
3439
+ if (firstLine === HEADER) {
3440
+ firstEntryIndex = 1;
3441
+ } else if (firstLine.startsWith("val-patch-log ")) {
3442
+ problems.push({
3443
+ type: "unsupported-version",
3444
+ header: firstLine
3445
+ });
3446
+ firstEntryIndex = 1;
3447
+ } else {
3448
+ problems.push({
3449
+ type: "missing-header",
3450
+ firstLine
3451
+ });
3452
+ }
3453
+ const entries = [];
3454
+ const seen = new Set();
3455
+ lines.slice(firstEntryIndex).forEach((rawLine, index) => {
3456
+ const line = rawLine.replace(/\r$/, "");
3457
+ // 1-based and counted from the top of the file, so they match what an editor
3458
+ // shows the person reading this report.
3459
+ const lineNumber = firstEntryIndex + index + 1;
3460
+ if (line.trim() === "") {
3461
+ return;
3462
+ }
3463
+ const entry = parsePatchLogLine(line);
3464
+ if (entry === null) {
3465
+ problems.push({
3466
+ type: "unparseable-line",
3467
+ lineNumber,
3468
+ line
3469
+ });
3470
+ return;
3471
+ }
3472
+ if (seen.has(entry.patchId)) {
3473
+ problems.push({
3474
+ type: "duplicate-entry",
3475
+ patchId: entry.patchId,
3476
+ lineNumber
3477
+ });
3478
+ return;
3479
+ }
3480
+ seen.add(entry.patchId);
3481
+ entries.push(entry);
3482
+ });
3483
+ return {
3484
+ status: "ok",
3485
+ entries,
3486
+ problems
3487
+ };
3687
3488
  }
3688
3489
 
3689
- /** Serializes a schema, or gives up quietly — serialization errors are reported elsewhere. */
3690
- function serializeSchemaSafely(schema) {
3490
+ /**
3491
+ * fsync the directory holding a file, so a rename or create survives a power cut.
3492
+ *
3493
+ * Best-effort: opening a directory is not permitted on Windows, and a store that
3494
+ * refuses to work there to buy durability nobody asked for is the worse trade.
3495
+ */
3496
+ function fsyncDir(dirPath) {
3497
+ let fd;
3691
3498
  try {
3692
- return schema === null || schema === void 0 ? void 0 : schema["executeSerialize"]();
3499
+ fd = fs__default["default"].openSync(dirPath, "r");
3500
+ fs__default["default"].fsyncSync(fd);
3693
3501
  } catch {
3694
- return undefined;
3502
+ // ignore: see above
3503
+ } finally {
3504
+ if (fd !== undefined) {
3505
+ try {
3506
+ fs__default["default"].closeSync(fd);
3507
+ } catch {
3508
+ // ignore
3509
+ }
3510
+ }
3695
3511
  }
3696
3512
  }
3697
- class ValOpsFS extends ValOps {
3698
- static VAL_DIR = ".val";
3699
- constructor(contentUrl, rootDir, valModules, options) {
3700
- super(valModules, options);
3701
- this.contentUrl = contentUrl;
3702
- this.rootDir = rootDir;
3703
- this.host = new FSOpsHost();
3704
- this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
3513
+
3514
+ /**
3515
+ * Add one entry to the end of the log.
3516
+ *
3517
+ * A single `writeSync` on an append-only descriptor, then fsync: the whole line
3518
+ * reaches the file or none of it does, and a reader that catches it mid-flight
3519
+ * discards the partial last line rather than misreading it.
3520
+ *
3521
+ * Callers must hold the patch lock. That is what makes "the torn line can only be
3522
+ * the last one" true, and it is why this does not try to be safe against
3523
+ * concurrent appends on its own.
3524
+ */
3525
+ function appendPatchLogEntry(logFilePath, entry) {
3526
+ const dirPath = path__namespace["default"].dirname(logFilePath);
3527
+ fs__default["default"].mkdirSync(dirPath, {
3528
+ recursive: true
3529
+ });
3530
+ if (!fs__default["default"].existsSync(logFilePath)) {
3531
+ writePatchLogFile(logFilePath, []);
3532
+ }
3533
+ const fd = fs__default["default"].openSync(logFilePath, "a");
3534
+ try {
3535
+ fs__default["default"].writeSync(fd, `${formatPatchLogLine(entry)}\n`);
3536
+ fs__default["default"].fsyncSync(fd);
3537
+ } finally {
3538
+ fs__default["default"].closeSync(fd);
3705
3539
  }
3540
+ }
3706
3541
 
3707
- /**
3708
- * Change detection for `.jsonValues()` entry files, which no sha can see (their
3709
- * content lives behind a thunk that `JSON.stringify` drops).
3542
+ /**
3543
+ * Replace the log wholesale — used by delete and by repair.
3544
+ *
3545
+ * Write a sibling temp file, fsync it, then rename over the original: a rename is
3546
+ * atomic, so a reader either sees the old log or the new one and never a
3547
+ * half-rewritten file. In-place truncation would have a window where the log is
3548
+ * short and the store looks like it lost patches.
3549
+ *
3550
+ * Callers must hold the patch lock.
3551
+ */
3552
+ function writePatchLogFile(logFilePath, entries) {
3553
+ const dirPath = path__namespace["default"].dirname(logFilePath);
3554
+ fs__default["default"].mkdirSync(dirPath, {
3555
+ recursive: true
3556
+ });
3557
+ const tmpPath = `${logFilePath}.tmp-${process.pid}`;
3558
+ const fd = fs__default["default"].openSync(tmpPath, "w");
3559
+ try {
3560
+ fs__default["default"].writeSync(fd, serializePatchLog(entries));
3561
+ fs__default["default"].fsyncSync(fd);
3562
+ } finally {
3563
+ fs__default["default"].closeSync(fd);
3564
+ }
3565
+ fs__default["default"].renameSync(tmpPath, logFilePath);
3566
+ fsyncDir(dirPath);
3567
+ }
3568
+
3569
+ /**
3570
+ * The on-disk shape of the local-dev patch store.
3571
+ *
3572
+ * ```
3573
+ * .val/patches/
3574
+ * patches.log the order, and the only place it lives
3575
+ * patches.repair.log what repair has done, for the person who has to know
3576
+ * <patchId>/patch.json one plain, self-contained directory per patch
3577
+ * <patchId>/base.json written when the patch is published
3578
+ * <patchId>/files/… binary payloads for this patch's file ops
3579
+ * ```
3580
+ *
3581
+ * A directory is named after the patch it holds, and a record references nothing
3582
+ * outside itself. That is the whole design, and it is a direct answer to how the
3583
+ * old layout failed: there, a directory was named after a record's PARENT, so
3584
+ * reading the store meant following links, and one absent record silently cut off
3585
+ * every patch written after it.
3586
+ *
3587
+ * The invariant this module exists to hold up is narrow and worth stating: **the
3588
+ * announced set and the delivered set are the same array.** `getStat` and
3589
+ * `fetchPatches` both come out of one `readPatchStore` call, so they cannot report
3590
+ * different numbers - which is exactly what they did when this broke, announcing
3591
+ * 410 patches and delivering 359 with no error in between.
3592
+ */
3593
+
3594
+ const PATCH_REPAIR_LOG_FILE_NAME = "patches.repair.log";
3595
+ const FSPatch = z.z.object({
3596
+ path: z.z.string().refine(p => p.startsWith("/") && p.includes(".val."), "Path is not valid. Must start with '/' and include '.val.'"),
3597
+ patch: internal.Patch,
3598
+ patchId: z.z.string(),
3599
+ baseSha: z.z.string().refine(p => true),
3600
+ authorId: z.z.string().refine(p => true).nullable(),
3601
+ createdAt: z.z.string().datetime(),
3602
+ coreVersion: z.z.string().nullable(),
3603
+ sessionId: z.z.string().nullable()
3604
+ });
3605
+ const FSPatchBase = z.z.object({
3606
+ baseSha: z.z.string().refine(p => true),
3607
+ timestamp: z.z.string().datetime()
3608
+ });
3609
+
3610
+ /**
3611
+ * Something wrong with the store that a reader can see.
3612
+ *
3613
+ * Every one of these used to be invisible. Reporting them is the point: the
3614
+ * failure that motivated this rewrite was not that the store broke, it was that
3615
+ * breaking looked exactly like working.
3616
+ */
3617
+
3618
+ function patchesLogFile(patchesDir) {
3619
+ return path__namespace["default"].join(patchesDir, PATCH_LOG_FILE_NAME);
3620
+ }
3621
+ function patchRepairLogFile(patchesDir) {
3622
+ return path__namespace["default"].join(patchesDir, PATCH_REPAIR_LOG_FILE_NAME);
3623
+ }
3624
+ function patchDir(patchesDir, patchId) {
3625
+ return path__namespace["default"].join(patchesDir, patchId);
3626
+ }
3627
+ function patchRecordFile(patchesDir, patchId) {
3628
+ return path__namespace["default"].join(patchDir(patchesDir, patchId), "patch.json");
3629
+ }
3630
+ function patchBaseFile(patchesDir, patchId) {
3631
+ return path__namespace["default"].join(patchDir(patchesDir, patchId), "base.json");
3632
+ }
3633
+ function patchBinaryFile(patchesDir, patchId, filePath) {
3634
+ return path__namespace["default"].join(patchDir(patchesDir, patchId), "files", filePath, path__namespace["default"].basename(filePath));
3635
+ }
3636
+ function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3637
+ return path__namespace["default"].join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
3638
+ }
3639
+
3640
+ /** Names that live in the patches directory but are not patches. */
3641
+ const RESERVED_NAMES = new Set([PATCH_LOG_FILE_NAME, PATCH_REPAIR_LOG_FILE_NAME]);
3642
+ function readJsonFile(filePath, parser) {
3643
+ let raw;
3644
+ try {
3645
+ raw = fs__default["default"].readFileSync(filePath, "utf-8");
3646
+ } catch (err) {
3647
+ return {
3648
+ error: err instanceof Error ? err.message : "unknown error"
3649
+ };
3650
+ }
3651
+ if (raw === "") {
3652
+ return {
3653
+ error: "the file is empty"
3654
+ };
3655
+ }
3656
+ let json;
3657
+ try {
3658
+ json = JSON.parse(raw);
3659
+ } catch (err) {
3660
+ return {
3661
+ error: `it is not valid JSON: ${err instanceof Error ? err.message : "unknown error"}`
3662
+ };
3663
+ }
3664
+ const parsed = parser.safeParse(json);
3665
+ if (!parsed.success) {
3666
+ return {
3667
+ error: zodValidationError.fromError(parsed.error).toString()
3668
+ };
3669
+ }
3670
+ return {
3671
+ data: parsed.data
3672
+ };
3673
+ }
3674
+ function listPatchDirNames(patchesDir) {
3675
+ return fs__default["default"].readdirSync(patchesDir, {
3676
+ withFileTypes: true
3677
+ }).filter(dirent => dirent.isDirectory() && !RESERVED_NAMES.has(dirent.name)).map(dirent => dirent.name);
3678
+ }
3679
+
3680
+ /**
3681
+ * Read the whole store: the order, the records, and everything wrong with it.
3682
+ *
3683
+ * One call, one answer. Callers that need only the ids and callers that need the
3684
+ * ops both use this, which is what stops them disagreeing.
3685
+ */
3686
+ function readPatchStore(patchesDir) {
3687
+ if (!fs__default["default"].existsSync(patchesDir)) {
3688
+ return {
3689
+ status: "ok",
3690
+ entries: [],
3691
+ problems: []
3692
+ };
3693
+ }
3694
+ let dirNames;
3695
+ try {
3696
+ dirNames = listPatchDirNames(patchesDir);
3697
+ } catch (err) {
3698
+ return {
3699
+ status: "unreadable",
3700
+ message: `Could not list ${patchesDir}: ${err instanceof Error ? err.message : "unknown error"}`
3701
+ };
3702
+ }
3703
+ const problems = [];
3704
+ const logRes = readPatchLog(patchesLogFile(patchesDir));
3705
+ if (logRes.status === "unreadable") {
3706
+ return {
3707
+ status: "unreadable",
3708
+ message: logRes.message
3709
+ };
3710
+ }
3711
+ let logEntries;
3712
+ if (logRes.status === "absent") {
3713
+ if (dirNames.length === 0) {
3714
+ return {
3715
+ status: "ok",
3716
+ entries: [],
3717
+ problems: []
3718
+ };
3719
+ }
3720
+ logEntries = reconstructLogEntries(patchesDir, dirNames);
3721
+ if (logEntries.length > 0) {
3722
+ problems.push({
3723
+ type: "reconstructed-log",
3724
+ entryCount: logEntries.length
3725
+ });
3726
+ }
3727
+ } else {
3728
+ logEntries = logRes.entries;
3729
+ for (const problem of logRes.problems) {
3730
+ problems.push({
3731
+ type: "log",
3732
+ problem
3733
+ });
3734
+ }
3735
+ }
3736
+ const entries = [];
3737
+ const named = new Set();
3738
+ for (const logEntry of logEntries) {
3739
+ named.add(logEntry.patchId);
3740
+ const read = readPatchRecord(patchesDir, logEntry.patchId);
3741
+ if (read.error !== undefined) {
3742
+ problems.push({
3743
+ type: "unreadable-patch",
3744
+ name: logEntry.patchId,
3745
+ dir: patchDir(patchesDir, logEntry.patchId),
3746
+ message: read.error
3747
+ });
3748
+ continue;
3749
+ }
3750
+ const baseFile = patchBaseFile(patchesDir, logEntry.patchId);
3751
+ const baseRes = fs__default["default"].existsSync(baseFile) ? readJsonFile(baseFile, FSPatchBase) : undefined;
3752
+ entries.push({
3753
+ patchId: logEntry.patchId,
3754
+ record: read.data,
3755
+ base: (baseRes === null || baseRes === void 0 ? void 0 : baseRes.data) ?? null
3756
+ });
3757
+ }
3758
+ for (const name of dirNames) {
3759
+ if (named.has(name)) {
3760
+ continue;
3761
+ }
3762
+ // Told apart by whether the directory holds a usable record. One is a patch
3763
+ // whose contents are lost and has to be reported; the other is a crash
3764
+ // leftover nothing ever read.
3765
+ const read = readPatchRecord(patchesDir, name);
3766
+ if (read.error !== undefined) {
3767
+ problems.push({
3768
+ type: "unreadable-patch",
3769
+ name,
3770
+ dir: path__namespace["default"].join(patchesDir, name),
3771
+ message: read.error
3772
+ });
3773
+ } else {
3774
+ problems.push({
3775
+ type: "orphan-directory",
3776
+ name,
3777
+ dir: path__namespace["default"].join(patchesDir, name)
3778
+ });
3779
+ }
3780
+ }
3781
+ return {
3782
+ status: "ok",
3783
+ entries,
3784
+ problems
3785
+ };
3786
+ }
3787
+
3788
+ /**
3789
+ * Read one patch directory, insisting it holds the patch it is named after.
3790
+ *
3791
+ * The name check is not a formality. A directory named after something other
3792
+ * than the record inside is how the previous layout worked - the name was the
3793
+ * record's PARENT - so this is what tells a store this version can use from one
3794
+ * it cannot.
3795
+ */
3796
+ function readPatchRecord(patchesDir, name) {
3797
+ const file = patchRecordFile(patchesDir, name);
3798
+ if (!fs__default["default"].existsSync(file)) {
3799
+ return {
3800
+ error: `there is no ${file}`
3801
+ };
3802
+ }
3803
+ const res = readJsonFile(file, FSPatch);
3804
+ if (res.error !== undefined) {
3805
+ return {
3806
+ error: res.error
3807
+ };
3808
+ }
3809
+ if (res.data.patchId !== name) {
3810
+ return {
3811
+ error: `it holds ${res.data.patchId}, not the patch its directory is named after`
3812
+ };
3813
+ }
3814
+ return {
3815
+ data: res.data
3816
+ };
3817
+ }
3818
+
3819
+ /**
3820
+ * Rebuild the order from the records when the log itself is gone.
3821
+ *
3822
+ * `createdAt` is the only ordering left, and it is not a perfect one, so the
3823
+ * caller reports that the order was guessed rather than presenting it as fact.
3824
+ */
3825
+ function reconstructLogEntries(patchesDir, dirNames) {
3826
+ const entries = [];
3827
+ for (const name of dirNames) {
3828
+ const res = readJsonFile(path__namespace["default"].join(patchesDir, name, "patch.json"), FSPatch);
3829
+ if (res.error !== undefined || res.data.patchId !== name) {
3830
+ continue;
3831
+ }
3832
+ entries.push({
3833
+ patchId: res.data.patchId,
3834
+ createdAt: res.data.createdAt,
3835
+ path: res.data.path
3836
+ });
3837
+ }
3838
+ entries.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3839
+ return entries;
3840
+ }
3841
+
3842
+ /** Write a patch record so that a reader sees all of it or none of it. */
3843
+ function writePatchRecord(patchesDir, patchId, record) {
3844
+ const dir = patchDir(patchesDir, patchId);
3845
+ fs__default["default"].mkdirSync(dir, {
3846
+ recursive: true
3847
+ });
3848
+ const file = patchRecordFile(patchesDir, patchId);
3849
+ const tmpFile = `${file}.tmp-${process.pid}`;
3850
+ const fd = fs__default["default"].openSync(tmpFile, "w");
3851
+ try {
3852
+ fs__default["default"].writeSync(fd, JSON.stringify(record));
3853
+ fs__default["default"].fsyncSync(fd);
3854
+ } finally {
3855
+ fs__default["default"].closeSync(fd);
3856
+ }
3857
+ fs__default["default"].renameSync(tmpFile, file);
3858
+ }
3859
+
3860
+ /**
3861
+ * Add a patch to the store.
3862
+ *
3863
+ * Record first, then the log line, and the order is the safety property: an
3864
+ * interrupted append leaves a directory nothing points at, which repair sweeps
3865
+ * up. The reverse order would leave the log naming a patch that is not there -
3866
+ * the exact state this whole rewrite exists to make unreachable.
3867
+ *
3868
+ * Callers must hold the patch lock.
3869
+ */
3870
+ function appendPatch(patchesDir, record) {
3871
+ const patchId = record.patchId;
3872
+ writePatchRecord(patchesDir, patchId, record);
3873
+ const entry = {
3874
+ patchId,
3875
+ createdAt: record.createdAt,
3876
+ path: record.path
3877
+ };
3878
+ appendPatchLogEntry(patchesLogFile(patchesDir), entry);
3879
+ return entry;
3880
+ }
3881
+ /**
3882
+ * Bring the store back to a state where the log and the directories agree.
3883
+ *
3884
+ * Safe in a way the old layout's repair could never be: the log is a flat list,
3885
+ * so dropping an entry does not orphan the entries after it. There is nothing to
3886
+ * re-link, which is why this can run unattended where re-parenting a chain could
3887
+ * not.
3888
+ *
3889
+ * A patch that cannot be read is removed rather than kept around to fail again
3890
+ * on every load. That does discard unpublished work, which is why
3891
+ * {@link RepairAction} carries the reason, why it is written to
3892
+ * `patches.repair.log`, and why the caller is expected to tell the person
3893
+ * editing.
3894
+ *
3895
+ * Callers must hold the patch lock.
3896
+ */
3897
+ function repairPatchStore(patchesDir, read) {
3898
+ const actions = [];
3899
+ const needsRewrite = read.problems.some(problem => problem.type === "unreadable-patch" || problem.type === "reconstructed-log" || problem.type === "log");
3900
+ for (const problem of read.problems) {
3901
+ if (problem.type === "unreadable-patch") {
3902
+ remove(problem.dir);
3903
+ actions.push({
3904
+ type: "removed-unreadable-patch",
3905
+ name: problem.name,
3906
+ because: problem.message
3907
+ });
3908
+ } else if (problem.type === "orphan-directory") {
3909
+ remove(problem.dir);
3910
+ actions.push({
3911
+ type: "removed-orphan-directory",
3912
+ name: problem.name
3913
+ });
3914
+ }
3915
+ }
3916
+ if (needsRewrite) {
3917
+ writePatchLogFile(patchesLogFile(patchesDir), read.entries.map(entry => ({
3918
+ patchId: entry.patchId,
3919
+ createdAt: entry.record.createdAt,
3920
+ path: entry.record.path
3921
+ })));
3922
+ actions.push({
3923
+ type: "rewrote-log",
3924
+ entryCount: read.entries.length
3925
+ });
3926
+ }
3927
+ if (actions.length > 0) {
3928
+ recordRepair(patchesDir, actions);
3929
+ }
3930
+ return actions;
3931
+ }
3932
+ function remove(dir) {
3933
+ try {
3934
+ fs__default["default"].rmSync(dir, {
3935
+ recursive: true,
3936
+ force: true
3937
+ });
3938
+ } catch {
3939
+ // Left in place. It is inert either way - nothing reads a directory the log
3940
+ // does not name, and the log has already been rewritten without it.
3941
+ }
3942
+ }
3943
+
3944
+ /**
3945
+ * Leave a trail. Repair drops a person's unpublished edits, and doing that
3946
+ * without saying so anywhere durable is how you get someone certain they saved
3947
+ * something and no way to tell them what happened to it.
3948
+ */
3949
+ function recordRepair(patchesDir, actions) {
3950
+ const at = new Date().toISOString();
3951
+ const lines = actions.map(action => {
3952
+ if (action.type === "removed-unreadable-patch") {
3953
+ return `${at} removed ${action.name}: ${action.because}`;
3954
+ }
3955
+ if (action.type === "removed-orphan-directory") {
3956
+ return `${at} removed orphan directory ${action.name}`;
3957
+ }
3958
+ return `${at} rewrote the log with ${action.entryCount} entries`;
3959
+ });
3960
+ try {
3961
+ fs__default["default"].appendFileSync(patchRepairLogFile(patchesDir), `${lines.join("\n")}\n`, "utf-8");
3962
+ } catch {
3963
+ // An audit trail that cannot be written is not a reason to fail the repair.
3964
+ }
3965
+ }
3966
+
3967
+ /**
3968
+ * Last resort: move the whole store aside and start empty.
3969
+ *
3970
+ * A rename, never a delete. What is being given up on here is someone's
3971
+ * unpublished work, and the least this can do is say where it went.
3972
+ *
3973
+ * Callers must hold the patch lock.
3974
+ */
3975
+ function resetPatchStore(patchesDir, reason) {
3976
+ if (!fs__default["default"].existsSync(patchesDir)) {
3977
+ return {
3978
+ movedTo: patchesDir
3979
+ };
3980
+ }
3981
+ const movedTo = `${patchesDir}-corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
3982
+ try {
3983
+ fs__default["default"].renameSync(patchesDir, movedTo);
3984
+ } catch (err) {
3985
+ return {
3986
+ error: `Could not move ${patchesDir} aside: ${err instanceof Error ? err.message : "unknown error"}`
3987
+ };
3988
+ }
3989
+ try {
3990
+ fs__default["default"].mkdirSync(patchesDir, {
3991
+ recursive: true
3992
+ });
3993
+ fs__default["default"].appendFileSync(patchRepairLogFile(patchesDir), `${new Date().toISOString()} reset the store (${reason}); the previous contents are in ${movedTo}\n`, "utf-8");
3994
+ } catch {
3995
+ // The rename is the part that mattered.
3996
+ }
3997
+ return {
3998
+ movedTo
3999
+ };
4000
+ }
4001
+
4002
+ /** One line per problem, for a log line or an API error a person has to act on. */
4003
+ function describePatchStoreProblems(problems) {
4004
+ return problems.map(problem => {
4005
+ switch (problem.type) {
4006
+ case "unreadable-patch":
4007
+ return `The change ${problem.name} could not be read: ${problem.message} (${problem.dir})`;
4008
+ case "orphan-directory":
4009
+ return `${problem.dir} is not listed in the log and is not being used.`;
4010
+ case "reconstructed-log":
4011
+ return `The log was missing, so the order of ${problem.entryCount} change(s) was reconstructed from their timestamps and may not be exact.`;
4012
+ case "log":
4013
+ return describeLogProblem(problem.problem);
4014
+ }
4015
+ });
4016
+ }
4017
+ function describeLogProblem(problem) {
4018
+ switch (problem.type) {
4019
+ case "missing-header":
4020
+ return `The log does not start with its header (it starts with ${JSON.stringify(problem.firstLine)}).`;
4021
+ case "unsupported-version":
4022
+ return `The log says ${JSON.stringify(problem.header)}, which this version of Val does not understand.`;
4023
+ case "unparseable-line":
4024
+ return `Line ${problem.lineNumber} of the log could not be read: ${JSON.stringify(problem.line)}`;
4025
+ case "duplicate-entry":
4026
+ return `The change ${problem.patchId} is listed twice in the log (line ${problem.lineNumber}).`;
4027
+ case "torn-final-line":
4028
+ return `The last line of the log was not finished being written and was ignored.`;
4029
+ }
4030
+ }
4031
+
4032
+ /**
4033
+ * A timed, cross-process lock over the patches directory.
4034
+ *
4035
+ * ## Why there is one at all
4036
+ *
4037
+ * Nothing serialized writes to the patch store before this. Two dev-server route
4038
+ * handlers, two browser tabs and the CLI all wrote to the same directory, and the
4039
+ * one guard that existed — a `mkdir` whose stated purpose was to throw when the
4040
+ * directory already existed — had its `EEXIST` swallowed by a bare `catch {}`, so
4041
+ * a colliding write silently overwrote the patch that was already there. A store
4042
+ * that loses a patch that way loses every patch chained behind it too.
4043
+ *
4044
+ * ## Why a file, and why timed
4045
+ *
4046
+ * A real lock manager is more than this problem needs: the contenders are a
4047
+ * handful of processes on one machine, holding it for milliseconds. What that
4048
+ * does need is to survive a holder that dies — `kill -9` on a dev server, a
4049
+ * laptop lid, a crashed test — without leaving the store permanently unwritable.
4050
+ * Hence an expiry: a lock nobody renews stops being a lock.
4051
+ *
4052
+ * The file is plain `key: value` text on purpose. When something is stuck, the
4053
+ * question is always "who is holding it and since when", and `cat .val/patches.lock`
4054
+ * should answer it without a tool.
4055
+ *
4056
+ * ## The takeover race
4057
+ *
4058
+ * Two processes can decide the same lock is stale at the same moment, and a blind
4059
+ * `unlink` would then let the second one delete the FIRST one's fresh lock. So a
4060
+ * stale lock is only removed after being read twice, unchanged, across a poll
4061
+ * interval, and the `owner` nonce is what "unchanged" means. Whoever wins the
4062
+ * `wx` create afterwards is unique regardless, because that create is atomic —
4063
+ * the double read is what stops a winner being deleted a moment after winning.
4064
+ */
4065
+
4066
+ const PATCH_LOCK_FILE_NAME = "patches.lock";
4067
+ const DEFAULT_TIMEOUT_MS = 10_000;
4068
+ const DEFAULT_POLL_MS = 25;
4069
+
4070
+ /**
4071
+ * Locks this process is currently holding, released on exit.
4072
+ *
4073
+ * A best-effort courtesy: it turns the common crash — an unhandled throw, a
4074
+ * ctrl-C — into an immediately reusable store instead of one that waits out a
4075
+ * TTL. The TTL is still what makes correctness not depend on this running.
4076
+ */
4077
+ const heldLocks = new Set();
4078
+ let exitHookInstalled = false;
4079
+ function installExitHook() {
4080
+ if (exitHookInstalled) {
4081
+ return;
4082
+ }
4083
+ exitHookInstalled = true;
4084
+ process.once("exit", () => {
4085
+ heldLocks.forEach(lock => {
4086
+ try {
4087
+ lock.release();
4088
+ } catch {
4089
+ // Nothing useful can be done during exit.
4090
+ }
4091
+ });
4092
+ });
4093
+ }
4094
+ function formatPatchLockInfo(info) {
4095
+ return [`owner: ${info.owner}`, `pid: ${info.pid}`, `host: ${info.host}`, `op: ${info.op}`, `acquired: ${info.acquiredAt}`, `expires: ${info.expiresAt}`, ""].join("\n");
4096
+ }
4097
+ function parsePatchLockInfo(raw) {
4098
+ const fields = {};
4099
+ for (const line of raw.split("\n")) {
4100
+ const separator = line.indexOf(":");
4101
+ if (separator === -1) {
4102
+ continue;
4103
+ }
4104
+ fields[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
4105
+ }
4106
+ const {
4107
+ owner,
4108
+ pid,
4109
+ host,
4110
+ op,
4111
+ acquired,
4112
+ expires
4113
+ } = fields;
4114
+ if (!owner || !acquired || !expires) {
4115
+ return null;
4116
+ }
4117
+ const parsedPid = Number.parseInt(pid ?? "", 10);
4118
+ return {
4119
+ owner,
4120
+ pid: Number.isNaN(parsedPid) ? -1 : parsedPid,
4121
+ host: host ?? "",
4122
+ op: op ?? "",
4123
+ acquiredAt: acquired,
4124
+ expiresAt: expires
4125
+ };
4126
+ }
4127
+ function readRaw(lockFilePath) {
4128
+ try {
4129
+ return fs__default["default"].readFileSync(lockFilePath, "utf-8");
4130
+ } catch {
4131
+ return null;
4132
+ }
4133
+ }
4134
+ function isExpired(info, nowMs) {
4135
+ // An unparseable lock file is treated as expired. It is either a half-written
4136
+ // acquisition or something that is not a lock at all, and neither should be
4137
+ // able to wedge the store forever.
4138
+ if (info === null) {
4139
+ return true;
4140
+ }
4141
+ const expiresAt = Date.parse(info.expiresAt);
4142
+ if (Number.isNaN(expiresAt)) {
4143
+ return true;
4144
+ }
4145
+ return expiresAt <= nowMs;
4146
+ }
4147
+ function describeHolder(info) {
4148
+ if (info === null) {
4149
+ return "the lock file could not be read";
4150
+ }
4151
+ return `held by pid ${info.pid} on ${info.host || "unknown host"} for "${info.op}" since ${info.acquiredAt} (expires ${info.expiresAt})`;
4152
+ }
4153
+ function sleep(ms) {
4154
+ return new Promise(resolve => setTimeout(resolve, ms));
4155
+ }
4156
+
4157
+ /**
4158
+ * Try to create the lock file exclusively.
4159
+ *
4160
+ * `wx` is `O_CREAT | O_EXCL`: the create either happens or reports EEXIST, with
4161
+ * no window in between. Everything else in this module exists to handle the
4162
+ * EEXIST case; this is the part that actually provides mutual exclusion.
4163
+ */
4164
+ function tryCreate(lockFilePath, info) {
4165
+ let fd;
4166
+ try {
4167
+ fd = fs__default["default"].openSync(lockFilePath, "wx");
4168
+ } catch (err) {
4169
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") {
4170
+ return "exists";
4171
+ }
4172
+ return {
4173
+ error: err instanceof Error ? err.message : "unknown error"
4174
+ };
4175
+ }
4176
+ try {
4177
+ fs__default["default"].writeSync(fd, formatPatchLockInfo(info));
4178
+ fs__default["default"].fsyncSync(fd);
4179
+ } finally {
4180
+ fs__default["default"].closeSync(fd);
4181
+ }
4182
+ return "created";
4183
+ }
4184
+ async function acquirePatchLock(lockFilePath, options) {
4185
+ const now = options.now ?? Date.now;
4186
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4187
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
4188
+ const deadline = now() + timeoutMs;
4189
+ try {
4190
+ fs__default["default"].mkdirSync(path__namespace["default"].dirname(lockFilePath), {
4191
+ recursive: true
4192
+ });
4193
+ } catch (err) {
4194
+ return {
4195
+ status: "error",
4196
+ message: `Could not create the directory for ${lockFilePath}: ${err instanceof Error ? err.message : "unknown error"}`
4197
+ };
4198
+ }
4199
+ let lastSeenHolder = null;
4200
+ for (;;) {
4201
+ const acquiredAt = now();
4202
+ const info = {
4203
+ owner: crypto__default["default"].randomUUID(),
4204
+ pid: process.pid,
4205
+ host: os__default["default"].hostname(),
4206
+ op: options.op,
4207
+ acquiredAt: new Date(acquiredAt).toISOString(),
4208
+ expiresAt: new Date(acquiredAt + options.ttlMs).toISOString()
4209
+ };
4210
+ const created = tryCreate(lockFilePath, info);
4211
+ if (created === "created") {
4212
+ const lock = createLock(lockFilePath, info, options.ttlMs, now);
4213
+ installExitHook();
4214
+ heldLocks.add(lock);
4215
+ return {
4216
+ status: "acquired",
4217
+ lock
4218
+ };
4219
+ }
4220
+ if (created !== "exists") {
4221
+ return {
4222
+ status: "error",
4223
+ message: `Could not create ${lockFilePath}: ${created.error}`
4224
+ };
4225
+ }
4226
+ const rawBefore = readRaw(lockFilePath);
4227
+ if (rawBefore === null) {
4228
+ // Released between the failed create and the read. Go straight round again.
4229
+ continue;
4230
+ }
4231
+ lastSeenHolder = parsePatchLockInfo(rawBefore);
4232
+ if (isExpired(lastSeenHolder, now())) {
4233
+ // Read twice across a poll interval before removing it: an unchanged stale
4234
+ // file is one nobody is renewing, and requiring the second read is what
4235
+ // stops two processes both deciding to clear it and the loser wiping the
4236
+ // winner's brand-new lock.
4237
+ await sleep(pollMs);
4238
+ const rawAfter = readRaw(lockFilePath);
4239
+ if (rawAfter === rawBefore && isExpired(lastSeenHolder, now())) {
4240
+ try {
4241
+ fs__default["default"].unlinkSync(lockFilePath);
4242
+ } catch {
4243
+ // Someone else got there first, which is the outcome we wanted anyway.
4244
+ }
4245
+ }
4246
+ continue;
4247
+ }
4248
+ if (now() >= deadline) {
4249
+ return {
4250
+ status: "busy",
4251
+ holder: lastSeenHolder,
4252
+ message: `Timed out after ${timeoutMs}ms waiting for the Val patch lock at ${lockFilePath}: ${describeHolder(lastSeenHolder)}`
4253
+ };
4254
+ }
4255
+ await sleep(pollMs);
4256
+ }
4257
+ }
4258
+ function createLock(lockFilePath, initialInfo, defaultTtlMs, now) {
4259
+ let info = initialInfo;
4260
+ let released = false;
4261
+ const stillOurs = () => {
4262
+ var _parsePatchLockInfo;
4263
+ const raw = readRaw(lockFilePath);
4264
+ if (raw === null) {
4265
+ return false;
4266
+ }
4267
+ return ((_parsePatchLockInfo = parsePatchLockInfo(raw)) === null || _parsePatchLockInfo === void 0 ? void 0 : _parsePatchLockInfo.owner) === info.owner;
4268
+ };
4269
+ const lock = {
4270
+ get info() {
4271
+ return info;
4272
+ },
4273
+ renew(ttlMs) {
4274
+ if (released || !stillOurs()) {
4275
+ return false;
4276
+ }
4277
+ const next = {
4278
+ ...info,
4279
+ expiresAt: new Date(now() + (ttlMs ?? defaultTtlMs)).toISOString()
4280
+ };
4281
+ const fd = fs__default["default"].openSync(lockFilePath, "w");
4282
+ try {
4283
+ fs__default["default"].writeSync(fd, formatPatchLockInfo(next));
4284
+ fs__default["default"].fsyncSync(fd);
4285
+ } finally {
4286
+ fs__default["default"].closeSync(fd);
4287
+ }
4288
+ info = next;
4289
+ return true;
4290
+ },
4291
+ release() {
4292
+ if (released) {
4293
+ return;
4294
+ }
4295
+ released = true;
4296
+ heldLocks.delete(lock);
4297
+ // Only ours. If the lock expired and someone else took it, the file now
4298
+ // belongs to them and deleting it would hand the store to a third party.
4299
+ if (stillOurs()) {
4300
+ try {
4301
+ fs__default["default"].unlinkSync(lockFilePath);
4302
+ } catch {
4303
+ // Already gone.
4304
+ }
4305
+ }
4306
+ }
4307
+ };
4308
+ return lock;
4309
+ }
4310
+
4311
+ /**
4312
+ * Run `fn` while holding the lock, and always give it back.
4313
+ *
4314
+ * The only shape callers should use: a `release()` that a throw can skip is how
4315
+ * a store ends up locked by a process that has long since moved on.
4316
+ */
4317
+ async function withPatchLock(lockFilePath, options, fn) {
4318
+ const acquired = await acquirePatchLock(lockFilePath, options);
4319
+ if (acquired.status !== "acquired") {
4320
+ return acquired;
4321
+ }
4322
+ try {
4323
+ return {
4324
+ status: "ok",
4325
+ value: await fn(acquired.lock)
4326
+ };
4327
+ } finally {
4328
+ acquired.lock.release();
4329
+ }
4330
+ }
4331
+
4332
+ async function uploadRemoteFile(contentHost, project, bucket, fileHash, fileExt, fileBuffer, auth) {
4333
+ const authHeader = "apiKey" in auth ? {
4334
+ Authorization: `Bearer ${auth.apiKey}`
4335
+ } : {
4336
+ "x-val-pat": auth.pat
4337
+ };
4338
+ const res = await fetch(`${contentHost}/v1/${project}/remote/files/b/${bucket}/f/${fileHash}.${fileExt}`, {
4339
+ method: "PUT",
4340
+ headers: {
4341
+ ...authHeader,
4342
+ "Content-Type": "application/octet-stream"
4343
+ },
4344
+ body: new Uint8Array(fileBuffer)
4345
+ });
4346
+ if (!res.ok) {
4347
+ var _res$headers$get;
4348
+ if (res.status === 409) {
4349
+ // File already exists
4350
+ return {
4351
+ success: true
4352
+ };
4353
+ }
4354
+ if ((_res$headers$get = res.headers.get("content-type")) !== null && _res$headers$get !== void 0 && _res$headers$get.includes("application/json")) {
4355
+ const json = await res.json();
4356
+ const message = internal.getErrorMessageFromUnknownJson(json, JSON.stringify(json));
4357
+ return {
4358
+ success: false,
4359
+ error: `Failed to upload remote file: ${message}.`
4360
+ };
4361
+ }
4362
+ return {
4363
+ success: false,
4364
+ error: `An unexpected error occurred while uploading remote file. HTTP status was: ${await res.text()}.`
4365
+ };
4366
+ }
4367
+ return {
4368
+ success: true
4369
+ };
4370
+ }
4371
+
4372
+ function getFileExt(filePath) {
4373
+ // 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).
4374
+ return filePath.split(".").pop() || "";
4375
+ }
4376
+
4377
+ /** Serializes a schema, or gives up quietly — serialization errors are reported elsewhere. */
4378
+ function serializeSchemaSafely(schema) {
4379
+ try {
4380
+ return schema === null || schema === void 0 ? void 0 : schema["executeSerialize"]();
4381
+ } catch {
4382
+ return undefined;
4383
+ }
4384
+ }
4385
+ class ValOpsFS extends ValOps {
4386
+ static VAL_DIR = ".val";
4387
+ constructor(contentUrl, rootDir, valModules, options) {
4388
+ super(valModules, options);
4389
+ this.contentUrl = contentUrl;
4390
+ this.rootDir = rootDir;
4391
+ this.host = new FSOpsHost();
4392
+ this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
4393
+ }
4394
+
4395
+ /**
4396
+ * Change detection for `.jsonValues()` entry files, which no sha can see (their
4397
+ * content lives behind a thunk that `JSON.stringify` drops).
3710
4398
  */
3711
4399
 
4400
+ /**
4401
+ * Unpublished changes repair threw away, waiting to be told to somebody.
4402
+ *
4403
+ * Held here and drained by the next `getStat`, because stat is the channel
4404
+ * that always flows. The case worth reporting is a repair that removed
4405
+ * EVERYTHING - which is what an old store looks like - and then there is
4406
+ * nothing left for the studio to fetch, so a notice riding on `fetchPatches`
4407
+ * would never be collected. Drained rather than kept, because this has to be
4408
+ * said once: a permanent flag would put a toast on the screen forever.
4409
+ *
4410
+ * Held in memory only: a restart loses it, and that is the right trade. The
4411
+ * durable record is `patches.repair.log`.
4412
+ */
4413
+ removedPatchNotices = [];
3712
4414
  async onInit() {
3713
4415
  // do nothing
3714
4416
  }
@@ -3802,14 +4504,28 @@ class ValOpsFS extends ValOps {
3802
4504
  const serializedSchemas = Object.fromEntries(Object.entries(schemas).map(([path, schema]) => [path, serializeSchemaSafely(schema)]));
3803
4505
  const currentJsonEntriesSha = this.jsonEntryFilesFingerprint.compute(serializedSchemas);
3804
4506
  const jsonEntryFilePaths = this.jsonEntryFilesFingerprint.entryFilePaths(serializedSchemas);
3805
- const patchData = await this.readPatches();
3806
- const patches = [];
3807
- // TODO: use proper patch sequences when available:
3808
- for (const [patchId] of Object.entries(patchData.patches).sort(([, a], [, b]) => {
3809
- return a.createdAt.localeCompare(b.createdAt, undefined);
3810
- })) {
3811
- patches.push(patchId);
4507
+
4508
+ // The SAME read `fetchPatches` delivers from. Announcing out of one source
4509
+ // and delivering out of another is what let this store tell a studio about
4510
+ // 410 unpublished changes and then hand over 359 of them, with nothing in
4511
+ // between reporting that anything was wrong.
4512
+ const announceRes = await this.readStore();
4513
+ if (announceRes.status === "error") {
4514
+ return {
4515
+ type: "error",
4516
+ error: {
4517
+ message: announceRes.message
4518
+ }
4519
+ };
3812
4520
  }
4521
+ const patches = announceRes.entries.map(entry => entry.patchId);
4522
+ // Drained here, on the channel that always flows. A repair that removed
4523
+ // everything leaves the studio nothing to fetch, so a notice riding on
4524
+ // `GET /patches` would sit here unread.
4525
+ const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4526
+ const removedNotice = removed.length > 0 ? {
4527
+ removed
4528
+ } : {};
3813
4529
  // something changed: return immediately
3814
4530
  const didChange = !params ||
3815
4531
  // An entry file changed on disk: nothing else here can see that, since a
@@ -3824,6 +4540,7 @@ class ValOpsFS extends ValOps {
3824
4540
  schemaSha: currentSchemaSha,
3825
4541
  sourcesSha: currentSourcesSha,
3826
4542
  patches,
4543
+ ...removedNotice,
3827
4544
  jsonEntriesSha: currentJsonEntriesSha
3828
4545
  };
3829
4546
  }
@@ -3948,6 +4665,7 @@ class ValOpsFS extends ValOps {
3948
4665
  schemaSha: currentSchemaSha,
3949
4666
  sourcesSha: currentSourcesSha,
3950
4667
  patches,
4668
+ ...removedNotice,
3951
4669
  jsonEntriesSha: currentJsonEntriesSha
3952
4670
  };
3953
4671
  } catch (err) {
@@ -3967,134 +4685,152 @@ class ValOpsFS extends ValOps {
3967
4685
  };
3968
4686
  }
3969
4687
  }
3970
- async readPatches(includes) {
3971
- const patchesCacheDir = this.getPatchesDir();
3972
- let patchJsonFiles = [];
3973
- if (!this.host.directoryExists || this.host.directoryExists && this.host.directoryExists(patchesCacheDir)) {
3974
- patchJsonFiles = this.host.readDirectory(patchesCacheDir, ["patch.json"], [], []);
3975
- }
3976
- const patches = {};
3977
- const errors = [];
3978
- const parsedUnsortedFsPatches = patchJsonFiles.map(file => path__namespace["default"].basename(path__namespace["default"].dirname(file))).map(patchDir => [patchDir, this.parseJsonFile(this.getPatchFilePath(patchDir), FSPatch), this.host.fileExists(this.getPatchBaseFile(patchDir)) ? this.parseJsonFile(this.getPatchBaseFile(patchDir), FSPatchBase) : undefined]);
3979
- parsedUnsortedFsPatches.forEach(([dir, parsedPatch, parsedBase]) => {
3980
- if (parsedPatch.error) {
3981
- errors.push({
3982
- ...parsedPatch.error,
3983
- parentPatchId: dir
3984
- });
3985
- } else if (parsedBase && parsedBase.error) {
3986
- errors.push({
3987
- ...parsedBase.error,
3988
- parentPatchId: dir
3989
- });
3990
- } else {
3991
- const patchId = parsedPatch.data.patchId;
3992
- if (includes && includes.length > 0 && !includes.includes(patchId)) {
3993
- return;
3994
- }
3995
- patches[patchId] = {
3996
- path: parsedPatch.data.path,
3997
- patch: parsedPatch.data.patch,
3998
- parentRef: parsedPatch.data.parentRef,
3999
- baseSha: parsedPatch.data.baseSha,
4000
- createdAt: parsedPatch.data.createdAt,
4001
- authorId: parsedPatch.data.authorId,
4002
- appliedAt: null
4003
- };
4004
- }
4005
- });
4006
4688
 
4007
- // If there are patches, but no head. error
4008
- if (Object.keys(errors).length > 0) {
4689
+ /**
4690
+ * The one read every other read comes out of.
4691
+ *
4692
+ * `getStat` announces from this and `fetchPatches` delivers from this, so the
4693
+ * two cannot disagree. They used to: `getStat` counted the directories on disk
4694
+ * while `fetchPatches` walked the parent links between them, and when a single
4695
+ * record went missing the first said 410 and the second said 359 — with no
4696
+ * error anywhere, because a walk that runs out of links just stops.
4697
+ *
4698
+ * Anything wrong is reported, then repaired, and reset only if repair does not
4699
+ * settle it. In that order, and never silently.
4700
+ */
4701
+ async readStore() {
4702
+ const read = readPatchStore(this.getPatchesDir());
4703
+ if (read.status === "unreadable") {
4009
4704
  return {
4010
- patches,
4011
- errors
4705
+ status: "error",
4706
+ message: read.message
4012
4707
  };
4013
4708
  }
4014
- return {
4015
- patches
4016
- };
4017
- }
4018
- getParentPatchIdFromParentRef(parentRef) {
4019
- return parentRef.type === "head" ? "head" : parentRef.patchId;
4709
+ if (read.problems.length === 0) {
4710
+ return {
4711
+ status: "ok",
4712
+ entries: read.entries
4713
+ };
4714
+ }
4715
+ return this.repairStore(read);
4020
4716
  }
4021
- async fetchPatches(filters) {
4022
- const fetchPatchesRes = await this.fetchPatchesFromFS(!!filters.excludePatchOps);
4023
- const sortedPatches = this.createPatchChain(fetchPatchesRes.patches).map(patchData => {
4024
- if (filters.excludePatchOps) {
4717
+
4718
+ /**
4719
+ * Report, repair, and reset only if repair did not take.
4720
+ *
4721
+ * Reached only when something is actually wrong, so the healthy path — which
4722
+ * is every stat poll — never touches the lock.
4723
+ */
4724
+ async repairStore(read) {
4725
+ const patchesDir = this.getPatchesDir();
4726
+ for (const problem of describePatchStoreProblems(read.problems)) {
4727
+ console.warn("Val: something is wrong with the patch store.", problem);
4728
+ }
4729
+ const locked = await withPatchLock(this.getPatchLockFile(), {
4730
+ ttlMs: 30_000,
4731
+ op: "repair the patch store"
4732
+ }, () => {
4733
+ // Read again under the lock. What was seen above was seen without it, so
4734
+ // a write may have landed since — and repairing against a stale picture
4735
+ // is how the old delete path destroyed live patches.
4736
+ const current = readPatchStore(patchesDir);
4737
+ if (current.status !== "ok") {
4738
+ return {
4739
+ status: "error",
4740
+ message: current.message
4741
+ };
4742
+ }
4743
+ if (current.problems.length === 0) {
4025
4744
  return {
4026
- ...patchData,
4027
- patch: undefined
4745
+ status: "ok",
4746
+ entries: current.entries
4028
4747
  };
4029
4748
  }
4030
- return patchData;
4031
- }).filter(patchData => {
4032
- if (filters.patchIds && filters.patchIds.length > 0) {
4033
- return filters.patchIds.includes(patchData.patchId);
4749
+ for (const action of repairPatchStore(patchesDir, current)) {
4750
+ if (action.type === "removed-unreadable-patch") {
4751
+ console.warn(`Val: removed the unpublished change ${action.name} because ${action.because}. See ${patchesDir}/${PATCH_REPAIR_LOG_FILE_NAME}.`);
4752
+ // Queued rather than only logged: this is someone's unpublished
4753
+ // work being thrown away, and a server console is not somewhere the
4754
+ // person editing is looking.
4755
+ this.removedPatchNotices.push({
4756
+ patchId: action.name,
4757
+ reason: action.because
4758
+ });
4759
+ } else if (action.type === "removed-orphan-directory") {
4760
+ // Not queued. Nothing ever read this - it is a record whose log line
4761
+ // never landed - so there is no edit to tell anyone about.
4762
+ console.warn(`Val: removed the unused patch directory ${action.name}.`);
4763
+ }
4034
4764
  }
4035
- return true;
4765
+ const after = readPatchStore(patchesDir);
4766
+ if (after.status === "ok" && after.problems.length === 0) {
4767
+ return {
4768
+ status: "ok",
4769
+ entries: after.entries
4770
+ };
4771
+ }
4772
+ // Repair did not settle it, so the store is not something anyone can
4773
+ // describe any more. Move it aside — a rename, never a delete — and say
4774
+ // where it went.
4775
+ const reset = resetPatchStore(patchesDir, "it could not be repaired automatically");
4776
+ if ("error" in reset) {
4777
+ return {
4778
+ status: "error",
4779
+ message: reset.error
4780
+ };
4781
+ }
4782
+ console.warn(`Val: the patch store could not be repaired, so it was reset. The previous contents are in ${reset.movedTo}.`);
4783
+ return {
4784
+ status: "ok",
4785
+ entries: []
4786
+ };
4036
4787
  });
4037
- return {
4038
- patches: sortedPatches,
4039
- errors: fetchPatchesRes.errors
4040
- };
4041
- }
4042
- async fetchPatchesFromFS(excludePatchOps) {
4043
- const patches = {};
4044
- const {
4045
- errors,
4046
- patches: allPatches
4047
- } = await this.readPatches();
4048
- for (const [patchIdS, patch] of Object.entries(allPatches)) {
4049
- const patchId = patchIdS;
4050
- patches[patchId] = {
4051
- patch: excludePatchOps ? undefined : patch.patch,
4052
- parentRef: patch.parentRef,
4053
- path: patch.path,
4054
- baseSha: patch.baseSha,
4055
- createdAt: patch.createdAt,
4056
- authorId: patch.authorId,
4057
- appliedAt: patch.appliedAt
4788
+ if (locked.status !== "ok") {
4789
+ // Someone else holds the lock, quite possibly to run this same repair.
4790
+ // Serving what was read is safe — those entries are the ones that ARE
4791
+ // readable — and the next read tries again.
4792
+ console.warn(`Val: could not repair the patch store now. ${locked.message}`);
4793
+ return {
4794
+ status: "ok",
4795
+ entries: read.entries
4058
4796
  };
4059
4797
  }
4060
- if (errors && errors.length > 0) {
4798
+ return locked.value;
4799
+ }
4800
+ async fetchPatches(filters) {
4801
+ const storeRes = await this.readStore();
4802
+ if (storeRes.status === "error") {
4803
+ // Surfaced, not returned as an empty list. An empty answer to "what is
4804
+ // pending" is indistinguishable from "nothing is pending", and the studio
4805
+ // would render published content over unpublished edits without a word.
4806
+ const none = [];
4061
4807
  return {
4062
- patches,
4063
- errors
4808
+ patches: none,
4809
+ error: {
4810
+ message: storeRes.message
4811
+ }
4812
+ // The cast is unavoidable: the return type is conditional on a generic
4813
+ // that TypeScript cannot narrow from a value.
4064
4814
  };
4065
4815
  }
4816
+ const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
4817
+ const patches = storeRes.entries.filter(entry => requested === null || requested.has(entry.patchId)).map(entry => ({
4818
+ patchId: entry.patchId,
4819
+ path: entry.record.path,
4820
+ patch: filters.excludePatchOps ? undefined : entry.record.patch,
4821
+ createdAt: entry.record.createdAt,
4822
+ authorId: entry.record.authorId,
4823
+ baseSha: entry.record.baseSha,
4824
+ // Publishing deletes the whole store, so in fs mode a patch that is
4825
+ // still here has not been applied. `base.json` is read for its parse
4826
+ // errors, not for this.
4827
+ appliedAt: null
4828
+ }));
4066
4829
  return {
4067
4830
  patches
4068
4831
  };
4069
4832
  }
4070
4833
 
4071
- // #region createPatchChain
4072
- createPatchChain(unsortedPatchRecord) {
4073
- var _Object$entries$find;
4074
- // TODO: Error handling
4075
- const nextPatch = {};
4076
- Object.keys(unsortedPatchRecord).forEach(patchId => {
4077
- const patch = unsortedPatchRecord[patchId];
4078
- if (patch.parentRef.type === "head") {
4079
- nextPatch["head"] = patchId;
4080
- } else {
4081
- nextPatch[patch.parentRef.patchId] = patchId;
4082
- }
4083
- });
4084
- const sortedPatches = [];
4085
- let nextPatchId = (_Object$entries$find = Object.entries(unsortedPatchRecord).find(([, patch]) => patch.parentRef.type === "head")) === null || _Object$entries$find === void 0 ? void 0 : _Object$entries$find[0];
4086
- while (!!nextPatchId && nextPatchId in unsortedPatchRecord) {
4087
- const patch = unsortedPatchRecord[nextPatchId];
4088
- delete patch["parentRef"];
4089
- sortedPatches.push({
4090
- ...patch,
4091
- patchId: nextPatchId
4092
- });
4093
- nextPatchId = nextPatch[nextPatchId];
4094
- }
4095
- return sortedPatches;
4096
- }
4097
-
4098
4834
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4099
4835
  parseJsonFile(filePath, parser) {
4100
4836
  if (!this.host.fileExists(filePath)) {
@@ -4169,53 +4905,60 @@ class ValOpsFS extends ValOps {
4169
4905
  };
4170
4906
  }
4171
4907
  }
4172
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
4173
- const patchDir = this.getParentPatchIdFromParentRef(parentRef);
4174
- try {
4175
- const baseSha = await this.getBaseSha();
4176
- const data = {
4177
- patch,
4178
- patchId,
4179
- parentRef,
4180
- path,
4181
- authorId,
4182
- sessionId,
4183
- baseSha,
4184
- coreVersion: core.Internal.VERSION.core,
4185
- createdAt: new Date().toISOString()
4186
- };
4187
- const headParentPatchId = "head";
4188
- if (patchDir !== headParentPatchId && !this.host.fileExists(this.getPatchFilePath(headParentPatchId))) {
4189
- console.error("Val: out-of-band patch detected.", this.getPatchFilePath(headParentPatchId));
4190
- return fp.result.err({
4191
- errorType: "patch-head-conflict"
4192
- });
4193
- }
4194
- const writeRes = this.host.tryWriteUf8File(this.getPatchFilePath(patchDir), JSON.stringify(data));
4195
- if (writeRes.type === "error") {
4908
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
4909
+ const patchesDir = this.getPatchesDir();
4910
+ const record = {
4911
+ patch,
4912
+ patchId,
4913
+ path,
4914
+ authorId,
4915
+ sessionId,
4916
+ baseSha: await this.getBaseSha(),
4917
+ coreVersion: core.Internal.VERSION.core,
4918
+ createdAt: new Date().toISOString()
4919
+ };
4920
+ /*
4921
+ * `parentRef` is ignored here, and that is the point of the log.
4922
+ *
4923
+ * It used to be the directory name, which is how a client working from a
4924
+ * stale view could write a patch whose parent had never landed and strand
4925
+ * every patch behind it. With the order held in one append-only list the
4926
+ * server decides where a patch goes: it goes last. There is no parent to
4927
+ * name, nothing to point at, and so nothing that can point at nothing.
4928
+ *
4929
+ * The parameter stays because `ValOpsHttp` genuinely needs it — the content
4930
+ * api runs its own chain and is told the parent — but in fs mode a stale
4931
+ * parent is not an error to refuse, it is a fact with no consequences.
4932
+ *
4933
+ * What that gives up is early detection of a patch computed against a
4934
+ * different state: two tabs editing the same array can produce an op that no
4935
+ * longer fits. That is caught where it actually shows — applying the patch —
4936
+ * and handled by dropping it and saying so, rather than by refusing writes
4937
+ * up front and making every interleaved edit a round trip.
4938
+ */
4939
+ const locked = await withPatchLock(this.getPatchLockFile(), {
4940
+ ttlMs: 10_000,
4941
+ op: "PUT /patches"
4942
+ }, () => {
4943
+ try {
4944
+ appendPatch(patchesDir, record);
4945
+ } catch (err) {
4196
4946
  return fp.result.err({
4197
4947
  errorType: "other",
4198
- error: writeRes.error,
4199
- message: "Failed to write patch file"
4948
+ message: `Failed to write the patch: ${err instanceof Error ? err.message : "unknown error"}`
4200
4949
  });
4201
4950
  }
4202
4951
  return fp.result.ok({
4203
4952
  patchId
4204
4953
  });
4205
- } catch (err) {
4206
- if (err instanceof Error) {
4207
- return fp.result.err({
4208
- errorType: "other",
4209
- error: err,
4210
- message: err.message
4211
- });
4212
- }
4954
+ });
4955
+ if (locked.status !== "ok") {
4213
4956
  return fp.result.err({
4214
4957
  errorType: "other",
4215
- error: err,
4216
- message: "Unknown error"
4958
+ message: locked.message
4217
4959
  });
4218
4960
  }
4961
+ return locked.value;
4219
4962
  }
4220
4963
  async getSourceFile(path) {
4221
4964
  const filePath = path__namespace["default"].join(this.rootDir, path);
@@ -4252,10 +4995,14 @@ class ValOpsFS extends ValOps {
4252
4995
  };
4253
4996
  }
4254
4997
  }
4255
- async saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, data, _type, metadata) {
4256
- const patchDir = this.getParentPatchIdFromParentRef(parentRef);
4257
- const patchFilePath = this.getBinaryFilePath(filePath, patchDir);
4258
- const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDir);
4998
+ async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
4999
+ // Keyed by the patch's own id, so the parent is not needed and is not asked
5000
+ // for. Uploads arrive before the patch record does, which is fine: the
5001
+ // directory sits there unreferenced until the log line that names it lands,
5002
+ // and repair sweeps it up if that never happens.
5003
+ const patchesDir = this.getPatchesDir();
5004
+ const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
5005
+ const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
4259
5006
  try {
4260
5007
  if (data === null) {
4261
5008
  this.host.deleteFile(patchFilePath);
@@ -4295,15 +5042,7 @@ class ValOpsFS extends ValOps {
4295
5042
  }
4296
5043
  }
4297
5044
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
4298
- const patchDirRes = await this.getParentPatchIdFromPatchId(patchId);
4299
- if (fp.result.isErr(patchDirRes)) {
4300
- return {
4301
- errors: [{
4302
- message: "Failed to get patch dir from patch id"
4303
- }]
4304
- };
4305
- }
4306
- const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDirRes.value);
5045
+ const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
4307
5046
  if (!this.host.fileExists(metadataFilePath)) {
4308
5047
  return {
4309
5048
  errors: [{
@@ -4339,95 +5078,132 @@ class ValOpsFS extends ValOps {
4339
5078
  };
4340
5079
  }
4341
5080
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
4342
- const patchDirRes = await this.getParentPatchIdFromPatchId(patchId);
4343
- if (!fp.result.isOk(patchDirRes)) {
4344
- return null;
4345
- }
4346
- const absPath = this.getBinaryFilePath(filePath, patchDirRes.value);
5081
+ // Straight from the id. This used to read and parse every patch on disk to
5082
+ // work out which directory the file was under, on every single image request.
5083
+ const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
4347
5084
  if (!this.host.fileExists(absPath)) {
4348
5085
  return null;
4349
5086
  }
4350
5087
  return this.host.readBinaryFile(absPath);
4351
5088
  }
4352
5089
  async deletePatches(patchIds) {
4353
- const deleted = [];
4354
- const patchDirMapRes = await this.getParentPatchIdFromPatchIdMap();
4355
- if (fp.result.isErr(patchDirMapRes)) {
5090
+ const patchesDir = this.getPatchesDir();
5091
+ const requested = new Set(patchIds);
5092
+ const locked = await withPatchLock(this.getPatchLockFile(), {
5093
+ ttlMs: 30_000,
5094
+ op: "DELETE /patches"
5095
+ }, () => {
5096
+ const read = readPatchStore(patchesDir);
5097
+ if (read.status !== "ok") {
5098
+ return {
5099
+ status: "error",
5100
+ message: read.message
5101
+ };
5102
+ }
5103
+ /*
5104
+ * Deleting is now dropping lines from a list.
5105
+ *
5106
+ * It used to be a three-step re-link per surviving patch — rewrite a
5107
+ * record, remove the destination directory, rename another one over it —
5108
+ * with the plan computed from a snapshot taken before hundreds of file
5109
+ * reads, every failure swallowed by a `console.error`, and one step that
5110
+ * unconditionally removed whatever occupied the destination. An
5111
+ * interruption anywhere in it stranded every patch after the hole.
5112
+ */
5113
+ writePatchLogFile(patchesLogFile(patchesDir), read.entries.filter(entry => !requested.has(entry.patchId)).map(entry => ({
5114
+ patchId: entry.patchId,
5115
+ createdAt: entry.record.createdAt,
5116
+ path: entry.record.path
5117
+ })));
5118
+ // The log first, then the directories. A crash in between leaves
5119
+ // directories nothing names, which is inert and swept up on the next
5120
+ // read; the other order would leave the log naming patches that are gone.
5121
+ const deleted = [];
5122
+ const errors = {};
5123
+ for (const patchId of patchIds) {
5124
+ try {
5125
+ fs__default["default"].rmSync(patchDir(patchesDir, patchId), {
5126
+ recursive: true,
5127
+ force: true
5128
+ });
5129
+ deleted.push(patchId);
5130
+ } catch (err) {
5131
+ // Reported. This endpoint used to answer "deleted" unconditionally —
5132
+ // `deleted` and `errors` were never written to at all — so a delete
5133
+ // that failed looked exactly like one that worked.
5134
+ errors[patchId] = {
5135
+ message: `Could not remove the files for ${patchId}: ${err instanceof Error ? err.message : "unknown error"}`
5136
+ };
5137
+ }
5138
+ }
5139
+ return {
5140
+ status: "ok",
5141
+ deleted,
5142
+ errors
5143
+ };
5144
+ });
5145
+ if (locked.status !== "ok") {
4356
5146
  return {
4357
5147
  error: {
4358
- message: "Failed to get patch dir map"
5148
+ message: locked.message
4359
5149
  }
4360
5150
  };
4361
5151
  }
4362
- const currentPatches = this.createPatchChain((await this.fetchPatchesFromFS(false)).patches);
4363
- this.updateOrderedPatches(computeChangedPatchParentRefs(currentPatches, patchIds), patchDirMapRes.value, patchIds);
5152
+ if (locked.value.status === "error") {
5153
+ return {
5154
+ error: {
5155
+ message: locked.value.message
5156
+ }
5157
+ };
5158
+ }
5159
+ const {
5160
+ deleted,
5161
+ errors
5162
+ } = locked.value;
5163
+ if (Object.keys(errors).length > 0) {
5164
+ return {
5165
+ deleted,
5166
+ errors
5167
+ };
5168
+ }
4364
5169
  return {
4365
5170
  deleted
4366
5171
  };
4367
5172
  }
4368
5173
  async deleteAllPatches() {
4369
- const patchesCacheDir = this.getPatchesDir();
4370
- const tmpDir = path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, "patches-deleted-" + crypto.randomUUID());
4371
- try {
4372
- this.host.moveDir(patchesCacheDir, tmpDir);
4373
- this.host.deleteDir(tmpDir);
4374
- return {};
4375
- } catch (err) {
4376
- if (err instanceof Error) {
5174
+ const patchesDir = this.getPatchesDir();
5175
+ const locked = await withPatchLock(this.getPatchLockFile(), {
5176
+ ttlMs: 30_000,
5177
+ op: "delete all patches"
5178
+ }, () => {
5179
+ if (!fs__default["default"].existsSync(patchesDir)) {
5180
+ return {};
5181
+ }
5182
+ const tmpDir = path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, "patches-deleted-" + crypto.randomUUID());
5183
+ try {
5184
+ // One rename takes the log and the directories together, so the store
5185
+ // is empty and self-consistent from the first instant rather than
5186
+ // part-way through. It is also the cleanup route for a store this
5187
+ // version refuses to read: discarding everything always works.
5188
+ this.host.moveDir(patchesDir, tmpDir);
5189
+ this.host.deleteDir(tmpDir);
5190
+ return {};
5191
+ } catch (err) {
4377
5192
  return {
4378
5193
  error: {
4379
- message: `Got an error while deleting patches: ${err.message}`
5194
+ message: `Got an error while deleting patches: ${err instanceof Error ? err.message : "unknown error"}`
4380
5195
  }
4381
5196
  };
4382
5197
  }
5198
+ });
5199
+ if (locked.status !== "ok") {
4383
5200
  return {
4384
5201
  error: {
4385
- message: "Got an unexpected error while deleting patches"
5202
+ message: locked.message
4386
5203
  }
4387
5204
  };
4388
5205
  }
4389
- }
4390
- updateOrderedPatches(updates, patchDirMap, deletePatchIds) {
4391
- for (const patchId of deletePatchIds) {
4392
- const patchDir = patchDirMap[patchId];
4393
- if (!patchDir) {
4394
- console.error("Could not find patch dir for patch id scheduled for deletion: ", patchId);
4395
- continue;
4396
- }
4397
- try {
4398
- this.host.deleteDir(this.getFullPatchDir(patchDir));
4399
- } catch (err) {
4400
- console.error("Failed to delete patch dir", err);
4401
- }
4402
- }
4403
- for (const [patchIdS, parentRef] of Object.entries(updates.changedPatches)) {
4404
- const prevParentPatchId = patchDirMap[patchIdS];
4405
- if (!prevParentPatchId) {
4406
- console.error("Could not find previous parent patch id for deleted patch id: ", patchIdS);
4407
- continue;
4408
- }
4409
- const newParentPatchId = parentRef.type === "head" ? "head" : parentRef.patchId;
4410
- const currentPatchDataRes = this.parseJsonFile(this.getPatchFilePath(prevParentPatchId), FSPatch);
4411
- if (currentPatchDataRes.error) {
4412
- console.error("Failed to parse patch file while fixing patch chain after deleted patch", {
4413
- updates
4414
- }, currentPatchDataRes.error);
4415
- continue;
4416
- }
4417
- const newPatchData = currentPatchDataRes.data;
4418
- newPatchData.parentRef = parentRef;
4419
- try {
4420
- this.host.writeUf8File(this.getPatchFilePath(prevParentPatchId), JSON.stringify(newPatchData));
4421
- if (this.host.directoryExists(this.getFullPatchDir(newParentPatchId))) {
4422
- this.host.deleteDir(this.getFullPatchDir(newParentPatchId));
4423
- }
4424
- this.host.moveDir(this.getFullPatchDir(prevParentPatchId), this.getFullPatchDir(newParentPatchId));
4425
- } catch (err) {
4426
- console.error("Failed fix patch chain after deleted patch", {
4427
- updates
4428
- }, err);
4429
- }
4430
- }
5206
+ return locked.value;
4431
5207
  }
4432
5208
  async saveOrUploadFiles(preparedCommit, mode, auth) {
4433
5209
  const updatedFiles = [];
@@ -4490,30 +5266,13 @@ class ValOpsFS extends ValOps {
4490
5266
  uploadedRemoteRefs.push(ref);
4491
5267
  }
4492
5268
  }
4493
- const patchIdToPatchDirMapRes = await this.getParentPatchIdFromPatchIdMap();
4494
- if (fp.result.isErr(patchIdToPatchDirMapRes)) {
4495
- return {
4496
- updatedFiles,
4497
- uploadedRemoteRefs,
4498
- errors
4499
- };
4500
- }
4501
- const patchIdToPatchDirMap = patchIdToPatchDirMapRes.value;
4502
5269
  for (const [ref, {
4503
5270
  patchId
4504
5271
  }] of localFileDescriptors) {
4505
5272
  const filePath = ref;
4506
5273
  const absPath = path__namespace["default"].join(this.rootDir, ...filePath.split("/"));
4507
5274
  try {
4508
- const patchDir = patchIdToPatchDirMap[patchId];
4509
- if (!patchDir) {
4510
- errors[absPath] = {
4511
- message: "Failed to find PatchDir for PatchId " + patchId,
4512
- filePath: filePath
4513
- };
4514
- continue;
4515
- }
4516
- this.host.copyFile(this.getBinaryFilePath(filePath, patchDir), absPath);
5275
+ this.host.copyFile(patchBinaryFile(this.getPatchesDir(), patchId, filePath), absPath);
4517
5276
  updatedFiles.push(absPath);
4518
5277
  } catch (err) {
4519
5278
  errors[absPath] = {
@@ -4543,14 +5302,7 @@ class ValOpsFS extends ValOps {
4543
5302
  baseSha: await this.getBaseSha(),
4544
5303
  timestamp: new Date().toISOString()
4545
5304
  };
4546
- const patchDir = patchIdToPatchDirMap[patchId];
4547
- if (!patchDir) {
4548
- errors[`patchId:${patchId}`] = {
4549
- message: "Failed to find PatchDir for PatchId " + patchId
4550
- };
4551
- continue;
4552
- }
4553
- const absPath = this.getPatchBaseFile(patchDir);
5305
+ const absPath = patchBaseFile(this.getPatchesDir(), patchId);
4554
5306
  try {
4555
5307
  this.host.writeUf8File(absPath, JSON.stringify(appliedAt));
4556
5308
  } catch (err) {
@@ -4595,47 +5347,18 @@ class ValOpsFS extends ValOps {
4595
5347
  }
4596
5348
  return createMetadataFromBuffer(type, mimeType, buffer);
4597
5349
  }
4598
- async getParentPatchIdFromPatchId(patchId) {
4599
- // This is not great. If needed we should find a better way
4600
- const patches = await this.readPatches();
4601
- if (patches.errors || patches.error) {
4602
- console.error("Failed to read patches", JSON.stringify(patches));
4603
- return fp.result.err("failed-to-read-patches");
4604
- }
4605
- const patch = patches.patches[patchId];
4606
- if (!patch) {
4607
- console.error("Could not find patch with patchId: ", patchId);
4608
- return fp.result.err("patch-not-found");
4609
- }
4610
- return fp.result.ok(this.getParentPatchIdFromParentRef(patch.parentRef));
4611
- }
4612
- async getParentPatchIdFromPatchIdMap() {
4613
- const patches = await this.readPatches();
4614
- if (patches.errors || patches.error) {
4615
- console.error("Failed to read patches", JSON.stringify(patches));
4616
- return fp.result.err("failed-to-read-patches");
4617
- }
4618
- return fp.result.ok(Object.fromEntries(Object.entries(patches.patches).map(([patchId, value]) => [patchId, this.getParentPatchIdFromParentRef(value.parentRef)])));
4619
- }
4620
5350
 
4621
5351
  // #region fs file path helpers
4622
5352
  getPatchesDir() {
4623
5353
  return path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, "patches");
4624
5354
  }
4625
- getFullPatchDir(patchDir) {
4626
- return path__namespace["default"].join(this.getPatchesDir(), patchDir);
4627
- }
4628
- getBinaryFilePath(filePath, patchDir) {
4629
- return path__namespace["default"].join(this.getFullPatchDir(patchDir), "files", filePath, path__namespace["default"].basename(filePath));
4630
- }
4631
- getBinaryFileMetadataPath(filePath, patchDir) {
4632
- return path__namespace["default"].join(this.getFullPatchDir(patchDir), "files", filePath, "metadata.json");
4633
- }
4634
- getPatchFilePath(patchDir) {
4635
- return path__namespace["default"].join(this.getFullPatchDir(patchDir), "patch.json");
4636
- }
4637
- getPatchBaseFile(patchDir) {
4638
- return path__namespace["default"].join(this.getFullPatchDir(patchDir), "base.json");
5355
+
5356
+ /**
5357
+ * Deliberately outside the patches directory: delete-all and reset rename that
5358
+ * whole directory, and a lock that moves away with it is not holding anything.
5359
+ */
5360
+ getPatchLockFile() {
5361
+ return path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
4639
5362
  }
4640
5363
  }
4641
5364
  class FSOpsHost {
@@ -4715,27 +5438,11 @@ class FSOpsHost {
4715
5438
  fs__default["default"].mkdirSync(path__namespace["default"].dirname(to), {
4716
5439
  recursive: true
4717
5440
  });
4718
- fs__default["default"].copyFileSync(from, to);
4719
- }
4720
- }
4721
- const FSPatch = z.z.object({
4722
- path: z.z.string().refine(p => p.startsWith("/") && p.includes(".val."), "Path is not valid. Must start with '/' and include '.val.'"),
4723
- patch: internal.Patch,
4724
- patchId: z.z.string(),
4725
- baseSha: z.z.string(),
4726
- parentRef: internal.ParentRef,
4727
- authorId: z.z.string().refine(p => true).nullable(),
4728
- createdAt: z.z.string().datetime(),
4729
- coreVersion: z.z.string().nullable(),
4730
- // TODO: use this to check if patch is compatible with current core version?
4731
- sessionId: z.z.string().nullable()
4732
- });
4733
- const FSPatchBase = z.z.object({
4734
- baseSha: z.z.string().refine(p => true),
4735
- timestamp: z.z.string().datetime()
4736
- });
5441
+ fs__default["default"].copyFileSync(from, to);
5442
+ }
5443
+ }
4737
5444
 
4738
- const textEncoder$1 = new TextEncoder();
5445
+ const textEncoder$2 = new TextEncoder();
4739
5446
  const PatchId = z.z.string().refine(s => !!s); // TODO: validate
4740
5447
  const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
4741
5448
  z.z.string().refine(s => !!s); // TODO: validate
@@ -5426,7 +6133,7 @@ class ValOpsHttp extends ValOps {
5426
6133
  });
5427
6134
  params.set("body_sha",
5428
6135
  // We use this for cache invalidation
5429
- core.Internal.getSHA256Hash(textEncoder$1.encode(stringifiedFiles)));
6136
+ core.Internal.getSHA256Hash(textEncoder$2.encode(stringifiedFiles)));
5430
6137
  return fetch(`${this.contentUrl}/v1/${this.project}/files?${params}`, {
5431
6138
  method: "PUT",
5432
6139
  // Yes, PUT is weird. Weirder to have a body in a GET request.
@@ -6759,7 +7466,7 @@ const ValServer = (valModules, options, callbacks) => {
6759
7466
  status: 409,
6760
7467
  json: {
6761
7468
  type: "patch-head-conflict",
6762
- message: "Patch id conflict"
7469
+ message: "The change was written against a different starting point than the server has."
6763
7470
  }
6764
7471
  };
6765
7472
  } else {
@@ -9154,15 +9861,21 @@ _input // TODO: use buffer to determine mimetype
9154
9861
  };
9155
9862
  }
9156
9863
 
9864
+ /**
9865
+ * The media path a validation error is about, if it is about media at all.
9866
+ *
9867
+ * There is no schema in hand here — a `ValidationError` carries only the value
9868
+ * it flagged — so the shape is all there is to go on.
9869
+ */
9157
9870
  function getValidationErrorFileRef(validationError) {
9158
- const maybeRef = validationError.value && typeof validationError.value === "object" && core.FILE_REF_PROP in validationError.value && typeof validationError.value[core.FILE_REF_PROP] === "string" ? validationError.value[core.FILE_REF_PROP] : undefined;
9159
- if (!maybeRef) {
9871
+ const maybePath = validationError.value && typeof validationError.value === "object" && "path" in validationError.value && typeof validationError.value.path === "string" ? validationError.value.path : undefined;
9872
+ if (!maybePath) {
9160
9873
  return null;
9161
9874
  }
9162
- return maybeRef;
9875
+ return maybePath;
9163
9876
  }
9164
9877
 
9165
- const textEncoder = new TextEncoder();
9878
+ const textEncoder$1 = new TextEncoder();
9166
9879
  async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
9167
9880
  const remoteRefDataRes = core.Internal.remote.splitRemoteRef(ref);
9168
9881
  if (remoteRefDataRes.status === "error") {
@@ -9192,10 +9905,10 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
9192
9905
  const fileExt = getFileExt(relativeFilePath);
9193
9906
  const currentValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata,
9194
9907
  // TODO: validate
9195
- fileHash, textEncoder);
9908
+ fileHash, textEncoder$1);
9196
9909
  const validationHashFromRemoteRefValues = core.Internal.remote.getValidationHash(remoteRefDataRes.version, schema, getFileExt(remoteRefDataRes.filePath), metadata,
9197
9910
  // TODO: validate
9198
- fileHash, textEncoder);
9911
+ fileHash, textEncoder$1);
9199
9912
  if (
9200
9913
  // Current validation hash is the same as the remote validation hash
9201
9914
  // We assume the uploaded file exists and is correct
@@ -9241,7 +9954,7 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
9241
9954
  ...metadata,
9242
9955
  ...currentMetadata
9243
9956
  };
9244
- const nextValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, updatedMetadata, fileBufferRes.fileHash, textEncoder);
9957
+ const nextValidationHash = core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, updatedMetadata, fileBufferRes.fileHash, textEncoder$1);
9245
9958
  if (!updatedMetadata.mimeType) {
9246
9959
  return {
9247
9960
  status: "error",
@@ -9371,6 +10084,36 @@ function parentPatchPathOfMediaEntry(sourcePath, entryKey) {
9371
10084
  }
9372
10085
 
9373
10086
  // TODO: find a better name? transformFixesToPatch?
10087
+ /**
10088
+ * A whole media value, for the fixes that replace one outright.
10089
+ *
10090
+ * The three remote fixes each wrote this shape out by hand, and one of them was
10091
+ * left behind when the shape changed. One name, so the next change cannot miss a
10092
+ * site.
10093
+ *
10094
+ * `path` last is deliberate: it is the field the fix is about, and a stray
10095
+ * `path` inside the metadata must not win over it.
10096
+ */
10097
+ function mediaValue(path, metadata) {
10098
+ const fields = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
10099
+ return {
10100
+ ...fields,
10101
+ path
10102
+ };
10103
+ }
10104
+
10105
+ /**
10106
+ * The media value a validation error flagged, read as a plain record.
10107
+ *
10108
+ * A `ValidationError` carries only a value, never the schema that rejected it,
10109
+ * so the shape is all there is to go on here.
10110
+ */
10111
+ function mediaValueOf(value) {
10112
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
10113
+ return undefined;
10114
+ }
10115
+ return value;
10116
+ }
9374
10117
  async function createFixPatch(config, apply, sourcePath, validationError, remoteFiles, moduleSource, moduleSchema) {
9375
10118
  const remainingErrors = [];
9376
10119
  const patch$1 = [];
@@ -9384,57 +10127,38 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9384
10127
  fixes: undefined
9385
10128
  });
9386
10129
  } else if (fix === "image:check-metadata") {
9387
- const currentValue = validationError.value;
9388
- const metadataIsCorrect =
9389
- // metadata is a prop that is an object
9390
- typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object" &&
9391
- // width is correct
9392
- "width" in currentValue.metadata && currentValue.metadata.width === imageMetadata.width &&
9393
- // height is correct
9394
- "height" in currentValue.metadata && currentValue.metadata.height === imageMetadata.height &&
9395
- // mimeType is correct
9396
- "mimeType" in currentValue.metadata && currentValue.metadata.mimeType === imageMetadata.mimeType;
9397
- // skips if the metadata is already correct
9398
- if (!metadataIsCorrect) {
10130
+ const currentValue = mediaValueOf(validationError.value);
10131
+ const derived = {
10132
+ width: imageMetadata.width,
10133
+ height: imageMetadata.height,
10134
+ mimeType: imageMetadata.mimeType
10135
+ };
10136
+ // One op per field, not one for the whole object: `alt` and `hotspot`
10137
+ // are authored and sit next to these, so a whole-object write would
10138
+ // throw away what a person typed.
10139
+ const wrong = Object.entries(derived).filter(([field, expected]) => (currentValue === null || currentValue === void 0 ? void 0 : currentValue[field]) !== expected);
10140
+ if (wrong.length > 0) {
9399
10141
  if (apply) {
9400
- patch$1.push({
9401
- op: "replace",
9402
- path: patch.sourceToPatchPath(sourcePath).concat("metadata"),
9403
- value: {
9404
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9405
- ...currentValue.metadata,
9406
- width: imageMetadata.width,
9407
- height: imageMetadata.height,
9408
- mimeType: imageMetadata.mimeType
9409
- }
9410
- });
9411
- } else {
9412
- if (typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object") {
9413
- if (!("width" in currentValue.metadata) || currentValue.metadata.width !== imageMetadata.width) {
9414
- remainingErrors.push({
9415
- message: "Image metadata width is incorrect! Found: " + ("width" in currentValue.metadata ? currentValue.metadata.width : "<empty>") + ". Expected: " + imageMetadata.width,
9416
- fixes: undefined
9417
- });
9418
- }
9419
- if (!("height" in currentValue.metadata) || currentValue.metadata.height !== imageMetadata.height) {
9420
- remainingErrors.push({
9421
- message: "Image metadata height is incorrect! Found: " + ("height" in currentValue.metadata ? currentValue.metadata.height : "<empty>") + ". Expected: " + imageMetadata.height,
9422
- fixes: undefined
9423
- });
9424
- }
9425
- if (!("mimeType" in currentValue.metadata) || currentValue.metadata.mimeType !== imageMetadata.mimeType) {
9426
- remainingErrors.push({
9427
- message: "Image metadata mimeType is incorrect! Found: " + ("mimeType" in currentValue.metadata ? currentValue.metadata.mimeType : "<empty>") + ". Expected: " + imageMetadata.mimeType,
9428
- fixes: undefined
9429
- });
9430
- }
9431
- } else {
10142
+ for (const [field, expected] of wrong) {
10143
+ patch$1.push({
10144
+ op: "add",
10145
+ path: patch.sourceToPatchPath(sourcePath).concat(field),
10146
+ value: expected
10147
+ });
10148
+ }
10149
+ } else if (currentValue) {
10150
+ for (const [field, expected] of wrong) {
9432
10151
  remainingErrors.push({
9433
- ...validationError,
9434
- message: "Image metadata is not an object!",
10152
+ message: `Image ${field} is incorrect! Found: ` + (currentValue[field] ?? "<empty>") + ". Expected: " + expected,
9435
10153
  fixes: undefined
9436
10154
  });
9437
10155
  }
10156
+ } else {
10157
+ remainingErrors.push({
10158
+ ...validationError,
10159
+ message: "Image is not an object!",
10160
+ fixes: undefined
10161
+ });
9438
10162
  }
9439
10163
  }
9440
10164
  } else if (fix === "image:add-metadata") {
@@ -9445,14 +10169,19 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9445
10169
  fixes: undefined
9446
10170
  });
9447
10171
  } else {
10172
+ const patchPath = patch.sourceToPatchPath(sourcePath);
9448
10173
  patch$1.push({
9449
10174
  op: "add",
9450
- path: patch.sourceToPatchPath(sourcePath).concat("metadata"),
9451
- value: {
9452
- width: imageMetadata.width,
9453
- height: imageMetadata.height,
9454
- mimeType: imageMetadata.mimeType
9455
- }
10175
+ path: patchPath.concat("width"),
10176
+ value: imageMetadata.width
10177
+ }, {
10178
+ op: "add",
10179
+ path: patchPath.concat("height"),
10180
+ value: imageMetadata.height
10181
+ }, {
10182
+ op: "add",
10183
+ path: patchPath.concat("mimeType"),
10184
+ value: imageMetadata.mimeType
9456
10185
  });
9457
10186
  }
9458
10187
  }
@@ -9465,53 +10194,32 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9465
10194
  fixes: undefined
9466
10195
  });
9467
10196
  } else if (fix === "file:check-metadata") {
9468
- const currentValue = validationError.value;
9469
- const metadataIsCorrect =
9470
- // metadata is a prop that is an object
9471
- typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object" &&
9472
- // mimeType is correct
9473
- "mimeType" in currentValue.metadata && currentValue.metadata.mimeType === fileMetadata.mimeType;
9474
-
9475
- // skips if the metadata is already correct
9476
- if (!metadataIsCorrect) {
10197
+ const currentValue = mediaValueOf(validationError.value);
10198
+ if ((currentValue === null || currentValue === void 0 ? void 0 : currentValue.mimeType) !== fileMetadata.mimeType) {
9477
10199
  if (apply) {
9478
10200
  patch$1.push({
9479
- op: "replace",
9480
- path: patch.sourceToPatchPath(sourcePath).concat("metadata"),
9481
- value: {
9482
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9483
- ...currentValue.metadata,
9484
- ...(fileMetadata.mimeType ? {
9485
- mimeType: fileMetadata.mimeType
9486
- } : {})
9487
- }
10201
+ op: "add",
10202
+ path: patch.sourceToPatchPath(sourcePath).concat("mimeType"),
10203
+ value: fileMetadata.mimeType ?? null
10204
+ });
10205
+ } else if (currentValue) {
10206
+ remainingErrors.push({
10207
+ message: "File mimeType is incorrect! Found: " + (currentValue.mimeType ?? "<empty>") + ". Expected: " + fileMetadata.mimeType,
10208
+ fixes: undefined
9488
10209
  });
9489
10210
  } else {
9490
- if (typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object") {
9491
- if (!("mimeType" in currentValue.metadata) || currentValue.metadata.mimeType !== fileMetadata.mimeType) {
9492
- remainingErrors.push({
9493
- message: "File metadata mimeType is incorrect! Found: " + ("mimeType" in currentValue.metadata ? currentValue.metadata.mimeType : "<empty>") + ". Expected: " + fileMetadata.mimeType,
9494
- fixes: undefined
9495
- });
9496
- }
9497
- } else {
9498
- remainingErrors.push({
9499
- ...validationError,
9500
- message: "Image metadata is not an object!",
9501
- fixes: undefined
9502
- });
9503
- }
10211
+ remainingErrors.push({
10212
+ ...validationError,
10213
+ message: "File is not an object!",
10214
+ fixes: undefined
10215
+ });
9504
10216
  }
9505
10217
  }
9506
10218
  } else if (fix === "file:add-metadata") {
9507
10219
  patch$1.push({
9508
10220
  op: "add",
9509
- path: patch.sourceToPatchPath(sourcePath).concat("metadata"),
9510
- value: {
9511
- ...(fileMetadata.mimeType ? {
9512
- mimeType: fileMetadata.mimeType
9513
- } : {})
9514
- }
10221
+ path: patch.sourceToPatchPath(sourcePath).concat("mimeType"),
10222
+ value: fileMetadata.mimeType ?? null
9515
10223
  });
9516
10224
  }
9517
10225
  } else if (fix === "image:upload-remote" || fix === "file:upload-remote") {
@@ -9539,11 +10247,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9539
10247
  } else {
9540
10248
  patch$1.push({
9541
10249
  op: "replace",
9542
- value: {
9543
- _type: "remote",
9544
- _ref: remoteFile.ref,
9545
- metadata
9546
- },
10250
+ value: mediaValue(remoteFile.ref, metadata),
9547
10251
  path: patch.sourceToPatchPath(sourcePath)
9548
10252
  });
9549
10253
  }
@@ -9612,7 +10316,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9612
10316
  });
9613
10317
  continue;
9614
10318
  }
9615
- const splitRemoteRefDataRes = core.Internal.remote.splitRemoteRef(v._ref);
10319
+ const splitRemoteRefDataRes = core.Internal.remote.splitRemoteRef(v.path);
9616
10320
  if (splitRemoteRefDataRes.status === "error") {
9617
10321
  remainingErrors.push({
9618
10322
  ...validationError,
@@ -9621,7 +10325,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9621
10325
  });
9622
10326
  continue;
9623
10327
  }
9624
- const url = v._ref;
10328
+ const url = v.path;
9625
10329
  const filePath = splitRemoteRefDataRes.filePath;
9626
10330
  if (!filePath) {
9627
10331
  remainingErrors.push({
@@ -9652,20 +10356,10 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9652
10356
  });
9653
10357
  continue;
9654
10358
  }
9655
- const value = {
9656
- [core.VAL_EXTENSION]: "file",
9657
- [core.FILE_REF_PROP]: `/${filePath}`,
9658
- ...(fix === "image:download-remote" ? {
9659
- [core.FILE_REF_SUBTYPE_TAG]: "image"
9660
- } : {})
9661
- };
9662
10359
  patch$1.push({
9663
10360
  op: "replace",
9664
10361
  path: patch.sourceToPatchPath(sourcePath),
9665
- value: v.metadata ? {
9666
- ...value,
9667
- metadata: v.metadata
9668
- } : value
10362
+ value: mediaValue(`/${filePath}`, v.metadata)
9669
10363
  });
9670
10364
  } else if (fix === "images:check-all-files" || fix === "files:check-all-files") {
9671
10365
  if (!moduleSource || typeof moduleSource !== "object") {
@@ -9788,7 +10482,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9788
10482
  schema: schemaAtPath
9789
10483
  } = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
9790
10484
  if (schemaAtPath.type === "image" || schemaAtPath.type === "file") {
9791
- const res = await checkRemoteRef(config.remoteHost, v._ref, config.projectRoot, schemaAtPath, v.metadata);
10485
+ const res = await checkRemoteRef(config.remoteHost, v.path, config.projectRoot, schemaAtPath, v.metadata);
9792
10486
  if (res.status === "success") ; else if (res.status === "error") {
9793
10487
  remainingErrors.push({
9794
10488
  ...validationError,
@@ -9800,11 +10494,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9800
10494
  patch$1.push({
9801
10495
  op: "replace",
9802
10496
  path: patch.sourceToPatchPath(sourcePath),
9803
- value: {
9804
- _type: "remote",
9805
- _ref: res.ref,
9806
- metadata: res.metadata
9807
- }
10497
+ value: mediaValue(res.ref, res.metadata)
9808
10498
  });
9809
10499
  } else {
9810
10500
  remainingErrors.push({
@@ -9830,80 +10520,695 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9830
10520
  }
9831
10521
  }
9832
10522
  }
9833
- if (!validationError.fixes || validationError.fixes.length === 0) {
9834
- remainingErrors.push(validationError);
10523
+ if (!validationError.fixes || validationError.fixes.length === 0) {
10524
+ remainingErrors.push(validationError);
10525
+ }
10526
+ return {
10527
+ patch: patch$1,
10528
+ remainingErrors
10529
+ };
10530
+ }
10531
+ function getRemoteValueFromValidationError(v) {
10532
+ if (v.value && typeof v.value !== "object") {
10533
+ return {
10534
+ success: false,
10535
+ message: "Unexpected error while checking remote (not an object)"
10536
+ };
10537
+ }
10538
+ if (!v.value) {
10539
+ return {
10540
+ success: false,
10541
+ message: "Unexpected error while checking remote (no value)"
10542
+ };
10543
+ }
10544
+ if (typeof v.value !== "object" || v.value === null || !("path" in v.value)) {
10545
+ return {
10546
+ success: false,
10547
+ message: "Unexpected error while checking remote (no path in value)"
10548
+ };
10549
+ }
10550
+ if (typeof v.value.path !== "string") {
10551
+ return {
10552
+ success: false,
10553
+ message: "Unexpected error while checking remote (path is not a string)"
10554
+ };
10555
+ }
10556
+ // Everything but the path is what Val read from the bytes.
10557
+ const {
10558
+ path,
10559
+ ...metadata
10560
+ } = v.value;
10561
+ return {
10562
+ success: true,
10563
+ path,
10564
+ metadata
10565
+ };
10566
+ }
10567
+ async function getImageMetadata(projectRoot, validationError) {
10568
+ const fileRef = getValidationErrorFileRef(validationError);
10569
+ if (!fileRef) {
10570
+ // TODO:
10571
+ throw Error("Cannot fix image without a file reference");
10572
+ }
10573
+ const filename = path__namespace["default"].join(projectRoot, fileRef);
10574
+ const buffer = fs__default["default"].readFileSync(filename);
10575
+ return extractImageMetadata(filename, buffer);
10576
+ }
10577
+ async function getFileMetadata(projectRoot, validationError) {
10578
+ const fileRef = getValidationErrorFileRef(validationError);
10579
+ if (!fileRef) {
10580
+ // TODO:
10581
+ throw Error("Cannot fix file without a file reference");
10582
+ }
10583
+ const filename = path__namespace["default"].join(projectRoot, fileRef);
10584
+ fs__default["default"].readFileSync(filename);
10585
+ return extractFileMetadata(fileRef);
10586
+ }
10587
+
10588
+ /**
10589
+ * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
10590
+ * its own `*.val.json`, replacing the inline value with
10591
+ * `c.json(() => import("./<key>.val.json"))`.
10592
+ *
10593
+ * This is the fix for the `jsonValues:extract-entry` validation error. It is not
10594
+ * expressible as a patch (a patch edits one `.val.ts` and cannot create the
10595
+ * backing JSON file), so it writes both files directly — JSON first, so a
10596
+ * failure part-way through never leaves the module pointing at a file that does
10597
+ * not exist.
10598
+ *
10599
+ * Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
10600
+ * up in the module's root record/router object literal.
10601
+ */
10602
+ function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
10603
+ const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
10604
+ const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
10605
+ if (!sourceFile) {
10606
+ throw Error(`Source file ${valTsPath} not found`);
10607
+ }
10608
+ const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
10609
+ if (fp.result.isErr(pathsRes)) {
10610
+ throw Error(formatPatchSourceError(pathsRes.error));
10611
+ }
10612
+ const {
10613
+ jsonPath,
10614
+ importPath
10615
+ } = pathsRes.value;
10616
+ const absoluteJsonPath = path__namespace["default"].join(rootDir, jsonPath);
10617
+ if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
10618
+ throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
10619
+ }
10620
+
10621
+ // Remove the inline property, then add the `c.json(...)` reference back. The
10622
+ // entry moves to the end of the record: entry ORDER in a jsonValues record is
10623
+ // not meaningful (the Studio and the runtime key entries by name), and
10624
+ // insert-in-place would mean reimplementing insertValJsonEntry.
10625
+ const removed = removeValJsonEntry(sourceFile, [], entryKey);
10626
+ if (fp.result.isErr(removed)) {
10627
+ throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
10628
+ }
10629
+ const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
10630
+ if (fp.result.isErr(inserted)) {
10631
+ throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
10632
+ }
10633
+ sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
10634
+ sourceFileHandler.writeSourceFile(inserted.value);
10635
+ }
10636
+ function formatOpsError(error, sourceFile) {
10637
+ if (error instanceof patch.PatchError) {
10638
+ return error.message;
10639
+ }
10640
+ return flatMapErrors(error, e => formatSyntaxError(e, sourceFile)).join("\n");
10641
+ }
10642
+
10643
+ /**
10644
+ * The fix handlers behind `val validate --fix`, and the editor quick fixes that
10645
+ * must agree with it.
10646
+ *
10647
+ * ## Why this lives in `@valbuild/server`
10648
+ *
10649
+ * A fix is two layers. `createFixPatch` is the second: given a validation
10650
+ * error it produces a patch. The first is everything that has to happen before a
10651
+ * patch can be produced — read the file, check it is actually on disk, extract
10652
+ * metadata, pick a remote bucket, upload the bytes, download them back. That
10653
+ * layer used to live in `packages/cli/src/runValidation.ts`, which made it
10654
+ * reachable only from the CLI: `@valbuild/cli` exports nothing but `./cli`, and
10655
+ * `@valbuild/language-server` cannot depend on it anyway without creating a
10656
+ * cycle (the CLI depends on the language server).
10657
+ *
10658
+ * The consequence was that an editor could offer the fixes needing no
10659
+ * precondition and had to reimplement or skip the rest. Both happened: the
10660
+ * VS Code extension grew its own remote-upload client, and the language server
10661
+ * declined to offer remote fixes at all.
10662
+ *
10663
+ * So the layer sits here, next to `createFixPatch`, and the CLI and the language
10664
+ * server are both callers. {@link FixHandlerContext} is deliberately made of
10665
+ * things any caller already has — a `Service`, an `IValFSHost`, a project root —
10666
+ * rather than of CLI concepts.
10667
+ *
10668
+ * What stays in the CLI is the driver: the `runValidation` async generator that
10669
+ * walks every module, decides what to report, and renders it to a terminal.
10670
+ */
10671
+ const textEncoder = new TextEncoder();
10672
+
10673
+ // Types for handler system
10674
+
10675
+ // Handler functions
10676
+ async function handleFileMetadata(ctx) {
10677
+ var _fileSource$source;
10678
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
10679
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
10680
+ return {
10681
+ success: false,
10682
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
10683
+ };
10684
+ }
10685
+ const fileSource = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
10686
+
10687
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10688
+ const fileRefProp = (_fileSource$source = fileSource.source) === null || _fileSource$source === void 0 ? void 0 : _fileSource$source.path;
10689
+ if (!fileRefProp) {
10690
+ return {
10691
+ success: false,
10692
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
10693
+ };
10694
+ }
10695
+ const filePath = path__namespace["default"].join(ctx.projectRoot, fileRefProp);
10696
+ if (!ctx.fs.fileExists(filePath)) {
10697
+ return {
10698
+ success: false,
10699
+ errorMessage: `File ${filePath} does not exist`
10700
+ };
10701
+ }
10702
+ return {
10703
+ success: true,
10704
+ shouldApplyPatch: true
10705
+ };
10706
+ }
10707
+
10708
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
10709
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
10710
+ // how they derive the local file ref, metadata and serialized image/file
10711
+ // schema; everything from auth through upload is identical.
10712
+ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
10713
+ const filePath = path__namespace["default"].join(ctx.projectRoot, fileRef);
10714
+ if (!ctx.fs.fileExists(filePath)) {
10715
+ return {
10716
+ success: false,
10717
+ errorMessage: `File ${filePath} does not exist`
10718
+ };
10719
+ }
10720
+ const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
10721
+ if (!ctx.fs.fileExists(patFile)) {
10722
+ return {
10723
+ success: false,
10724
+ errorMessage: `File: ${path__namespace["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`
10725
+ };
10726
+ }
10727
+ const patFileContent = ctx.fs.readFile(patFile);
10728
+ if (patFileContent === undefined) {
10729
+ return {
10730
+ success: false,
10731
+ errorMessage: `Could not read personal access token file at ${patFile}`
10732
+ };
10733
+ }
10734
+ const parsedPatFile = parsePersonalAccessTokenFile(patFileContent);
10735
+ if (!parsedPatFile.success) {
10736
+ return {
10737
+ success: false,
10738
+ errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`
10739
+ };
10740
+ }
10741
+ const {
10742
+ pat
10743
+ } = parsedPatFile.data;
10744
+ if (ctx.remoteFiles[ctx.sourcePath]) {
10745
+ return {
10746
+ success: true,
10747
+ events: [{
10748
+ type: "remote-already-uploaded",
10749
+ filePath
10750
+ }]
10751
+ };
10752
+ }
10753
+ const projectName = ctx.project;
10754
+ let publicProjectId = ctx.publicProjectId;
10755
+ let remoteFileBuckets = ctx.remoteFileBuckets;
10756
+ let remoteFilesCounter = ctx.remoteFilesCounter;
10757
+ if (!publicProjectId || !remoteFileBuckets) {
10758
+ if (!projectName) {
10759
+ return {
10760
+ success: false,
10761
+ errorMessage: "Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable"
10762
+ };
10763
+ }
10764
+ const settingsRes = await ctx.remote.getSettings(projectName, {
10765
+ pat
10766
+ });
10767
+ if (!settingsRes.success) {
10768
+ return {
10769
+ success: false,
10770
+ errorMessage: `Could not get public project id: ${settingsRes.message}.`
10771
+ };
10772
+ }
10773
+ publicProjectId = settingsRes.data.publicProjectId;
10774
+ remoteFileBuckets = settingsRes.data.remoteFileBuckets.map(b => b.bucket);
10775
+ }
10776
+ if (!publicProjectId) {
10777
+ return {
10778
+ success: false,
10779
+ errorMessage: "Could not get public project id"
10780
+ };
10781
+ }
10782
+ if (!projectName) {
10783
+ return {
10784
+ success: false,
10785
+ errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
10786
+ };
10787
+ }
10788
+ remoteFilesCounter += 1;
10789
+ const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
10790
+ if (!bucket) {
10791
+ return {
10792
+ success: false,
10793
+ errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`
10794
+ };
10795
+ }
10796
+ const fileBuffer = ctx.fs.readBuffer(filePath);
10797
+ if (fileBuffer === undefined) {
10798
+ return {
10799
+ success: false,
10800
+ errorMessage: `Error reading file: ${filePath}`
10801
+ };
10802
+ }
10803
+ const relativeFilePath = path__namespace["default"].relative(ctx.projectRoot, filePath).split(path__namespace["default"].sep).join("/");
10804
+ if (!relativeFilePath.startsWith("public/")) {
10805
+ return {
10806
+ success: false,
10807
+ errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
10808
+ };
10809
+ }
10810
+ const fileHash = core.Internal.remote.getFileHash(fileBuffer);
10811
+ const coreVersion = core.Internal.VERSION.core || "unknown";
10812
+ const fileExt = getFileExt(filePath);
10813
+ const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
10814
+ publicProjectId,
10815
+ coreVersion,
10816
+ bucket,
10817
+ validationHash: core.Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata, fileHash, textEncoder),
10818
+ fileHash,
10819
+ filePath: relativeFilePath
10820
+ });
10821
+ const remoteFileUpload = await ctx.remote.uploadFile(projectName, bucket, fileHash, fileExt, fileBuffer, {
10822
+ pat
10823
+ });
10824
+ if (!remoteFileUpload.success) {
10825
+ return {
10826
+ success: false,
10827
+ errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`
10828
+ };
9835
10829
  }
10830
+ ctx.remoteFiles[ctx.sourcePath] = {
10831
+ ref,
10832
+ metadata
10833
+ };
9836
10834
  return {
9837
- patch: patch$1,
9838
- remainingErrors
10835
+ success: true,
10836
+ shouldApplyPatch: true,
10837
+ publicProjectId,
10838
+ remoteFileBuckets,
10839
+ remoteFilesCounter,
10840
+ events: [{
10841
+ type: "remote-uploading",
10842
+ ref
10843
+ }, {
10844
+ type: "remote-uploaded",
10845
+ ref
10846
+ }]
9839
10847
  };
9840
10848
  }
9841
- function getRemoteValueFromValidationError(v) {
9842
- if (v.value && typeof v.value !== "object") {
10849
+ async function handleRemoteFileUpload(ctx) {
10850
+ var _resolvedRemoteFileAt;
10851
+ if (!ctx.fix) {
9843
10852
  return {
9844
10853
  success: false,
9845
- message: "Unexpected error while checking remote (not an object)"
10854
+ // No sourcePath in the message: the reported location already points at it.
10855
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
9846
10856
  };
9847
10857
  }
9848
- if (!v.value) {
10858
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
10859
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
9849
10860
  return {
9850
10861
  success: false,
9851
- message: "Unexpected error while checking remote (no value)"
10862
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
9852
10863
  };
9853
10864
  }
9854
- if (typeof v.value !== "object" || v.value === null || !(core.FILE_REF_PROP in v.value)) {
10865
+ const resolvedRemoteFileAtSourcePath = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
10866
+
10867
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10868
+ const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt.path;
10869
+ if (!fileRefProp) {
9855
10870
  return {
9856
10871
  success: false,
9857
- message: "Unexpected error while checking remote (no _ref in value)"
10872
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
9858
10873
  };
9859
10874
  }
9860
- if (typeof v.value._ref !== "string") {
10875
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
10876
+ if (!resolveRemoteFileSchema) {
9861
10877
  return {
9862
10878
  success: false,
9863
- message: "Unexpected error while checking remote (_ref is not a string)"
10879
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
9864
10880
  };
9865
10881
  }
9866
- let metadata;
9867
- if ("metadata" in v.value && typeof v.value.metadata === "object") {
9868
- if (v.value.metadata === null) {
9869
- return {
9870
- success: false,
9871
- message: "Unexpected error while checking remote (metadata is null)"
9872
- };
10882
+ if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
10883
+ return {
10884
+ success: false,
10885
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
10886
+ };
10887
+ }
10888
+
10889
+ // The metadata is everything the media source carries besides its path: the
10890
+ // schema above already established that this IS media.
10891
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
10892
+ const fileSourceMetadata = actualRemoteFileSource && typeof actualRemoteFileSource === "object" && "path" in actualRemoteFileSource ? (() => {
10893
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10894
+ const {
10895
+ path: _path,
10896
+ ...metadata
10897
+ } = actualRemoteFileSource;
10898
+ return metadata;
10899
+ })() : undefined;
10900
+ return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
10901
+ }
10902
+
10903
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
10904
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
10905
+ // path and the value is bare metadata (no FileSource), so we derive the file
10906
+ // ref from the key and synthesize the image/file schema from the record's
10907
+ // media options.
10908
+ async function handleRemoteGalleryFileUpload(ctx) {
10909
+ var _ctx$validationError$;
10910
+ if (!ctx.fix) {
10911
+ return {
10912
+ success: false,
10913
+ // No sourcePath in the message: the reported location already points at it.
10914
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
10915
+ };
10916
+ }
10917
+ const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
10918
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
10919
+
10920
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
10921
+ // that key as the error value.
10922
+ const fileRef = ctx.validationError.value;
10923
+ if (typeof fileRef !== "string") {
10924
+ return {
10925
+ success: false,
10926
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
10927
+ };
10928
+ }
10929
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
10930
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
10931
+ return {
10932
+ success: false,
10933
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
10934
+ };
10935
+ }
10936
+ const resolved = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
10937
+ const entrySource = resolved.source;
10938
+ const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
10939
+
10940
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
10941
+ // synthesize the serialized image/file schema (matching how single fields
10942
+ // serialize) for the remote ref's validation hash.
10943
+ //
10944
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
10945
+ // from the entry's PARENT path - not from the module root, which is only the
10946
+ // record when the gallery is the whole module. A nested gallery
10947
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
10948
+ // with no options and bake a validation hash into the remote ref that can
10949
+ // never validate, so a mismatch fails fast instead of uploading.
10950
+ const [, parentModulePath] = core.Internal.splitModuleFilePathAndModulePath(core.Internal.parentOfSourcePath(ctx.sourcePath));
10951
+ const recordSchema = core.Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
10952
+ if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
10953
+ return {
10954
+ success: false,
10955
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
10956
+ };
10957
+ }
10958
+ const {
10959
+ accept,
10960
+ directory
10961
+ } = recordSchema;
10962
+ const schema = mediaType === "image" ? {
10963
+ type: "image",
10964
+ opt: false,
10965
+ options: {
10966
+ ...(accept ? {
10967
+ accept
10968
+ } : {}),
10969
+ ...(directory ? {
10970
+ directory
10971
+ } : {})
10972
+ }
10973
+ } : {
10974
+ type: "file",
10975
+ opt: false,
10976
+ options: {
10977
+ ...(accept ? {
10978
+ accept
10979
+ } : {})
10980
+ }
10981
+ };
10982
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
10983
+ }
10984
+ async function handleRemoteFileDownload(ctx) {
10985
+ if (ctx.fix) {
10986
+ return {
10987
+ success: true,
10988
+ shouldApplyPatch: true,
10989
+ events: [{
10990
+ type: "remote-downloading",
10991
+ sourcePath: ctx.sourcePath
10992
+ }]
10993
+ };
10994
+ } else {
10995
+ return {
10996
+ success: false,
10997
+ errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`
10998
+ };
10999
+ }
11000
+ }
11001
+ async function handleRemoteFileCheck() {
11002
+ // Skip - no action needed
11003
+ return {
11004
+ success: true,
11005
+ shouldApplyPatch: true
11006
+ };
11007
+ }
11008
+ async function handleUniqueFolderCheck(ctx) {
11009
+ const value = ctx.validationError.value;
11010
+ if (!value || typeof value.directory !== "string") {
11011
+ return {
11012
+ success: false,
11013
+ errorMessage: `Unexpected value in unique folder check for ${ctx.sourcePath}`
11014
+ };
11015
+ }
11016
+ const {
11017
+ directory
11018
+ } = value;
11019
+ const conflicts = [];
11020
+ for (const file of ctx.valFiles) {
11021
+ const otherModuleFilePath = `/${file}`;
11022
+ if (otherModuleFilePath === ctx.moduleFilePath) continue;
11023
+ const otherModule = await ctx.service.get(otherModuleFilePath, "", {
11024
+ validate: false
11025
+ });
11026
+ const schema = otherModule.schema;
11027
+ if ((schema === null || schema === void 0 ? void 0 : schema.type) === "record" && schema.directory === directory && schema.mediaType) {
11028
+ conflicts.push(otherModuleFilePath);
9873
11029
  }
9874
- if (Array.isArray(v.value.metadata)) {
11030
+ }
11031
+ if (conflicts.length > 0) {
11032
+ return {
11033
+ success: false,
11034
+ errorMessage: `Gallery directory '${directory}' in ${ctx.moduleFilePath} is also used by: ${conflicts.join(", ")}. Each gallery must use a unique directory.`
11035
+ };
11036
+ }
11037
+ return {
11038
+ success: true
11039
+ };
11040
+ }
11041
+
11042
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
11043
+ // entries by a remote URL while keeping the file on disk; everything else is
11044
+ // already a local path and is returned unchanged.
11045
+ function remoteKeyToLocalPath(key) {
11046
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
11047
+ if (remoteRefRes.status === "success") {
11048
+ return `/${remoteRefRes.filePath}`;
11049
+ }
11050
+ return key;
11051
+ }
11052
+ async function handleCheckAllFiles(ctx) {
11053
+ const value = ctx.validationError.value;
11054
+ if (!value || typeof value.directory !== "string") {
11055
+ return {
11056
+ success: false,
11057
+ errorMessage: `Unexpected value in check-all-files for ${ctx.sourcePath}`
11058
+ };
11059
+ }
11060
+ const {
11061
+ directory
11062
+ } = value;
11063
+ const source = ctx.valModule.source;
11064
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
11065
+ return {
11066
+ success: false,
11067
+ errorMessage: `Could not get source for ${ctx.sourcePath}`
11068
+ };
11069
+ }
11070
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
11071
+ // entries by a remote URL, but the file is kept on disk at its local path, so
11072
+ // normalize remote-URL keys back to that local path for the on-disk checks.
11073
+ const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
11074
+
11075
+ // Check that all tracked files exist on disk
11076
+ const missingTrackedFiles = Array.from(trackedFiles).filter(f => {
11077
+ return !ctx.fs.fileExists(path__namespace["default"].join(ctx.projectRoot, f));
11078
+ });
11079
+ if (missingTrackedFiles.length > 0) {
11080
+ if (!ctx.fix) {
9875
11081
  return {
9876
11082
  success: false,
9877
- message: "Unexpected error while checking remote (metadata is an array)"
11083
+ 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.`
9878
11084
  };
9879
11085
  }
9880
- metadata = v.value.metadata;
11086
+ // fix: true — let createFixPatch remove the missing entries
11087
+ return {
11088
+ success: true,
11089
+ shouldApplyPatch: true
11090
+ };
11091
+ }
11092
+ const dirPath = path__namespace["default"].join(ctx.projectRoot, directory);
11093
+ const filesInDir = [];
11094
+ try {
11095
+ const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
11096
+ for (const entry of entries) {
11097
+ const relPath = "/" + path__namespace["default"].relative(ctx.projectRoot, entry).split(path__namespace["default"].sep).join("/");
11098
+ filesInDir.push(relPath);
11099
+ }
11100
+ } catch {
11101
+ // directory doesn't exist — no untracked files possible
11102
+ }
11103
+ const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
11104
+ if (untrackedFiles.length > 0) {
11105
+ return {
11106
+ success: false,
11107
+ errorMessage: `Gallery in ${ctx.moduleFilePath} has files not tracked: ${untrackedFiles.join(", ")}. Add these files to the gallery or remove them from the directory.`
11108
+ };
9881
11109
  }
11110
+
11111
+ // All files accounted for — trigger metadata verification via createFixPatch
9882
11112
  return {
9883
11113
  success: true,
9884
- _ref: v.value._ref,
9885
- metadata
11114
+ shouldApplyPatch: true
9886
11115
  };
9887
11116
  }
9888
- async function getImageMetadata(projectRoot, validationError) {
9889
- const fileRef = getValidationErrorFileRef(validationError);
9890
- if (!fileRef) {
9891
- // TODO:
9892
- throw Error("Cannot fix image without a file reference");
11117
+ async function handleJsonValuesExtractEntry(ctx) {
11118
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
11119
+ const parts = core.Internal.splitModulePath(modulePath);
11120
+ // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
11121
+ // direct child of the module's root record/router, so the path is one segment.
11122
+ if (parts.length !== 1) {
11123
+ return {
11124
+ success: false,
11125
+ errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`
11126
+ };
9893
11127
  }
9894
- const filename = path__namespace["default"].join(projectRoot, fileRef);
9895
- const buffer = fs__default["default"].readFileSync(filename);
9896
- return extractImageMetadata(filename, buffer);
9897
- }
9898
- async function getFileMetadata(projectRoot, validationError) {
9899
- const fileRef = getValidationErrorFileRef(validationError);
9900
- if (!fileRef) {
9901
- // TODO:
9902
- throw Error("Cannot fix file without a file reference");
11128
+ const entryKey = parts[0];
11129
+ const source = ctx.valModule.source;
11130
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
11131
+ return {
11132
+ success: false,
11133
+ errorMessage: `Could not get source for ${ctx.moduleFilePath}`
11134
+ };
9903
11135
  }
9904
- const filename = path__namespace["default"].join(projectRoot, fileRef);
9905
- fs__default["default"].readFileSync(filename);
9906
- return extractFileMetadata(fileRef);
11136
+ const content = source[entryKey];
11137
+ if (content === undefined) {
11138
+ return {
11139
+ success: false,
11140
+ errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`
11141
+ };
11142
+ }
11143
+ if (!ctx.fix) {
11144
+ return {
11145
+ success: true,
11146
+ fixableErrorMessage: ctx.validationError.message
11147
+ };
11148
+ }
11149
+ try {
11150
+ extractJsonValuesEntry(ctx.moduleFilePath, ctx.projectRoot, entryKey, content, ctx.service.sourceFileHandler);
11151
+ } catch (err) {
11152
+ return {
11153
+ success: false,
11154
+ errorMessage: err instanceof Error ? err.message : String(err)
11155
+ };
11156
+ }
11157
+ return {
11158
+ success: true,
11159
+ appliedFix: true
11160
+ };
11161
+ }
11162
+
11163
+ // Fix handler registry. `keyof:check-keys` and `router:check-route` are
11164
+ // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
11165
+ // this registry, so they're excluded from the key set.
11166
+ const currentFixHandlers = {
11167
+ "image:check-metadata": handleFileMetadata,
11168
+ "image:add-metadata": handleFileMetadata,
11169
+ "file:check-metadata": handleFileMetadata,
11170
+ "file:add-metadata": handleFileMetadata,
11171
+ "image:upload-remote": handleRemoteFileUpload,
11172
+ "file:upload-remote": handleRemoteFileUpload,
11173
+ "images:upload-remote": handleRemoteGalleryFileUpload,
11174
+ "files:upload-remote": handleRemoteGalleryFileUpload,
11175
+ "image:download-remote": handleRemoteFileDownload,
11176
+ "file:download-remote": handleRemoteFileDownload,
11177
+ "image:check-remote": handleRemoteFileCheck,
11178
+ "images:check-remote": handleRemoteFileCheck,
11179
+ "file:check-remote": handleRemoteFileCheck,
11180
+ "files:check-remote": handleRemoteFileCheck,
11181
+ "images:check-unique-folder": handleUniqueFolderCheck,
11182
+ "files:check-unique-folder": handleUniqueFolderCheck,
11183
+ "images:check-all-files": handleCheckAllFiles,
11184
+ "files:check-all-files": handleCheckAllFiles,
11185
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry
11186
+ };
11187
+ const deprecatedFixHandlers = {
11188
+ "image:replace-metadata": handleFileMetadata
11189
+ };
11190
+ const fixHandlers = {
11191
+ ...deprecatedFixHandlers,
11192
+ ...currentFixHandlers
11193
+ };
11194
+ function createDefaultValFSHost() {
11195
+ return {
11196
+ ...ts__default["default"].sys,
11197
+ writeFile: (fileName, data, encoding) => {
11198
+ fs__default["default"].mkdirSync(path__namespace["default"].dirname(fileName), {
11199
+ recursive: true
11200
+ });
11201
+ fs__default["default"].writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
11202
+ },
11203
+ rmFile: fs__default["default"].rmSync,
11204
+ readBuffer: fileName => {
11205
+ try {
11206
+ return fs__default["default"].readFileSync(fileName);
11207
+ } catch {
11208
+ return undefined;
11209
+ }
11210
+ }
11211
+ };
9907
11212
  }
9908
11213
 
9909
11214
  function validateMetadata(actualMetadata, expectedMetadata) {
@@ -10198,9 +11503,9 @@ function getModulePathRange(modulePath, modulePathMap,
10198
11503
  // Which part of an object/record member to point at. For an object property
10199
11504
  // the resolved node's own range is the *key* (property name); the *value*
10200
11505
  // range is stored under `children.val`. Array elements, leaf literals and
10201
- // `c.image`/`c.file` `_ref`/`metadata` nodes have no `val` child, so "value"
10202
- // falls back to the node's own range for those. Defaults to "key" to preserve
10203
- // existing callers.
11506
+ // Array elements and leaf literals have no `val` child, so "value" falls back
11507
+ // to the node's own range for those. Defaults to "key" to preserve existing
11508
+ // callers.
10204
11509
  target = "key") {
10205
11510
  var _range$children;
10206
11511
  // Handle invalid module paths gracefully
@@ -10253,7 +11558,7 @@ target = "key") {
10253
11558
  *
10254
11559
  * NOTE: do not compute the start as `end.character - node.getWidth()`. That
10255
11560
  * identity only holds while the node stays on a single line - for a multi-line
10256
- * node (an object inside an array, a `c.image` metadata argument, ...) it
11561
+ * node (an object inside an array, a media object's `path`, ...) it
10257
11562
  * reports the *closing* line and a negative character.
10258
11563
  */
10259
11564
  function rangeOf(node, sourceFile) {
@@ -10317,39 +11622,6 @@ function traverse(node, sourceFile) {
10317
11622
  if (ts__default["default"].isArrayLiteralExpression(node)) {
10318
11623
  return traverseArrayLiteral(node, sourceFile);
10319
11624
  }
10320
- if (ts__default["default"].isCallExpression(node)) {
10321
- return traverseCallExpression(node, sourceFile);
10322
- }
10323
- }
10324
- function traverseCallExpression(node, sourceFile) {
10325
- if (ts__default["default"].isPropertyAccessExpression(node.expression)) {
10326
- if (node.expression.expression.getText(sourceFile) === "c" && (node.expression.name.getText(sourceFile) === "file" || node.expression.name.getText(sourceFile) === "image")) {
10327
- const val = {
10328
- children: {},
10329
- ...rangeOf(node, sourceFile)
10330
- };
10331
- if (node.arguments[0]) {
10332
- const _ref = {
10333
- children: {},
10334
- ...rangeOf(node.arguments[0], sourceFile)
10335
- };
10336
- if (!node.arguments[1]) {
10337
- return {
10338
- val,
10339
- _ref
10340
- };
10341
- }
10342
- return {
10343
- val,
10344
- _ref,
10345
- metadata: {
10346
- children: {},
10347
- ...rangeOf(node.arguments[1], sourceFile)
10348
- }
10349
- };
10350
- }
10351
- }
10352
- }
10353
11625
  }
10354
11626
  function traverseArrayLiteral(node, sourceFile) {
10355
11627
  return node.elements.reduce((acc, element, index) => {
@@ -10423,61 +11695,6 @@ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
10423
11695
  return resolveExistingJsonPath(moduleFilePath, entry.importPath);
10424
11696
  }
10425
11697
 
10426
- /**
10427
- * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
10428
- * its own `*.val.json`, replacing the inline value with
10429
- * `c.json(() => import("./<key>.val.json"))`.
10430
- *
10431
- * This is the fix for the `jsonValues:extract-entry` validation error. It is not
10432
- * expressible as a patch (a patch edits one `.val.ts` and cannot create the
10433
- * backing JSON file), so it writes both files directly — JSON first, so a
10434
- * failure part-way through never leaves the module pointing at a file that does
10435
- * not exist.
10436
- *
10437
- * Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
10438
- * up in the module's root record/router object literal.
10439
- */
10440
- function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
10441
- const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
10442
- const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
10443
- if (!sourceFile) {
10444
- throw Error(`Source file ${valTsPath} not found`);
10445
- }
10446
- const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
10447
- if (fp.result.isErr(pathsRes)) {
10448
- throw Error(formatPatchSourceError(pathsRes.error));
10449
- }
10450
- const {
10451
- jsonPath,
10452
- importPath
10453
- } = pathsRes.value;
10454
- const absoluteJsonPath = path__namespace["default"].join(rootDir, jsonPath);
10455
- if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
10456
- throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
10457
- }
10458
-
10459
- // Remove the inline property, then add the `c.json(...)` reference back. The
10460
- // entry moves to the end of the record: entry ORDER in a jsonValues record is
10461
- // not meaningful (the Studio and the runtime key entries by name), and
10462
- // insert-in-place would mean reimplementing insertValJsonEntry.
10463
- const removed = removeValJsonEntry(sourceFile, [], entryKey);
10464
- if (fp.result.isErr(removed)) {
10465
- throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
10466
- }
10467
- const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
10468
- if (fp.result.isErr(inserted)) {
10469
- throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
10470
- }
10471
- sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
10472
- sourceFileHandler.writeSourceFile(inserted.value);
10473
- }
10474
- function formatOpsError(error, sourceFile) {
10475
- if (error instanceof patch.PatchError) {
10476
- return error.message;
10477
- }
10478
- return flatMapErrors(error, e => formatSyntaxError(e, sourceFile)).join("\n");
10479
- }
10480
-
10481
11698
  /**
10482
11699
  * Replays a `val debug` snapshot: applies its patches the way /save does and
10483
11700
  * validates the result.
@@ -10595,13 +11812,16 @@ exports.analyzeValModule = analyzeValModule;
10595
11812
  exports.awaitValLoginConfirmation = awaitValLoginConfirmation;
10596
11813
  exports.checkRemoteRef = checkRemoteRef;
10597
11814
  exports.compareWithCapturedReport = compareWithCapturedReport;
11815
+ exports.createDefaultValFSHost = createDefaultValFSHost;
10598
11816
  exports.createFixPatch = createFixPatch;
10599
11817
  exports.createJsonEntryPathMap = createJsonEntryPathMap;
10600
11818
  exports.createModulePathMap = createModulePathMap;
10601
11819
  exports.createService = createService;
10602
11820
  exports.createValApiRouter = createValApiRouter;
10603
11821
  exports.createValServer = createValServer;
11822
+ exports.currentFixHandlers = currentFixHandlers;
10604
11823
  exports.decodeJwt = decodeJwt;
11824
+ exports.describePatchStoreProblems = describePatchStoreProblems;
10605
11825
  exports.downloadFileFromRemote = downloadFileFromRemote;
10606
11826
  exports.encodeJwt = encodeJwt;
10607
11827
  exports.evalValConfigFile = evalValConfigFile;
@@ -10610,6 +11830,7 @@ exports.extractImageMetadata = extractImageMetadata;
10610
11830
  exports.extractJsonValuesEntry = extractJsonValuesEntry;
10611
11831
  exports.findAndEvalValConfigFile = findAndEvalValConfigFile;
10612
11832
  exports.findJsonEntryFilePath = findJsonEntryFilePath;
11833
+ exports.fixHandlers = fixHandlers;
10613
11834
  exports.formatPatchSourceError = formatPatchSourceError;
10614
11835
  exports.formatSyntaxErrorTree = formatSyntaxErrorTree;
10615
11836
  exports.getCachedRemoteFileDir = getCachedRemoteFileDir;
@@ -10621,11 +11842,20 @@ exports.getModulePathRange = getModulePathRange;
10621
11842
  exports.getPersonalAccessTokenPath = getPersonalAccessTokenPath;
10622
11843
  exports.getSettings = getSettings;
10623
11844
  exports.getValidationErrorFileRef = getValidationErrorFileRef;
11845
+ exports.handleCheckAllFiles = handleCheckAllFiles;
11846
+ exports.handleFileMetadata = handleFileMetadata;
11847
+ exports.handleJsonValuesExtractEntry = handleJsonValuesExtractEntry;
11848
+ exports.handleRemoteFileCheck = handleRemoteFileCheck;
11849
+ exports.handleRemoteFileDownload = handleRemoteFileDownload;
11850
+ exports.handleRemoteFileUpload = handleRemoteFileUpload;
11851
+ exports.handleRemoteGalleryFileUpload = handleRemoteGalleryFileUpload;
11852
+ exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
10624
11853
  exports.loadValModules = loadValModules;
10625
11854
  exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
10626
11855
  exports.patchSourceFile = patchSourceFile;
10627
11856
  exports.persistPersonalAccessToken = persistPersonalAccessToken;
10628
11857
  exports.readCapturedReport = readCapturedReport;
11858
+ exports.readPatchStore = readPatchStore;
10629
11859
  exports.replaySnapshot = replaySnapshot;
10630
11860
  exports.safeReadGit = safeReadGit;
10631
11861
  exports.startValLogin = startValLogin;