@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.
@@ -1,19 +1,20 @@
1
1
  import ts from 'typescript';
2
2
  import { pipe, result, array } from '@valbuild/core/fp';
3
- import { FILE_REF_PROP, FILE_REF_SUBTYPE_TAG, VAL_EXTENSION, derefPatch, RecordSchema, Internal, extractValModules, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
4
- export { hasRemoteFileSchema } from '@valbuild/core';
5
3
  import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
4
+ import { derefPatch, RecordSchema, Internal, extractValModules, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
5
+ export { hasRemoteFileSchema } from '@valbuild/core';
6
6
  import * as path from 'path';
7
7
  import path__default from 'path';
8
8
  import fs, { promises } from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module, createRequire } from 'node:module';
11
- import { resolveSchemaSourceFixForError, getErrorMessageFromUnknownJson, Patch, ParentRef, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
11
+ import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1 from 'crypto';
14
14
  import z$1, { z } from 'zod';
15
15
  import sizeOf from 'image-size';
16
16
  import { fromError } from 'zod-validation-error';
17
+ import os from 'os';
17
18
  import { transform } from 'sucrase';
18
19
  import http from 'http';
19
20
  import https from 'https';
@@ -69,7 +70,7 @@ function validateObjectProperties(nodes) {
69
70
  * validating its children.
70
71
  */
71
72
  function shallowValidateExpression(value) {
72
- return ts.isStringLiteralLike(value) || ts.isNumericLiteral(value) || value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword || value.kind === ts.SyntaxKind.NullKeyword || ts.isArrayLiteralExpression(value) || ts.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);
73
+ return ts.isStringLiteralLike(value) || ts.isNumericLiteral(value) || value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword || value.kind === ts.SyntaxKind.NullKeyword || ts.isArrayLiteralExpression(value) || ts.isObjectLiteralExpression(value) ? undefined : new ValSyntaxError("Expression must be a literal", value);
73
74
  }
74
75
 
75
76
  /**
@@ -95,55 +96,8 @@ function evaluateExpression(value) {
95
96
  return result.all(value.elements.map(evaluateExpression));
96
97
  } else if (ts.isObjectLiteralExpression(value)) {
97
98
  return pipe(validateObjectProperties(value.properties), result.flatMap(assignments => pipe(assignments.map(assignment => pipe(evaluateExpression(assignment.initializer), result.map(value => [assignment.name.text, value]))), result.all)), result.map(Object.fromEntries));
98
- } else if (isValFileMethodCall(value)) {
99
- return pipe(findValFileNodeArg(value), result.flatMap(ref => {
100
- if (value.arguments.length === 2) {
101
- return pipe(evaluateExpression(value.arguments[1]), result.map(metadata => ({
102
- [FILE_REF_PROP]: ref.text,
103
- _type: "file",
104
- metadata
105
- })));
106
- } else {
107
- return result.ok({
108
- [FILE_REF_PROP]: ref.text,
109
- _type: "file"
110
- });
111
- }
112
- }));
113
- } else if (isValImageMethodCall(value)) {
114
- return pipe(findValImageNodeArg(value), result.flatMap(ref => {
115
- if (value.arguments.length === 2) {
116
- return pipe(evaluateExpression(value.arguments[1]), result.map(metadata => ({
117
- [FILE_REF_PROP]: ref.text,
118
- _type: "file",
119
- [FILE_REF_SUBTYPE_TAG]: "image",
120
- metadata
121
- })));
122
- } else {
123
- return result.ok({
124
- [FILE_REF_PROP]: ref.text,
125
- _type: "file",
126
- [FILE_REF_SUBTYPE_TAG]: "image"
127
- });
128
- }
129
- }));
130
- } else if (isValRemoteMethodCall(value)) {
131
- return pipe(findValRemoteNodeArg(value), result.flatMap(ref => {
132
- if (value.arguments.length === 2) {
133
- return pipe(evaluateExpression(value.arguments[1]), result.map(metadata => ({
134
- [FILE_REF_PROP]: ref.text,
135
- _type: "remote",
136
- metadata
137
- })));
138
- } else {
139
- return result.ok({
140
- [FILE_REF_PROP]: ref.text,
141
- _type: "remote"
142
- });
143
- }
144
- }));
145
99
  } else {
146
- return result.err(new ValSyntaxError("Expression must be a literal or call c.file / c.image / c.remote", value));
100
+ return result.err(new ValSyntaxError("Expression must be a literal", value));
147
101
  }
148
102
  }
149
103
  function findObjectPropertyAssignment(value, key) {
@@ -157,93 +111,6 @@ function findObjectPropertyAssignment(value, key) {
157
111
  return result.ok(assignment);
158
112
  }));
159
113
  }
160
- function isValFileMethodCall(node) {
161
- return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "c" && node.expression.name.text === "file";
162
- }
163
- function isValImageMethodCall(node) {
164
- return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "c" && node.expression.name.text === "image";
165
- }
166
- function isValRemoteMethodCall(node) {
167
- return ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "c" && node.expression.name.text === "remote";
168
- }
169
- function findValFileNodeArg(node) {
170
- if (node.arguments.length === 0) {
171
- return result.err(new ValSyntaxError(`Invalid c.file() call: missing ref argument`, node));
172
- } else if (node.arguments.length > 2) {
173
- return result.err(new ValSyntaxError(`Invalid c.file() call: too many arguments ${node.arguments.length}}`, node));
174
- } else if (!ts.isStringLiteral(node.arguments[0])) {
175
- return result.err(new ValSyntaxError(`Invalid c.file() call: ref must be a string literal`, node));
176
- }
177
- const refNode = node.arguments[0];
178
- return result.ok(refNode);
179
- }
180
- function findValRemoteNodeArg(node) {
181
- if (node.arguments.length === 0) {
182
- return result.err(new ValSyntaxError(`Invalid c.remote() call: missing ref argument`, node));
183
- } else if (node.arguments.length > 2) {
184
- return result.err(new ValSyntaxError(`Invalid c.remote() call: too many arguments ${node.arguments.length}}`, node));
185
- } else if (!ts.isStringLiteral(node.arguments[0])) {
186
- return result.err(new ValSyntaxError(`Invalid c.remote() call: ref must be a string literal`, node));
187
- }
188
- const refNode = node.arguments[0];
189
- return result.ok(refNode);
190
- }
191
- function findValImageNodeArg(node) {
192
- if (node.arguments.length === 0) {
193
- return result.err(new ValSyntaxError(`Invalid c.image() call: missing ref argument`, node));
194
- } else if (node.arguments.length > 2) {
195
- return result.err(new ValSyntaxError(`Invalid c.image() call: too many arguments ${node.arguments.length}}`, node));
196
- } else if (!ts.isStringLiteral(node.arguments[0])) {
197
- return result.err(new ValSyntaxError(`Invalid c.image() call: ref must be a string literal`, node));
198
- }
199
- const refNode = node.arguments[0];
200
- return result.ok(refNode);
201
- }
202
- function findValFileMetadataArg(node) {
203
- if (node.arguments.length === 0) {
204
- return result.err(new ValSyntaxError(`Invalid c.file() call: missing ref argument`, node));
205
- } else if (node.arguments.length > 2) {
206
- return result.err(new ValSyntaxError(`Invalid c.file() call: too many arguments ${node.arguments.length}}`, node));
207
- } else if (node.arguments.length === 2) {
208
- if (!ts.isObjectLiteralExpression(node.arguments[1])) {
209
- return result.err(new ValSyntaxError(`Invalid c.file() call: metadata must be a object literal`, node));
210
- }
211
- return result.ok(node.arguments[1]);
212
- }
213
- return result.ok(undefined);
214
- }
215
- function findValImageMetadataArg(node) {
216
- if (node.arguments.length === 0) {
217
- return result.err(new ValSyntaxError(`Invalid c.image() call: missing ref argument`, node));
218
- } else if (node.arguments.length > 2) {
219
- return result.err(new ValSyntaxError(`Invalid c.image() call: too many arguments ${node.arguments.length}}`, node));
220
- } else if (node.arguments.length === 2) {
221
- if (!ts.isObjectLiteralExpression(node.arguments[1])) {
222
- return result.err(new ValSyntaxError(`Invalid c.image() call: metadata must be a object literal`, node));
223
- }
224
- return result.ok(node.arguments[1]);
225
- }
226
- return result.ok(undefined);
227
- }
228
- function findValRemoteMetadataArg(node) {
229
- if (node.arguments.length === 0) {
230
- return result.err(new ValSyntaxError(`Invalid c.remote() call: missing ref argument`, node));
231
- } else if (node.arguments.length > 2) {
232
- return result.err(new ValSyntaxError(`Invalid c.remote() call: too many arguments ${node.arguments.length}}`, node));
233
- } else if (node.arguments.length === 2) {
234
- if (!ts.isObjectLiteralExpression(node.arguments[1])) {
235
- return result.err(new ValSyntaxError(`Invalid c.remote() call: metadata must be a object literal`, node));
236
- }
237
- return result.ok(node.arguments[1]);
238
- }
239
- return result.ok(undefined);
240
- }
241
-
242
- /**
243
- * Given a list of expressions, validates that all the expressions are not
244
- * spread elements. In other words, it ensures that the expressions are the
245
- * initializers of the values at their respective indices in the evaluated list.
246
- */
247
114
  function validateInitializers(nodes) {
248
115
  for (const node of nodes) {
249
116
  if (ts.isSpreadElement(node)) {
@@ -325,27 +192,6 @@ function isValidIdentifier(text) {
325
192
  function createPropertyAssignment(key, value) {
326
193
  return ts.factory.createPropertyAssignment(isValidIdentifier(key) ? ts.factory.createIdentifier(key) : ts.factory.createStringLiteral(key), toExpression(value));
327
194
  }
328
- function createValFileReference(value) {
329
- const args = [ts.factory.createStringLiteral(value[FILE_REF_PROP])];
330
- if (value.metadata) {
331
- args.push(toExpression(value.metadata));
332
- }
333
- return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("c"), ts.factory.createIdentifier("file")), undefined, args);
334
- }
335
- function createValImageReference(value) {
336
- const args = [ts.factory.createStringLiteral(value[FILE_REF_PROP])];
337
- if (value.metadata) {
338
- args.push(toExpression(value.metadata));
339
- }
340
- return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("c"), ts.factory.createIdentifier("image")), undefined, args);
341
- }
342
- function createValRemoteReference(value) {
343
- const args = [ts.factory.createStringLiteral(value[FILE_REF_PROP])];
344
- if (value.metadata) {
345
- args.push(toExpression(value.metadata));
346
- }
347
- return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("c"), ts.factory.createIdentifier("remote")), undefined, args);
348
- }
349
195
 
350
196
  /**
351
197
  * Builds the expression `c.json(() => import("<importPath>"))` used to reference
@@ -431,16 +277,12 @@ function toExpression(value) {
431
277
  } else if (Array.isArray(value)) {
432
278
  return ts.factory.createArrayLiteralExpression(value.map(toExpression));
433
279
  } else if (typeof value === "object") {
434
- if (isValFileValue(value)) {
435
- return createValFileReference(value);
436
- }
437
- if (isValImageValue(value)) {
438
- return createValImageReference(value);
439
- }
440
- if (isValRemoteValue(value)) {
441
- return createValRemoteReference(value);
442
- }
443
- return ts.factory.createObjectLiteralExpression(Object.entries(value).map(([key, value]) => createPropertyAssignment(key, value)));
280
+ return ts.factory.createObjectLiteralExpression(Object.entries(value)
281
+ // `patch_id` is injected server-side onto a media source whose bytes are
282
+ // not committed yet. It describes the draft, not the content, and a
283
+ // whole-object write built from the client's optimistic view would
284
+ // otherwise print it into the user's file.
285
+ .filter(([key]) => key !== "patch_id").map(([key, value]) => createPropertyAssignment(key, value)));
444
286
  } else {
445
287
  return ts.factory.createStringLiteral(value);
446
288
  }
@@ -573,63 +415,6 @@ function replaceInNode(document, node, key, value) {
573
415
  }
574
416
  return result.ok(assignment);
575
417
  }), result.map(assignment => replaceNodeValue(document, assignment.initializer, value)));
576
- } else if (isValFileMethodCall(node)) {
577
- if (key === FILE_REF_PROP) {
578
- if (typeof value !== "string") {
579
- return result.err(new PatchError("Cannot replace c.file reference with non-string value"));
580
- }
581
- return pipe(findValFileNodeArg(node), result.map(refNode => replaceNodeValue(document, refNode, value)));
582
- } else {
583
- return pipe(findValFileMetadataArg(node), result.flatMap(metadataArgNode => {
584
- if (!metadataArgNode) {
585
- return result.err(new PatchError("Cannot replace in c.file metadata when it does not exist"));
586
- }
587
- if (key !== "metadata") {
588
- return result.err(new PatchError(`Cannot replace c.file metadata key ${key} when it does not exist`));
589
- }
590
- return replaceInNode(document,
591
- // TODO: creating a fake object here might not be right - seems to work though
592
- ts.factory.createObjectLiteralExpression([ts.factory.createPropertyAssignment(key, metadataArgNode)]), key, value);
593
- }));
594
- }
595
- } else if (isValImageMethodCall(node)) {
596
- if (key === FILE_REF_PROP) {
597
- if (typeof value !== "string") {
598
- return result.err(new PatchError("Cannot replace c.image reference with non-string value"));
599
- }
600
- return pipe(findValImageNodeArg(node), result.map(refNode => replaceNodeValue(document, refNode, value)));
601
- } else {
602
- return pipe(findValImageMetadataArg(node), result.flatMap(metadataArgNode => {
603
- if (!metadataArgNode) {
604
- return result.err(new PatchError("Cannot replace in c.image metadata when it does not exist"));
605
- }
606
- if (key !== "metadata") {
607
- return result.err(new PatchError(`Cannot replace c.image metadata key ${key} when it does not exist`));
608
- }
609
- return replaceInNode(document,
610
- // TODO: creating a fake object here might not be right - seems to work though
611
- ts.factory.createObjectLiteralExpression([ts.factory.createPropertyAssignment(key, metadataArgNode)]), key, value);
612
- }));
613
- }
614
- } else if (isValRemoteMethodCall(node)) {
615
- if (key === FILE_REF_PROP) {
616
- if (typeof value !== "string") {
617
- return result.err(new PatchError("Cannot replace c.image reference with non-string value"));
618
- }
619
- return pipe(findValRemoteNodeArg(node), result.map(refNode => replaceNodeValue(document, refNode, value)));
620
- } else {
621
- return pipe(findValRemoteMetadataArg(node), result.flatMap(metadataArgNode => {
622
- if (!metadataArgNode) {
623
- return result.err(new PatchError("Cannot replace in c.remote metadata when it does not exist"));
624
- }
625
- if (key !== "metadata") {
626
- return result.err(new PatchError(`Cannot replace c.remote metadata key ${key} when it does not exist`));
627
- }
628
- return replaceInNode(document,
629
- // TODO: creating a fake object here might not be right - seems to work though
630
- ts.factory.createObjectLiteralExpression([ts.factory.createPropertyAssignment(key, metadataArgNode)]), key, value);
631
- }));
632
- }
633
418
  } else {
634
419
  return result.err(shallowValidateExpression(node) ?? new PatchError("Cannot replace in non-object/array"));
635
420
  }
@@ -646,21 +431,6 @@ function getFromNode(node, key) {
646
431
  return pipe(parseAndValidateArrayInboundsIndex(key, node.elements), result.map(index => node.elements[index]));
647
432
  } else if (ts.isObjectLiteralExpression(node)) {
648
433
  return pipe(findObjectPropertyAssignment(node, key), result.map(assignment => assignment === null || assignment === void 0 ? void 0 : assignment.initializer));
649
- } else if (isValFileMethodCall(node)) {
650
- if (key === FILE_REF_PROP) {
651
- return findValFileNodeArg(node);
652
- }
653
- return findValFileMetadataArg(node);
654
- } else if (isValImageMethodCall(node)) {
655
- if (key === FILE_REF_PROP) {
656
- return findValImageNodeArg(node);
657
- }
658
- return findValImageMetadataArg(node);
659
- } else if (isValRemoteMethodCall(node)) {
660
- if (key === FILE_REF_PROP) {
661
- return findValRemoteNodeArg(node);
662
- }
663
- return findValRemoteMetadataArg(node);
664
434
  } else {
665
435
  return result.err(shallowValidateExpression(node) ?? new PatchError("Cannot access non-object/array"));
666
436
  }
@@ -693,39 +463,6 @@ function removeFromNode(document, node, key) {
693
463
  }
694
464
  return result.ok(assignment);
695
465
  }), result.map(assignment => [removeAt(document, node.properties, node.properties.indexOf(assignment))[0], assignment.initializer]));
696
- } else if (isValFileMethodCall(node)) {
697
- if (key === FILE_REF_PROP) {
698
- return result.err(new PatchError("Cannot remove a ref from c.file"));
699
- } else {
700
- return pipe(findValFileMetadataArg(node), result.flatMap(metadataArgNode => {
701
- if (!metadataArgNode) {
702
- return result.err(new PatchError("Cannot remove from c.file metadata when it does not exist"));
703
- }
704
- return removeFromNode(document, metadataArgNode, key);
705
- }));
706
- }
707
- } else if (isValImageMethodCall(node)) {
708
- if (key === FILE_REF_PROP) {
709
- return result.err(new PatchError("Cannot remove a ref from c.image"));
710
- } else {
711
- return pipe(findValImageMetadataArg(node), result.flatMap(metadataArgNode => {
712
- if (!metadataArgNode) {
713
- return result.err(new PatchError("Cannot remove from c.image metadata when it does not exist"));
714
- }
715
- return removeFromNode(document, metadataArgNode, key);
716
- }));
717
- }
718
- } else if (isValRemoteMethodCall(node)) {
719
- if (key === FILE_REF_PROP) {
720
- return result.err(new PatchError("Cannot remove a ref from c.remote"));
721
- } else {
722
- return pipe(findValRemoteMetadataArg(node), result.flatMap(metadataArgNode => {
723
- if (!metadataArgNode) {
724
- return result.err(new PatchError("Cannot remove from c.remote metadata when it does not exist"));
725
- }
726
- return removeFromNode(document, metadataArgNode, key);
727
- }));
728
- }
729
466
  } else {
730
467
  return result.err(shallowValidateExpression(node) ?? new PatchError("Cannot remove from non-object/array"));
731
468
  }
@@ -733,22 +470,10 @@ function removeFromNode(document, node, key) {
733
470
  function removeAtPath(document, rootNode, path) {
734
471
  return pipe(getPointerFromPath(rootNode, path), result.flatMap(([node, key]) => removeFromNode(document, node, key)));
735
472
  }
736
- function isValFileValue(value) {
737
- return !!(typeof value === "object" && value && VAL_EXTENSION in value && value[VAL_EXTENSION] === "file" && FILE_REF_PROP in value && typeof value[FILE_REF_PROP] === "string" && value[FILE_REF_SUBTYPE_TAG] !== "image");
738
- }
739
- function isValImageValue(value) {
740
- return !!(typeof value === "object" && value && VAL_EXTENSION in value && value[VAL_EXTENSION] === "file" && FILE_REF_PROP in value && typeof value[FILE_REF_PROP] === "string" && value[FILE_REF_SUBTYPE_TAG] === "image");
741
- }
742
- function isValRemoteValue(value) {
743
- return !!(typeof value === "object" && value && VAL_EXTENSION in value && value[VAL_EXTENSION] === "remote" && FILE_REF_PROP in value && typeof value[FILE_REF_PROP] === "string");
744
- }
745
473
  function addToNode(document, node, key, value) {
746
474
  if (ts.isArrayLiteralExpression(node)) {
747
475
  return pipe(parseAndValidateArrayInsertIndex(key, node.elements), result.map(index => [insertAt(document, node.elements, index, toExpression(value))]));
748
476
  } else if (ts.isObjectLiteralExpression(node)) {
749
- if (key === FILE_REF_PROP) {
750
- return result.err(new PatchError("Cannot add a key ref to object"));
751
- }
752
477
  return pipe(findObjectPropertyAssignment(node, key), result.map(assignment => {
753
478
  if (!assignment) {
754
479
  return [insertAt(document, node.properties, node.properties.length, createPropertyAssignment(key, value))];
@@ -756,57 +481,6 @@ function addToNode(document, node, key, value) {
756
481
  return replaceNodeValue(document, assignment.initializer, value);
757
482
  }
758
483
  }));
759
- } else if (isValFileMethodCall(node)) {
760
- if (key === FILE_REF_PROP) {
761
- if (typeof value !== "string") {
762
- return result.err(new PatchError(`Cannot add ${FILE_REF_PROP} key to c.file with non-string value`));
763
- }
764
- return pipe(findValFileNodeArg(node), result.map(arg => replaceNodeValue(document, arg, value)));
765
- } else {
766
- return pipe(findValFileMetadataArg(node), result.flatMap(metadataArgNode => {
767
- if (metadataArgNode) {
768
- return result.err(new PatchError("Cannot add metadata to c.file when it already exists"));
769
- }
770
- if (key !== "metadata") {
771
- return result.err(new PatchError(`Cannot add ${key} key to c.file: only metadata is allowed`));
772
- }
773
- return result.ok([insertAt(document, node.arguments, node.arguments.length, toExpression(value))]);
774
- }));
775
- }
776
- } else if (isValImageMethodCall(node)) {
777
- if (key === FILE_REF_PROP) {
778
- if (typeof value !== "string") {
779
- return result.err(new PatchError(`Cannot add ${FILE_REF_PROP} key to c.image with non-string value`));
780
- }
781
- return pipe(findValImageNodeArg(node), result.map(arg => replaceNodeValue(document, arg, value)));
782
- } else {
783
- return pipe(findValImageMetadataArg(node), result.flatMap(metadataArgNode => {
784
- if (metadataArgNode) {
785
- return result.err(new PatchError("Cannot add metadata to c.image when it already exists"));
786
- }
787
- if (key !== "metadata") {
788
- return result.err(new PatchError(`Cannot add ${key} key to c.image: only metadata is allowed`));
789
- }
790
- return result.ok([insertAt(document, node.arguments, node.arguments.length, toExpression(value))]);
791
- }));
792
- }
793
- } else if (isValRemoteMethodCall(node)) {
794
- if (key === FILE_REF_PROP) {
795
- if (typeof value !== "string") {
796
- return result.err(new PatchError(`Cannot add ${FILE_REF_PROP} key to c.remote with non-string value`));
797
- }
798
- return pipe(findValRemoteNodeArg(node), result.map(arg => replaceNodeValue(document, arg, value)));
799
- } else {
800
- return pipe(findValRemoteMetadataArg(node), result.flatMap(metadataArgNode => {
801
- if (metadataArgNode) {
802
- return result.err(new PatchError("Cannot add metadata to c.remote when it already exists"));
803
- }
804
- if (key !== "metadata") {
805
- return result.err(new PatchError(`Cannot add ${key} key to c.remote: only metadata is allowed`));
806
- }
807
- return result.ok([insertAt(document, node.arguments, node.arguments.length, toExpression(value))]);
808
- }));
809
- }
810
484
  } else {
811
485
  return result.err(shallowValidateExpression(node) ?? new PatchError("Cannot add to non-object/array"));
812
486
  }
@@ -1388,7 +1062,10 @@ function rebaseContentOp(op, prefixLen) {
1388
1062
  from: op.from.slice(prefixLen)
1389
1063
  });
1390
1064
  case "file":
1391
- return result.err(new PatchError("Cannot apply a file op to a jsonValues entry"));
1065
+ // A file op carries bytes, not content. The binary goes through the
1066
+ // normal upload pipeline; what lands in the entry is the `patch_id` that
1067
+ // marks the bytes as not yet committed, which the caller writes.
1068
+ return result.err(new PatchError("A file op is not a content op: rebase it via its path"));
1392
1069
  }
1393
1070
  }
1394
1071
 
@@ -1422,9 +1099,6 @@ function applyJsonValuesEntryPatches(args) {
1422
1099
  } of patches) {
1423
1100
  let touched = false;
1424
1101
  for (const op of patch) {
1425
- if (op.op === "file") {
1426
- continue;
1427
- }
1428
1102
  const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1429
1103
  kind: "normal"
1430
1104
  };
@@ -1432,6 +1106,32 @@ function applyJsonValuesEntryPatches(args) {
1432
1106
  continue;
1433
1107
  }
1434
1108
  touched = true;
1109
+ // A file op does not edit the entry's content — its bytes go through the
1110
+ // upload pipeline. What the entry needs is the `patch_id` saying those
1111
+ // bytes are not committed yet, so `mediaUrl` serves them from the patch
1112
+ // directory instead of a `/public` path that holds nothing. Without this a
1113
+ // just-uploaded image inside an entry renders broken.
1114
+ if (op.op === "file") {
1115
+ if (op.value === null || content === undefined) {
1116
+ // A delete carries no bytes to point at, and there is nothing to mark
1117
+ // on an entry that does not exist.
1118
+ continue;
1119
+ }
1120
+ const applied = applyPatch(deepClone(content), jsonOps$1, [{
1121
+ op: "add",
1122
+ path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1123
+ value: patchId
1124
+ }]);
1125
+ if (result.isErr(applied)) {
1126
+ return {
1127
+ kind: "error",
1128
+ message: applied.error.message,
1129
+ patchId
1130
+ };
1131
+ }
1132
+ content = applied.value;
1133
+ continue;
1134
+ }
1435
1135
  if (cls.subPath.length === 0) {
1436
1136
  if (op.op === "add" || op.op === "replace") {
1437
1137
  content = op.value;
@@ -2404,6 +2104,17 @@ class ValOps {
2404
2104
  const serializedSchema = jsonValuesSchemaFor(path);
2405
2105
  for (const op of patchData.patch) {
2406
2106
  if (op.op === "file") {
2107
+ // A file op inside a `.jsonValues()` entry has nothing to inject
2108
+ // HERE: the entry is an opaque marker in the module source, so an
2109
+ // `add` reaching into it fails and poisons the rest of this
2110
+ // module's chain. `applyJsonValuesEntryPatches` writes the patch_id
2111
+ // into the entry's draft content instead.
2112
+ const fileCls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
2113
+ kind: "normal"
2114
+ };
2115
+ if (fileCls.kind === "entry" && fileCls.subPath.length > 0) {
2116
+ continue;
2117
+ }
2407
2118
  if (op.value !== null) {
2408
2119
  // NOTE: We insert the last patch_id that modify a file
2409
2120
  // when constructing the url we use the patch id (and the file path)
@@ -2704,7 +2415,7 @@ class ValOps {
2704
2415
  };
2705
2416
  }
2706
2417
  const type = schemaAtPath instanceof ImageSchema ? "image" : "file";
2707
- const filePath = value[FILE_REF_PROP];
2418
+ const filePath = value.path;
2708
2419
  const fileData = (fileLastUpdatedByPatchId === null || fileLastUpdatedByPatchId === void 0 ? void 0 : fileLastUpdatedByPatchId[filePath]) || null;
2709
2420
  let metadata;
2710
2421
  let metadataErrors;
@@ -2746,23 +2457,14 @@ class ValOps {
2746
2457
  }]
2747
2458
  };
2748
2459
  }
2749
- const metadataSourcePath = Internal.createValPathOfItem(sourcePath, "metadata");
2750
- if (!metadataSourcePath) {
2751
- throw new Error("Could not create metadata path");
2752
- }
2753
- const currentValueMetadata = value.metadata;
2754
- if (!currentValueMetadata) {
2755
- return {
2756
- [metadataSourcePath]: [{
2757
- message: "Missing metadata field: 'metadata'",
2758
- value
2759
- }]
2760
- };
2761
- }
2460
+ // The fields Val computes from the bytes sit next to `path` now, so the
2461
+ // error is reported at the field it is about rather than at a `metadata`
2462
+ // object that no longer exists.
2463
+ const currentValueMetadata = value;
2762
2464
  const fieldErrors = {};
2763
2465
  for (const field of getFieldsForType(type)) {
2764
2466
  const fieldMetadata = metadata[field];
2765
- const fieldSourcePath = Internal.createValPathOfItem(metadataSourcePath, field);
2467
+ const fieldSourcePath = Internal.createValPathOfItem(sourcePath, field);
2766
2468
  if (!fieldSourcePath) {
2767
2469
  throw new Error("Could not create field path");
2768
2470
  }
@@ -3354,11 +3056,15 @@ function isOnlyFileCheckValidationError(validationError) {
3354
3056
  }
3355
3057
  return false;
3356
3058
  }
3059
+
3060
+ /**
3061
+ * Whether a flagged validation value is media.
3062
+ *
3063
+ * The caller already knows the schema said image or file — this only guards
3064
+ * against a value that never got that far.
3065
+ */
3357
3066
  function isFileSource(value) {
3358
- if (typeof value === "object" && value !== null && FILE_REF_PROP in value && VAL_EXTENSION in value && value[VAL_EXTENSION] === "file" && FILE_REF_PROP) {
3359
- return true;
3360
- }
3361
- return false;
3067
+ return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
3362
3068
  }
3363
3069
  function formatPatchSourceError(error) {
3364
3070
  if ("message" in error) {
@@ -3563,119 +3269,1114 @@ class JsonEntryFilesFingerprint {
3563
3269
  }
3564
3270
 
3565
3271
  /**
3566
- * Computes the changed patch parent references based on the current patches and the patch IDs to be deleted.
3272
+ * The order of the pending patches, held in one flat, append-only text file.
3273
+ *
3274
+ * ## Why a log and not links between the patches
3567
3275
  *
3568
- * NOTE: patches that will be deleted are not included in the changed patches, since they will be deleted any how.
3276
+ * The store this replaces kept the order in the patches themselves: every record
3277
+ * carried a `parentRef`, and the directory a record lived in was named after that
3278
+ * parent. Reading the chain meant walking those links from `head`, and the walk
3279
+ * stopped dead — silently — at the first id nothing on disk answered for.
3569
3280
  *
3570
- * @param currentPatches - The array of current patches.
3571
- * @param deletePatchIds - The array of patch IDs to be deleted.
3572
- * @returns An object containing the changed patches with their corresponding parent references.
3281
+ * That is not a hypothetical. A store with 410 patches lost exactly one record,
3282
+ * and the 51 patches written after it became unreachable: `/stat` counted the
3283
+ * directories and announced 410, `GET /patches` walked the links and delivered
3284
+ * 359, and the studio waited forever for 51 ids that no longer had a path back to
3285
+ * `head`. One missing file cost every edit made after it.
3286
+ *
3287
+ * So the order lives in one place and the patches reference nothing. A patch
3288
+ * directory is self-contained data; this file says what order the directories go
3289
+ * in. Removing a line cannot orphan the lines below it, because there is nothing
3290
+ * below it to orphan — the entry after a dropped one is simply next.
3291
+ *
3292
+ * ## Why text
3293
+ *
3294
+ * It is read by people during exactly the incidents that make it interesting, and
3295
+ * `cat` should be enough. One entry per line, whitespace-separated, id first:
3296
+ *
3297
+ * ```
3298
+ * val-patch-log v1
3299
+ * 659b8cfa-065c-47d1-8d82-fc69a2ac72a9 2026-08-27T11:46:13.730Z /content/authors.val.ts
3300
+ * ```
3301
+ *
3302
+ * Position in the file IS the order. There is deliberately no sequence number:
3303
+ * a number stored beside the thing it describes is a number that can disagree
3304
+ * with it, and then something has to decide which one lies.
3305
+ *
3306
+ * The timestamp and path are there to make the file readable, not to be believed
3307
+ * — `patch.json` owns those fields. Nothing here is a second copy of state that
3308
+ * anything reads back.
3573
3309
  */
3574
- function computeChangedPatchParentRefs(currentPatches, deletePatchIds) {
3575
- let lastNonDeletedPatchIndex = -1;
3576
- const changedPatches = {};
3577
- for (let i = 0; i < currentPatches.length; i++) {
3578
- const current = currentPatches[i];
3579
- if (
3580
- // skip all patches that will be deleted:
3581
- deletePatchIds.includes(current.patchId)) {
3582
- var _currentPatches;
3583
- if (
3584
- // skip change if the patch after is deleted anyway:
3585
- !deletePatchIds.includes((_currentPatches = currentPatches[i + 1]) === null || _currentPatches === void 0 ? void 0 : _currentPatches.patchId)) {
3586
- if (
3587
- // set next patch to point to head if it exists:
3588
- lastNonDeletedPatchIndex === -1 && currentPatches[i + 1]) {
3589
- changedPatches[currentPatches[i + 1].patchId] = {
3590
- type: "head",
3591
- headBaseSha: current.baseSha
3592
- };
3593
- } else if (
3594
- // set next patch to point to the last non-deleted patch:
3595
- currentPatches[lastNonDeletedPatchIndex] && currentPatches[i + 1]) {
3596
- changedPatches[currentPatches[i + 1].patchId] = {
3597
- type: "patch",
3598
- patchId: currentPatches[lastNonDeletedPatchIndex].patchId
3599
- };
3600
- }
3601
- }
3602
- } else {
3603
- lastNonDeletedPatchIndex = i;
3604
- }
3310
+
3311
+ const PATCH_LOG_FILE_NAME = "patches.log";
3312
+ const HEADER = "val-patch-log v1";
3313
+
3314
+ /**
3315
+ * Something that is wrong with the file but does not stop it being read.
3316
+ *
3317
+ * Reported rather than thrown, because a log that is 99% intact is worth reading
3318
+ * and then repairing. Only a file that cannot be understood at all is fatal, and
3319
+ * that is a `status` on the read result, not a problem in this list.
3320
+ */
3321
+
3322
+ function isModuleFilePath(path) {
3323
+ return path.startsWith("/") && path.includes(".val.");
3324
+ }
3325
+ function isPatchId(patchId) {
3326
+ return patchId.length > 0 && !/\s/.test(patchId);
3327
+ }
3328
+ function formatPatchLogLine(entry) {
3329
+ return `${entry.patchId} ${entry.createdAt} ${entry.path}`;
3330
+ }
3331
+
3332
+ /**
3333
+ * Split on the first two runs of whitespace only: a module file path may contain
3334
+ * spaces, and it is last precisely so that it can.
3335
+ */
3336
+ function parsePatchLogLine(line) {
3337
+ const trimmed = line.trim();
3338
+ if (trimmed === "") {
3339
+ return null;
3340
+ }
3341
+ const match = /^(\S+)\s+(\S+)\s+(.+)$/.exec(trimmed);
3342
+ if (!match) {
3343
+ return null;
3344
+ }
3345
+ const [, patchId, createdAt, path] = match;
3346
+ if (!isPatchId(patchId) || !isModuleFilePath(path)) {
3347
+ return null;
3605
3348
  }
3606
3349
  return {
3607
- changedPatches
3350
+ patchId,
3351
+ createdAt,
3352
+ path
3608
3353
  };
3609
3354
  }
3610
-
3611
- async function uploadRemoteFile(contentHost, project, bucket, fileHash, fileExt, fileBuffer, auth) {
3612
- const authHeader = "apiKey" in auth ? {
3613
- Authorization: `Bearer ${auth.apiKey}`
3614
- } : {
3615
- "x-val-pat": auth.pat
3616
- };
3617
- const res = await fetch(`${contentHost}/v1/${project}/remote/files/b/${bucket}/f/${fileHash}.${fileExt}`, {
3618
- method: "PUT",
3619
- headers: {
3620
- ...authHeader,
3621
- "Content-Type": "application/octet-stream"
3622
- },
3623
- body: new Uint8Array(fileBuffer)
3624
- });
3625
- if (!res.ok) {
3626
- var _res$headers$get;
3627
- if (res.status === 409) {
3628
- // File already exists
3629
- return {
3630
- success: true
3631
- };
3632
- }
3633
- if ((_res$headers$get = res.headers.get("content-type")) !== null && _res$headers$get !== void 0 && _res$headers$get.includes("application/json")) {
3634
- const json = await res.json();
3635
- const message = getErrorMessageFromUnknownJson(json, JSON.stringify(json));
3355
+ function serializePatchLog(entries) {
3356
+ return [HEADER, ...entries.map(formatPatchLogLine), ""].join("\n");
3357
+ }
3358
+ function readPatchLog(logFilePath) {
3359
+ let raw;
3360
+ try {
3361
+ raw = fs.readFileSync(logFilePath, "utf-8");
3362
+ } catch (err) {
3363
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
3636
3364
  return {
3637
- success: false,
3638
- error: `Failed to upload remote file: ${message}.`
3365
+ status: "absent"
3639
3366
  };
3640
3367
  }
3641
3368
  return {
3642
- success: false,
3643
- error: `An unexpected error occurred while uploading remote file. HTTP status was: ${await res.text()}.`
3369
+ status: "unreadable",
3370
+ message: `Could not read ${logFilePath}: ${err instanceof Error ? err.message : "unknown error"}`
3644
3371
  };
3645
3372
  }
3646
- return {
3647
- success: true
3648
- };
3373
+ return parsePatchLog(raw);
3649
3374
  }
3375
+ function parsePatchLog(raw) {
3376
+ const problems = [];
3377
+ // A zero-byte file is a store that was created and then interrupted before the
3378
+ // header landed. Empty, not broken: there are no patches either way.
3379
+ if (raw === "") {
3380
+ return {
3381
+ status: "ok",
3382
+ entries: [],
3383
+ problems
3384
+ };
3385
+ }
3386
+ const endsWithNewline = raw.endsWith("\n");
3387
+ const lines = raw.split("\n");
3388
+ // `split` always leaves a trailing "" when the text ends in a newline; when it
3389
+ // does not, the last element is a line whose write never finished.
3390
+ const tornFinalLine = endsWithNewline ? null : lines[lines.length - 1];
3391
+ lines.pop();
3392
+ if (tornFinalLine !== null && tornFinalLine.trim() !== "") {
3393
+ problems.push({
3394
+ type: "torn-final-line",
3395
+ line: tornFinalLine
3396
+ });
3397
+ }
3650
3398
 
3651
- function getFileExt(filePath) {
3652
- // 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).
3653
- return filePath.split(".").pop() || "";
3399
+ // Only consume the first line when it really is the header. A headerless file
3400
+ // is reported, but its first line is still offered to the entry parser rather
3401
+ // than eaten: dropping a real patch to pay for a missing header would be the
3402
+ // same silent data loss this store exists to stop.
3403
+ const firstLine = (lines[0] ?? "").replace(/\r$/, "");
3404
+ let firstEntryIndex = 0;
3405
+ if (firstLine === HEADER) {
3406
+ firstEntryIndex = 1;
3407
+ } else if (firstLine.startsWith("val-patch-log ")) {
3408
+ problems.push({
3409
+ type: "unsupported-version",
3410
+ header: firstLine
3411
+ });
3412
+ firstEntryIndex = 1;
3413
+ } else {
3414
+ problems.push({
3415
+ type: "missing-header",
3416
+ firstLine
3417
+ });
3418
+ }
3419
+ const entries = [];
3420
+ const seen = new Set();
3421
+ lines.slice(firstEntryIndex).forEach((rawLine, index) => {
3422
+ const line = rawLine.replace(/\r$/, "");
3423
+ // 1-based and counted from the top of the file, so they match what an editor
3424
+ // shows the person reading this report.
3425
+ const lineNumber = firstEntryIndex + index + 1;
3426
+ if (line.trim() === "") {
3427
+ return;
3428
+ }
3429
+ const entry = parsePatchLogLine(line);
3430
+ if (entry === null) {
3431
+ problems.push({
3432
+ type: "unparseable-line",
3433
+ lineNumber,
3434
+ line
3435
+ });
3436
+ return;
3437
+ }
3438
+ if (seen.has(entry.patchId)) {
3439
+ problems.push({
3440
+ type: "duplicate-entry",
3441
+ patchId: entry.patchId,
3442
+ lineNumber
3443
+ });
3444
+ return;
3445
+ }
3446
+ seen.add(entry.patchId);
3447
+ entries.push(entry);
3448
+ });
3449
+ return {
3450
+ status: "ok",
3451
+ entries,
3452
+ problems
3453
+ };
3654
3454
  }
3655
3455
 
3656
- /** Serializes a schema, or gives up quietly — serialization errors are reported elsewhere. */
3657
- function serializeSchemaSafely(schema) {
3456
+ /**
3457
+ * fsync the directory holding a file, so a rename or create survives a power cut.
3458
+ *
3459
+ * Best-effort: opening a directory is not permitted on Windows, and a store that
3460
+ * refuses to work there to buy durability nobody asked for is the worse trade.
3461
+ */
3462
+ function fsyncDir(dirPath) {
3463
+ let fd;
3658
3464
  try {
3659
- return schema === null || schema === void 0 ? void 0 : schema["executeSerialize"]();
3465
+ fd = fs.openSync(dirPath, "r");
3466
+ fs.fsyncSync(fd);
3660
3467
  } catch {
3661
- return undefined;
3468
+ // ignore: see above
3469
+ } finally {
3470
+ if (fd !== undefined) {
3471
+ try {
3472
+ fs.closeSync(fd);
3473
+ } catch {
3474
+ // ignore
3475
+ }
3476
+ }
3662
3477
  }
3663
3478
  }
3664
- class ValOpsFS extends ValOps {
3665
- static VAL_DIR = ".val";
3666
- constructor(contentUrl, rootDir, valModules, options) {
3667
- super(valModules, options);
3668
- this.contentUrl = contentUrl;
3669
- this.rootDir = rootDir;
3670
- this.host = new FSOpsHost();
3671
- this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
3479
+
3480
+ /**
3481
+ * Add one entry to the end of the log.
3482
+ *
3483
+ * A single `writeSync` on an append-only descriptor, then fsync: the whole line
3484
+ * reaches the file or none of it does, and a reader that catches it mid-flight
3485
+ * discards the partial last line rather than misreading it.
3486
+ *
3487
+ * Callers must hold the patch lock. That is what makes "the torn line can only be
3488
+ * the last one" true, and it is why this does not try to be safe against
3489
+ * concurrent appends on its own.
3490
+ */
3491
+ function appendPatchLogEntry(logFilePath, entry) {
3492
+ const dirPath = path__default.dirname(logFilePath);
3493
+ fs.mkdirSync(dirPath, {
3494
+ recursive: true
3495
+ });
3496
+ if (!fs.existsSync(logFilePath)) {
3497
+ writePatchLogFile(logFilePath, []);
3498
+ }
3499
+ const fd = fs.openSync(logFilePath, "a");
3500
+ try {
3501
+ fs.writeSync(fd, `${formatPatchLogLine(entry)}\n`);
3502
+ fs.fsyncSync(fd);
3503
+ } finally {
3504
+ fs.closeSync(fd);
3672
3505
  }
3506
+ }
3673
3507
 
3674
- /**
3675
- * Change detection for `.jsonValues()` entry files, which no sha can see (their
3676
- * content lives behind a thunk that `JSON.stringify` drops).
3508
+ /**
3509
+ * Replace the log wholesale — used by delete and by repair.
3510
+ *
3511
+ * Write a sibling temp file, fsync it, then rename over the original: a rename is
3512
+ * atomic, so a reader either sees the old log or the new one and never a
3513
+ * half-rewritten file. In-place truncation would have a window where the log is
3514
+ * short and the store looks like it lost patches.
3515
+ *
3516
+ * Callers must hold the patch lock.
3517
+ */
3518
+ function writePatchLogFile(logFilePath, entries) {
3519
+ const dirPath = path__default.dirname(logFilePath);
3520
+ fs.mkdirSync(dirPath, {
3521
+ recursive: true
3522
+ });
3523
+ const tmpPath = `${logFilePath}.tmp-${process.pid}`;
3524
+ const fd = fs.openSync(tmpPath, "w");
3525
+ try {
3526
+ fs.writeSync(fd, serializePatchLog(entries));
3527
+ fs.fsyncSync(fd);
3528
+ } finally {
3529
+ fs.closeSync(fd);
3530
+ }
3531
+ fs.renameSync(tmpPath, logFilePath);
3532
+ fsyncDir(dirPath);
3533
+ }
3534
+
3535
+ /**
3536
+ * The on-disk shape of the local-dev patch store.
3537
+ *
3538
+ * ```
3539
+ * .val/patches/
3540
+ * patches.log the order, and the only place it lives
3541
+ * patches.repair.log what repair has done, for the person who has to know
3542
+ * <patchId>/patch.json one plain, self-contained directory per patch
3543
+ * <patchId>/base.json written when the patch is published
3544
+ * <patchId>/files/… binary payloads for this patch's file ops
3545
+ * ```
3546
+ *
3547
+ * A directory is named after the patch it holds, and a record references nothing
3548
+ * outside itself. That is the whole design, and it is a direct answer to how the
3549
+ * old layout failed: there, a directory was named after a record's PARENT, so
3550
+ * reading the store meant following links, and one absent record silently cut off
3551
+ * every patch written after it.
3552
+ *
3553
+ * The invariant this module exists to hold up is narrow and worth stating: **the
3554
+ * announced set and the delivered set are the same array.** `getStat` and
3555
+ * `fetchPatches` both come out of one `readPatchStore` call, so they cannot report
3556
+ * different numbers - which is exactly what they did when this broke, announcing
3557
+ * 410 patches and delivering 359 with no error in between.
3558
+ */
3559
+
3560
+ const PATCH_REPAIR_LOG_FILE_NAME = "patches.repair.log";
3561
+ const FSPatch = z.object({
3562
+ path: z.string().refine(p => p.startsWith("/") && p.includes(".val."), "Path is not valid. Must start with '/' and include '.val.'"),
3563
+ patch: Patch,
3564
+ patchId: z.string(),
3565
+ baseSha: z.string().refine(p => true),
3566
+ authorId: z.string().refine(p => true).nullable(),
3567
+ createdAt: z.string().datetime(),
3568
+ coreVersion: z.string().nullable(),
3569
+ sessionId: z.string().nullable()
3570
+ });
3571
+ const FSPatchBase = z.object({
3572
+ baseSha: z.string().refine(p => true),
3573
+ timestamp: z.string().datetime()
3574
+ });
3575
+
3576
+ /**
3577
+ * Something wrong with the store that a reader can see.
3578
+ *
3579
+ * Every one of these used to be invisible. Reporting them is the point: the
3580
+ * failure that motivated this rewrite was not that the store broke, it was that
3581
+ * breaking looked exactly like working.
3582
+ */
3583
+
3584
+ function patchesLogFile(patchesDir) {
3585
+ return path__default.join(patchesDir, PATCH_LOG_FILE_NAME);
3586
+ }
3587
+ function patchRepairLogFile(patchesDir) {
3588
+ return path__default.join(patchesDir, PATCH_REPAIR_LOG_FILE_NAME);
3589
+ }
3590
+ function patchDir(patchesDir, patchId) {
3591
+ return path__default.join(patchesDir, patchId);
3592
+ }
3593
+ function patchRecordFile(patchesDir, patchId) {
3594
+ return path__default.join(patchDir(patchesDir, patchId), "patch.json");
3595
+ }
3596
+ function patchBaseFile(patchesDir, patchId) {
3597
+ return path__default.join(patchDir(patchesDir, patchId), "base.json");
3598
+ }
3599
+ function patchBinaryFile(patchesDir, patchId, filePath) {
3600
+ return path__default.join(patchDir(patchesDir, patchId), "files", filePath, path__default.basename(filePath));
3601
+ }
3602
+ function patchBinaryFileMetadata(patchesDir, patchId, filePath) {
3603
+ return path__default.join(patchDir(patchesDir, patchId), "files", filePath, "metadata.json");
3604
+ }
3605
+
3606
+ /** Names that live in the patches directory but are not patches. */
3607
+ const RESERVED_NAMES = new Set([PATCH_LOG_FILE_NAME, PATCH_REPAIR_LOG_FILE_NAME]);
3608
+ function readJsonFile(filePath, parser) {
3609
+ let raw;
3610
+ try {
3611
+ raw = fs.readFileSync(filePath, "utf-8");
3612
+ } catch (err) {
3613
+ return {
3614
+ error: err instanceof Error ? err.message : "unknown error"
3615
+ };
3616
+ }
3617
+ if (raw === "") {
3618
+ return {
3619
+ error: "the file is empty"
3620
+ };
3621
+ }
3622
+ let json;
3623
+ try {
3624
+ json = JSON.parse(raw);
3625
+ } catch (err) {
3626
+ return {
3627
+ error: `it is not valid JSON: ${err instanceof Error ? err.message : "unknown error"}`
3628
+ };
3629
+ }
3630
+ const parsed = parser.safeParse(json);
3631
+ if (!parsed.success) {
3632
+ return {
3633
+ error: fromError(parsed.error).toString()
3634
+ };
3635
+ }
3636
+ return {
3637
+ data: parsed.data
3638
+ };
3639
+ }
3640
+ function listPatchDirNames(patchesDir) {
3641
+ return fs.readdirSync(patchesDir, {
3642
+ withFileTypes: true
3643
+ }).filter(dirent => dirent.isDirectory() && !RESERVED_NAMES.has(dirent.name)).map(dirent => dirent.name);
3644
+ }
3645
+
3646
+ /**
3647
+ * Read the whole store: the order, the records, and everything wrong with it.
3648
+ *
3649
+ * One call, one answer. Callers that need only the ids and callers that need the
3650
+ * ops both use this, which is what stops them disagreeing.
3651
+ */
3652
+ function readPatchStore(patchesDir) {
3653
+ if (!fs.existsSync(patchesDir)) {
3654
+ return {
3655
+ status: "ok",
3656
+ entries: [],
3657
+ problems: []
3658
+ };
3659
+ }
3660
+ let dirNames;
3661
+ try {
3662
+ dirNames = listPatchDirNames(patchesDir);
3663
+ } catch (err) {
3664
+ return {
3665
+ status: "unreadable",
3666
+ message: `Could not list ${patchesDir}: ${err instanceof Error ? err.message : "unknown error"}`
3667
+ };
3668
+ }
3669
+ const problems = [];
3670
+ const logRes = readPatchLog(patchesLogFile(patchesDir));
3671
+ if (logRes.status === "unreadable") {
3672
+ return {
3673
+ status: "unreadable",
3674
+ message: logRes.message
3675
+ };
3676
+ }
3677
+ let logEntries;
3678
+ if (logRes.status === "absent") {
3679
+ if (dirNames.length === 0) {
3680
+ return {
3681
+ status: "ok",
3682
+ entries: [],
3683
+ problems: []
3684
+ };
3685
+ }
3686
+ logEntries = reconstructLogEntries(patchesDir, dirNames);
3687
+ if (logEntries.length > 0) {
3688
+ problems.push({
3689
+ type: "reconstructed-log",
3690
+ entryCount: logEntries.length
3691
+ });
3692
+ }
3693
+ } else {
3694
+ logEntries = logRes.entries;
3695
+ for (const problem of logRes.problems) {
3696
+ problems.push({
3697
+ type: "log",
3698
+ problem
3699
+ });
3700
+ }
3701
+ }
3702
+ const entries = [];
3703
+ const named = new Set();
3704
+ for (const logEntry of logEntries) {
3705
+ named.add(logEntry.patchId);
3706
+ const read = readPatchRecord(patchesDir, logEntry.patchId);
3707
+ if (read.error !== undefined) {
3708
+ problems.push({
3709
+ type: "unreadable-patch",
3710
+ name: logEntry.patchId,
3711
+ dir: patchDir(patchesDir, logEntry.patchId),
3712
+ message: read.error
3713
+ });
3714
+ continue;
3715
+ }
3716
+ const baseFile = patchBaseFile(patchesDir, logEntry.patchId);
3717
+ const baseRes = fs.existsSync(baseFile) ? readJsonFile(baseFile, FSPatchBase) : undefined;
3718
+ entries.push({
3719
+ patchId: logEntry.patchId,
3720
+ record: read.data,
3721
+ base: (baseRes === null || baseRes === void 0 ? void 0 : baseRes.data) ?? null
3722
+ });
3723
+ }
3724
+ for (const name of dirNames) {
3725
+ if (named.has(name)) {
3726
+ continue;
3727
+ }
3728
+ // Told apart by whether the directory holds a usable record. One is a patch
3729
+ // whose contents are lost and has to be reported; the other is a crash
3730
+ // leftover nothing ever read.
3731
+ const read = readPatchRecord(patchesDir, name);
3732
+ if (read.error !== undefined) {
3733
+ problems.push({
3734
+ type: "unreadable-patch",
3735
+ name,
3736
+ dir: path__default.join(patchesDir, name),
3737
+ message: read.error
3738
+ });
3739
+ } else {
3740
+ problems.push({
3741
+ type: "orphan-directory",
3742
+ name,
3743
+ dir: path__default.join(patchesDir, name)
3744
+ });
3745
+ }
3746
+ }
3747
+ return {
3748
+ status: "ok",
3749
+ entries,
3750
+ problems
3751
+ };
3752
+ }
3753
+
3754
+ /**
3755
+ * Read one patch directory, insisting it holds the patch it is named after.
3756
+ *
3757
+ * The name check is not a formality. A directory named after something other
3758
+ * than the record inside is how the previous layout worked - the name was the
3759
+ * record's PARENT - so this is what tells a store this version can use from one
3760
+ * it cannot.
3761
+ */
3762
+ function readPatchRecord(patchesDir, name) {
3763
+ const file = patchRecordFile(patchesDir, name);
3764
+ if (!fs.existsSync(file)) {
3765
+ return {
3766
+ error: `there is no ${file}`
3767
+ };
3768
+ }
3769
+ const res = readJsonFile(file, FSPatch);
3770
+ if (res.error !== undefined) {
3771
+ return {
3772
+ error: res.error
3773
+ };
3774
+ }
3775
+ if (res.data.patchId !== name) {
3776
+ return {
3777
+ error: `it holds ${res.data.patchId}, not the patch its directory is named after`
3778
+ };
3779
+ }
3780
+ return {
3781
+ data: res.data
3782
+ };
3783
+ }
3784
+
3785
+ /**
3786
+ * Rebuild the order from the records when the log itself is gone.
3787
+ *
3788
+ * `createdAt` is the only ordering left, and it is not a perfect one, so the
3789
+ * caller reports that the order was guessed rather than presenting it as fact.
3790
+ */
3791
+ function reconstructLogEntries(patchesDir, dirNames) {
3792
+ const entries = [];
3793
+ for (const name of dirNames) {
3794
+ const res = readJsonFile(path__default.join(patchesDir, name, "patch.json"), FSPatch);
3795
+ if (res.error !== undefined || res.data.patchId !== name) {
3796
+ continue;
3797
+ }
3798
+ entries.push({
3799
+ patchId: res.data.patchId,
3800
+ createdAt: res.data.createdAt,
3801
+ path: res.data.path
3802
+ });
3803
+ }
3804
+ entries.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3805
+ return entries;
3806
+ }
3807
+
3808
+ /** Write a patch record so that a reader sees all of it or none of it. */
3809
+ function writePatchRecord(patchesDir, patchId, record) {
3810
+ const dir = patchDir(patchesDir, patchId);
3811
+ fs.mkdirSync(dir, {
3812
+ recursive: true
3813
+ });
3814
+ const file = patchRecordFile(patchesDir, patchId);
3815
+ const tmpFile = `${file}.tmp-${process.pid}`;
3816
+ const fd = fs.openSync(tmpFile, "w");
3817
+ try {
3818
+ fs.writeSync(fd, JSON.stringify(record));
3819
+ fs.fsyncSync(fd);
3820
+ } finally {
3821
+ fs.closeSync(fd);
3822
+ }
3823
+ fs.renameSync(tmpFile, file);
3824
+ }
3825
+
3826
+ /**
3827
+ * Add a patch to the store.
3828
+ *
3829
+ * Record first, then the log line, and the order is the safety property: an
3830
+ * interrupted append leaves a directory nothing points at, which repair sweeps
3831
+ * up. The reverse order would leave the log naming a patch that is not there -
3832
+ * the exact state this whole rewrite exists to make unreachable.
3833
+ *
3834
+ * Callers must hold the patch lock.
3835
+ */
3836
+ function appendPatch(patchesDir, record) {
3837
+ const patchId = record.patchId;
3838
+ writePatchRecord(patchesDir, patchId, record);
3839
+ const entry = {
3840
+ patchId,
3841
+ createdAt: record.createdAt,
3842
+ path: record.path
3843
+ };
3844
+ appendPatchLogEntry(patchesLogFile(patchesDir), entry);
3845
+ return entry;
3846
+ }
3847
+ /**
3848
+ * Bring the store back to a state where the log and the directories agree.
3849
+ *
3850
+ * Safe in a way the old layout's repair could never be: the log is a flat list,
3851
+ * so dropping an entry does not orphan the entries after it. There is nothing to
3852
+ * re-link, which is why this can run unattended where re-parenting a chain could
3853
+ * not.
3854
+ *
3855
+ * A patch that cannot be read is removed rather than kept around to fail again
3856
+ * on every load. That does discard unpublished work, which is why
3857
+ * {@link RepairAction} carries the reason, why it is written to
3858
+ * `patches.repair.log`, and why the caller is expected to tell the person
3859
+ * editing.
3860
+ *
3861
+ * Callers must hold the patch lock.
3862
+ */
3863
+ function repairPatchStore(patchesDir, read) {
3864
+ const actions = [];
3865
+ const needsRewrite = read.problems.some(problem => problem.type === "unreadable-patch" || problem.type === "reconstructed-log" || problem.type === "log");
3866
+ for (const problem of read.problems) {
3867
+ if (problem.type === "unreadable-patch") {
3868
+ remove(problem.dir);
3869
+ actions.push({
3870
+ type: "removed-unreadable-patch",
3871
+ name: problem.name,
3872
+ because: problem.message
3873
+ });
3874
+ } else if (problem.type === "orphan-directory") {
3875
+ remove(problem.dir);
3876
+ actions.push({
3877
+ type: "removed-orphan-directory",
3878
+ name: problem.name
3879
+ });
3880
+ }
3881
+ }
3882
+ if (needsRewrite) {
3883
+ writePatchLogFile(patchesLogFile(patchesDir), read.entries.map(entry => ({
3884
+ patchId: entry.patchId,
3885
+ createdAt: entry.record.createdAt,
3886
+ path: entry.record.path
3887
+ })));
3888
+ actions.push({
3889
+ type: "rewrote-log",
3890
+ entryCount: read.entries.length
3891
+ });
3892
+ }
3893
+ if (actions.length > 0) {
3894
+ recordRepair(patchesDir, actions);
3895
+ }
3896
+ return actions;
3897
+ }
3898
+ function remove(dir) {
3899
+ try {
3900
+ fs.rmSync(dir, {
3901
+ recursive: true,
3902
+ force: true
3903
+ });
3904
+ } catch {
3905
+ // Left in place. It is inert either way - nothing reads a directory the log
3906
+ // does not name, and the log has already been rewritten without it.
3907
+ }
3908
+ }
3909
+
3910
+ /**
3911
+ * Leave a trail. Repair drops a person's unpublished edits, and doing that
3912
+ * without saying so anywhere durable is how you get someone certain they saved
3913
+ * something and no way to tell them what happened to it.
3914
+ */
3915
+ function recordRepair(patchesDir, actions) {
3916
+ const at = new Date().toISOString();
3917
+ const lines = actions.map(action => {
3918
+ if (action.type === "removed-unreadable-patch") {
3919
+ return `${at} removed ${action.name}: ${action.because}`;
3920
+ }
3921
+ if (action.type === "removed-orphan-directory") {
3922
+ return `${at} removed orphan directory ${action.name}`;
3923
+ }
3924
+ return `${at} rewrote the log with ${action.entryCount} entries`;
3925
+ });
3926
+ try {
3927
+ fs.appendFileSync(patchRepairLogFile(patchesDir), `${lines.join("\n")}\n`, "utf-8");
3928
+ } catch {
3929
+ // An audit trail that cannot be written is not a reason to fail the repair.
3930
+ }
3931
+ }
3932
+
3933
+ /**
3934
+ * Last resort: move the whole store aside and start empty.
3935
+ *
3936
+ * A rename, never a delete. What is being given up on here is someone's
3937
+ * unpublished work, and the least this can do is say where it went.
3938
+ *
3939
+ * Callers must hold the patch lock.
3940
+ */
3941
+ function resetPatchStore(patchesDir, reason) {
3942
+ if (!fs.existsSync(patchesDir)) {
3943
+ return {
3944
+ movedTo: patchesDir
3945
+ };
3946
+ }
3947
+ const movedTo = `${patchesDir}-corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
3948
+ try {
3949
+ fs.renameSync(patchesDir, movedTo);
3950
+ } catch (err) {
3951
+ return {
3952
+ error: `Could not move ${patchesDir} aside: ${err instanceof Error ? err.message : "unknown error"}`
3953
+ };
3954
+ }
3955
+ try {
3956
+ fs.mkdirSync(patchesDir, {
3957
+ recursive: true
3958
+ });
3959
+ fs.appendFileSync(patchRepairLogFile(patchesDir), `${new Date().toISOString()} reset the store (${reason}); the previous contents are in ${movedTo}\n`, "utf-8");
3960
+ } catch {
3961
+ // The rename is the part that mattered.
3962
+ }
3963
+ return {
3964
+ movedTo
3965
+ };
3966
+ }
3967
+
3968
+ /** One line per problem, for a log line or an API error a person has to act on. */
3969
+ function describePatchStoreProblems(problems) {
3970
+ return problems.map(problem => {
3971
+ switch (problem.type) {
3972
+ case "unreadable-patch":
3973
+ return `The change ${problem.name} could not be read: ${problem.message} (${problem.dir})`;
3974
+ case "orphan-directory":
3975
+ return `${problem.dir} is not listed in the log and is not being used.`;
3976
+ case "reconstructed-log":
3977
+ return `The log was missing, so the order of ${problem.entryCount} change(s) was reconstructed from their timestamps and may not be exact.`;
3978
+ case "log":
3979
+ return describeLogProblem(problem.problem);
3980
+ }
3981
+ });
3982
+ }
3983
+ function describeLogProblem(problem) {
3984
+ switch (problem.type) {
3985
+ case "missing-header":
3986
+ return `The log does not start with its header (it starts with ${JSON.stringify(problem.firstLine)}).`;
3987
+ case "unsupported-version":
3988
+ return `The log says ${JSON.stringify(problem.header)}, which this version of Val does not understand.`;
3989
+ case "unparseable-line":
3990
+ return `Line ${problem.lineNumber} of the log could not be read: ${JSON.stringify(problem.line)}`;
3991
+ case "duplicate-entry":
3992
+ return `The change ${problem.patchId} is listed twice in the log (line ${problem.lineNumber}).`;
3993
+ case "torn-final-line":
3994
+ return `The last line of the log was not finished being written and was ignored.`;
3995
+ }
3996
+ }
3997
+
3998
+ /**
3999
+ * A timed, cross-process lock over the patches directory.
4000
+ *
4001
+ * ## Why there is one at all
4002
+ *
4003
+ * Nothing serialized writes to the patch store before this. Two dev-server route
4004
+ * handlers, two browser tabs and the CLI all wrote to the same directory, and the
4005
+ * one guard that existed — a `mkdir` whose stated purpose was to throw when the
4006
+ * directory already existed — had its `EEXIST` swallowed by a bare `catch {}`, so
4007
+ * a colliding write silently overwrote the patch that was already there. A store
4008
+ * that loses a patch that way loses every patch chained behind it too.
4009
+ *
4010
+ * ## Why a file, and why timed
4011
+ *
4012
+ * A real lock manager is more than this problem needs: the contenders are a
4013
+ * handful of processes on one machine, holding it for milliseconds. What that
4014
+ * does need is to survive a holder that dies — `kill -9` on a dev server, a
4015
+ * laptop lid, a crashed test — without leaving the store permanently unwritable.
4016
+ * Hence an expiry: a lock nobody renews stops being a lock.
4017
+ *
4018
+ * The file is plain `key: value` text on purpose. When something is stuck, the
4019
+ * question is always "who is holding it and since when", and `cat .val/patches.lock`
4020
+ * should answer it without a tool.
4021
+ *
4022
+ * ## The takeover race
4023
+ *
4024
+ * Two processes can decide the same lock is stale at the same moment, and a blind
4025
+ * `unlink` would then let the second one delete the FIRST one's fresh lock. So a
4026
+ * stale lock is only removed after being read twice, unchanged, across a poll
4027
+ * interval, and the `owner` nonce is what "unchanged" means. Whoever wins the
4028
+ * `wx` create afterwards is unique regardless, because that create is atomic —
4029
+ * the double read is what stops a winner being deleted a moment after winning.
4030
+ */
4031
+
4032
+ const PATCH_LOCK_FILE_NAME = "patches.lock";
4033
+ const DEFAULT_TIMEOUT_MS = 10_000;
4034
+ const DEFAULT_POLL_MS = 25;
4035
+
4036
+ /**
4037
+ * Locks this process is currently holding, released on exit.
4038
+ *
4039
+ * A best-effort courtesy: it turns the common crash — an unhandled throw, a
4040
+ * ctrl-C — into an immediately reusable store instead of one that waits out a
4041
+ * TTL. The TTL is still what makes correctness not depend on this running.
4042
+ */
4043
+ const heldLocks = new Set();
4044
+ let exitHookInstalled = false;
4045
+ function installExitHook() {
4046
+ if (exitHookInstalled) {
4047
+ return;
4048
+ }
4049
+ exitHookInstalled = true;
4050
+ process.once("exit", () => {
4051
+ heldLocks.forEach(lock => {
4052
+ try {
4053
+ lock.release();
4054
+ } catch {
4055
+ // Nothing useful can be done during exit.
4056
+ }
4057
+ });
4058
+ });
4059
+ }
4060
+ function formatPatchLockInfo(info) {
4061
+ return [`owner: ${info.owner}`, `pid: ${info.pid}`, `host: ${info.host}`, `op: ${info.op}`, `acquired: ${info.acquiredAt}`, `expires: ${info.expiresAt}`, ""].join("\n");
4062
+ }
4063
+ function parsePatchLockInfo(raw) {
4064
+ const fields = {};
4065
+ for (const line of raw.split("\n")) {
4066
+ const separator = line.indexOf(":");
4067
+ if (separator === -1) {
4068
+ continue;
4069
+ }
4070
+ fields[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
4071
+ }
4072
+ const {
4073
+ owner,
4074
+ pid,
4075
+ host,
4076
+ op,
4077
+ acquired,
4078
+ expires
4079
+ } = fields;
4080
+ if (!owner || !acquired || !expires) {
4081
+ return null;
4082
+ }
4083
+ const parsedPid = Number.parseInt(pid ?? "", 10);
4084
+ return {
4085
+ owner,
4086
+ pid: Number.isNaN(parsedPid) ? -1 : parsedPid,
4087
+ host: host ?? "",
4088
+ op: op ?? "",
4089
+ acquiredAt: acquired,
4090
+ expiresAt: expires
4091
+ };
4092
+ }
4093
+ function readRaw(lockFilePath) {
4094
+ try {
4095
+ return fs.readFileSync(lockFilePath, "utf-8");
4096
+ } catch {
4097
+ return null;
4098
+ }
4099
+ }
4100
+ function isExpired(info, nowMs) {
4101
+ // An unparseable lock file is treated as expired. It is either a half-written
4102
+ // acquisition or something that is not a lock at all, and neither should be
4103
+ // able to wedge the store forever.
4104
+ if (info === null) {
4105
+ return true;
4106
+ }
4107
+ const expiresAt = Date.parse(info.expiresAt);
4108
+ if (Number.isNaN(expiresAt)) {
4109
+ return true;
4110
+ }
4111
+ return expiresAt <= nowMs;
4112
+ }
4113
+ function describeHolder(info) {
4114
+ if (info === null) {
4115
+ return "the lock file could not be read";
4116
+ }
4117
+ return `held by pid ${info.pid} on ${info.host || "unknown host"} for "${info.op}" since ${info.acquiredAt} (expires ${info.expiresAt})`;
4118
+ }
4119
+ function sleep(ms) {
4120
+ return new Promise(resolve => setTimeout(resolve, ms));
4121
+ }
4122
+
4123
+ /**
4124
+ * Try to create the lock file exclusively.
4125
+ *
4126
+ * `wx` is `O_CREAT | O_EXCL`: the create either happens or reports EEXIST, with
4127
+ * no window in between. Everything else in this module exists to handle the
4128
+ * EEXIST case; this is the part that actually provides mutual exclusion.
4129
+ */
4130
+ function tryCreate(lockFilePath, info) {
4131
+ let fd;
4132
+ try {
4133
+ fd = fs.openSync(lockFilePath, "wx");
4134
+ } catch (err) {
4135
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") {
4136
+ return "exists";
4137
+ }
4138
+ return {
4139
+ error: err instanceof Error ? err.message : "unknown error"
4140
+ };
4141
+ }
4142
+ try {
4143
+ fs.writeSync(fd, formatPatchLockInfo(info));
4144
+ fs.fsyncSync(fd);
4145
+ } finally {
4146
+ fs.closeSync(fd);
4147
+ }
4148
+ return "created";
4149
+ }
4150
+ async function acquirePatchLock(lockFilePath, options) {
4151
+ const now = options.now ?? Date.now;
4152
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4153
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
4154
+ const deadline = now() + timeoutMs;
4155
+ try {
4156
+ fs.mkdirSync(path__default.dirname(lockFilePath), {
4157
+ recursive: true
4158
+ });
4159
+ } catch (err) {
4160
+ return {
4161
+ status: "error",
4162
+ message: `Could not create the directory for ${lockFilePath}: ${err instanceof Error ? err.message : "unknown error"}`
4163
+ };
4164
+ }
4165
+ let lastSeenHolder = null;
4166
+ for (;;) {
4167
+ const acquiredAt = now();
4168
+ const info = {
4169
+ owner: crypto$1.randomUUID(),
4170
+ pid: process.pid,
4171
+ host: os.hostname(),
4172
+ op: options.op,
4173
+ acquiredAt: new Date(acquiredAt).toISOString(),
4174
+ expiresAt: new Date(acquiredAt + options.ttlMs).toISOString()
4175
+ };
4176
+ const created = tryCreate(lockFilePath, info);
4177
+ if (created === "created") {
4178
+ const lock = createLock(lockFilePath, info, options.ttlMs, now);
4179
+ installExitHook();
4180
+ heldLocks.add(lock);
4181
+ return {
4182
+ status: "acquired",
4183
+ lock
4184
+ };
4185
+ }
4186
+ if (created !== "exists") {
4187
+ return {
4188
+ status: "error",
4189
+ message: `Could not create ${lockFilePath}: ${created.error}`
4190
+ };
4191
+ }
4192
+ const rawBefore = readRaw(lockFilePath);
4193
+ if (rawBefore === null) {
4194
+ // Released between the failed create and the read. Go straight round again.
4195
+ continue;
4196
+ }
4197
+ lastSeenHolder = parsePatchLockInfo(rawBefore);
4198
+ if (isExpired(lastSeenHolder, now())) {
4199
+ // Read twice across a poll interval before removing it: an unchanged stale
4200
+ // file is one nobody is renewing, and requiring the second read is what
4201
+ // stops two processes both deciding to clear it and the loser wiping the
4202
+ // winner's brand-new lock.
4203
+ await sleep(pollMs);
4204
+ const rawAfter = readRaw(lockFilePath);
4205
+ if (rawAfter === rawBefore && isExpired(lastSeenHolder, now())) {
4206
+ try {
4207
+ fs.unlinkSync(lockFilePath);
4208
+ } catch {
4209
+ // Someone else got there first, which is the outcome we wanted anyway.
4210
+ }
4211
+ }
4212
+ continue;
4213
+ }
4214
+ if (now() >= deadline) {
4215
+ return {
4216
+ status: "busy",
4217
+ holder: lastSeenHolder,
4218
+ message: `Timed out after ${timeoutMs}ms waiting for the Val patch lock at ${lockFilePath}: ${describeHolder(lastSeenHolder)}`
4219
+ };
4220
+ }
4221
+ await sleep(pollMs);
4222
+ }
4223
+ }
4224
+ function createLock(lockFilePath, initialInfo, defaultTtlMs, now) {
4225
+ let info = initialInfo;
4226
+ let released = false;
4227
+ const stillOurs = () => {
4228
+ var _parsePatchLockInfo;
4229
+ const raw = readRaw(lockFilePath);
4230
+ if (raw === null) {
4231
+ return false;
4232
+ }
4233
+ return ((_parsePatchLockInfo = parsePatchLockInfo(raw)) === null || _parsePatchLockInfo === void 0 ? void 0 : _parsePatchLockInfo.owner) === info.owner;
4234
+ };
4235
+ const lock = {
4236
+ get info() {
4237
+ return info;
4238
+ },
4239
+ renew(ttlMs) {
4240
+ if (released || !stillOurs()) {
4241
+ return false;
4242
+ }
4243
+ const next = {
4244
+ ...info,
4245
+ expiresAt: new Date(now() + (ttlMs ?? defaultTtlMs)).toISOString()
4246
+ };
4247
+ const fd = fs.openSync(lockFilePath, "w");
4248
+ try {
4249
+ fs.writeSync(fd, formatPatchLockInfo(next));
4250
+ fs.fsyncSync(fd);
4251
+ } finally {
4252
+ fs.closeSync(fd);
4253
+ }
4254
+ info = next;
4255
+ return true;
4256
+ },
4257
+ release() {
4258
+ if (released) {
4259
+ return;
4260
+ }
4261
+ released = true;
4262
+ heldLocks.delete(lock);
4263
+ // Only ours. If the lock expired and someone else took it, the file now
4264
+ // belongs to them and deleting it would hand the store to a third party.
4265
+ if (stillOurs()) {
4266
+ try {
4267
+ fs.unlinkSync(lockFilePath);
4268
+ } catch {
4269
+ // Already gone.
4270
+ }
4271
+ }
4272
+ }
4273
+ };
4274
+ return lock;
4275
+ }
4276
+
4277
+ /**
4278
+ * Run `fn` while holding the lock, and always give it back.
4279
+ *
4280
+ * The only shape callers should use: a `release()` that a throw can skip is how
4281
+ * a store ends up locked by a process that has long since moved on.
4282
+ */
4283
+ async function withPatchLock(lockFilePath, options, fn) {
4284
+ const acquired = await acquirePatchLock(lockFilePath, options);
4285
+ if (acquired.status !== "acquired") {
4286
+ return acquired;
4287
+ }
4288
+ try {
4289
+ return {
4290
+ status: "ok",
4291
+ value: await fn(acquired.lock)
4292
+ };
4293
+ } finally {
4294
+ acquired.lock.release();
4295
+ }
4296
+ }
4297
+
4298
+ async function uploadRemoteFile(contentHost, project, bucket, fileHash, fileExt, fileBuffer, auth) {
4299
+ const authHeader = "apiKey" in auth ? {
4300
+ Authorization: `Bearer ${auth.apiKey}`
4301
+ } : {
4302
+ "x-val-pat": auth.pat
4303
+ };
4304
+ const res = await fetch(`${contentHost}/v1/${project}/remote/files/b/${bucket}/f/${fileHash}.${fileExt}`, {
4305
+ method: "PUT",
4306
+ headers: {
4307
+ ...authHeader,
4308
+ "Content-Type": "application/octet-stream"
4309
+ },
4310
+ body: new Uint8Array(fileBuffer)
4311
+ });
4312
+ if (!res.ok) {
4313
+ var _res$headers$get;
4314
+ if (res.status === 409) {
4315
+ // File already exists
4316
+ return {
4317
+ success: true
4318
+ };
4319
+ }
4320
+ if ((_res$headers$get = res.headers.get("content-type")) !== null && _res$headers$get !== void 0 && _res$headers$get.includes("application/json")) {
4321
+ const json = await res.json();
4322
+ const message = getErrorMessageFromUnknownJson(json, JSON.stringify(json));
4323
+ return {
4324
+ success: false,
4325
+ error: `Failed to upload remote file: ${message}.`
4326
+ };
4327
+ }
4328
+ return {
4329
+ success: false,
4330
+ error: `An unexpected error occurred while uploading remote file. HTTP status was: ${await res.text()}.`
4331
+ };
4332
+ }
4333
+ return {
4334
+ success: true
4335
+ };
4336
+ }
4337
+
4338
+ function getFileExt(filePath) {
4339
+ // 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).
4340
+ return filePath.split(".").pop() || "";
4341
+ }
4342
+
4343
+ /** Serializes a schema, or gives up quietly — serialization errors are reported elsewhere. */
4344
+ function serializeSchemaSafely(schema) {
4345
+ try {
4346
+ return schema === null || schema === void 0 ? void 0 : schema["executeSerialize"]();
4347
+ } catch {
4348
+ return undefined;
4349
+ }
4350
+ }
4351
+ class ValOpsFS extends ValOps {
4352
+ static VAL_DIR = ".val";
4353
+ constructor(contentUrl, rootDir, valModules, options) {
4354
+ super(valModules, options);
4355
+ this.contentUrl = contentUrl;
4356
+ this.rootDir = rootDir;
4357
+ this.host = new FSOpsHost();
4358
+ this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
4359
+ }
4360
+
4361
+ /**
4362
+ * Change detection for `.jsonValues()` entry files, which no sha can see (their
4363
+ * content lives behind a thunk that `JSON.stringify` drops).
3677
4364
  */
3678
4365
 
4366
+ /**
4367
+ * Unpublished changes repair threw away, waiting to be told to somebody.
4368
+ *
4369
+ * Held here and drained by the next `getStat`, because stat is the channel
4370
+ * that always flows. The case worth reporting is a repair that removed
4371
+ * EVERYTHING - which is what an old store looks like - and then there is
4372
+ * nothing left for the studio to fetch, so a notice riding on `fetchPatches`
4373
+ * would never be collected. Drained rather than kept, because this has to be
4374
+ * said once: a permanent flag would put a toast on the screen forever.
4375
+ *
4376
+ * Held in memory only: a restart loses it, and that is the right trade. The
4377
+ * durable record is `patches.repair.log`.
4378
+ */
4379
+ removedPatchNotices = [];
3679
4380
  async onInit() {
3680
4381
  // do nothing
3681
4382
  }
@@ -3769,14 +4470,28 @@ class ValOpsFS extends ValOps {
3769
4470
  const serializedSchemas = Object.fromEntries(Object.entries(schemas).map(([path, schema]) => [path, serializeSchemaSafely(schema)]));
3770
4471
  const currentJsonEntriesSha = this.jsonEntryFilesFingerprint.compute(serializedSchemas);
3771
4472
  const jsonEntryFilePaths = this.jsonEntryFilesFingerprint.entryFilePaths(serializedSchemas);
3772
- const patchData = await this.readPatches();
3773
- const patches = [];
3774
- // TODO: use proper patch sequences when available:
3775
- for (const [patchId] of Object.entries(patchData.patches).sort(([, a], [, b]) => {
3776
- return a.createdAt.localeCompare(b.createdAt, undefined);
3777
- })) {
3778
- patches.push(patchId);
4473
+
4474
+ // The SAME read `fetchPatches` delivers from. Announcing out of one source
4475
+ // and delivering out of another is what let this store tell a studio about
4476
+ // 410 unpublished changes and then hand over 359 of them, with nothing in
4477
+ // between reporting that anything was wrong.
4478
+ const announceRes = await this.readStore();
4479
+ if (announceRes.status === "error") {
4480
+ return {
4481
+ type: "error",
4482
+ error: {
4483
+ message: announceRes.message
4484
+ }
4485
+ };
3779
4486
  }
4487
+ const patches = announceRes.entries.map(entry => entry.patchId);
4488
+ // Drained here, on the channel that always flows. A repair that removed
4489
+ // everything leaves the studio nothing to fetch, so a notice riding on
4490
+ // `GET /patches` would sit here unread.
4491
+ const removed = this.removedPatchNotices.splice(0, this.removedPatchNotices.length);
4492
+ const removedNotice = removed.length > 0 ? {
4493
+ removed
4494
+ } : {};
3780
4495
  // something changed: return immediately
3781
4496
  const didChange = !params ||
3782
4497
  // An entry file changed on disk: nothing else here can see that, since a
@@ -3791,6 +4506,7 @@ class ValOpsFS extends ValOps {
3791
4506
  schemaSha: currentSchemaSha,
3792
4507
  sourcesSha: currentSourcesSha,
3793
4508
  patches,
4509
+ ...removedNotice,
3794
4510
  jsonEntriesSha: currentJsonEntriesSha
3795
4511
  };
3796
4512
  }
@@ -3915,6 +4631,7 @@ class ValOpsFS extends ValOps {
3915
4631
  schemaSha: currentSchemaSha,
3916
4632
  sourcesSha: currentSourcesSha,
3917
4633
  patches,
4634
+ ...removedNotice,
3918
4635
  jsonEntriesSha: currentJsonEntriesSha
3919
4636
  };
3920
4637
  } catch (err) {
@@ -3934,134 +4651,152 @@ class ValOpsFS extends ValOps {
3934
4651
  };
3935
4652
  }
3936
4653
  }
3937
- async readPatches(includes) {
3938
- const patchesCacheDir = this.getPatchesDir();
3939
- let patchJsonFiles = [];
3940
- if (!this.host.directoryExists || this.host.directoryExists && this.host.directoryExists(patchesCacheDir)) {
3941
- patchJsonFiles = this.host.readDirectory(patchesCacheDir, ["patch.json"], [], []);
3942
- }
3943
- const patches = {};
3944
- const errors = [];
3945
- const parsedUnsortedFsPatches = patchJsonFiles.map(file => path__default.basename(path__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]);
3946
- parsedUnsortedFsPatches.forEach(([dir, parsedPatch, parsedBase]) => {
3947
- if (parsedPatch.error) {
3948
- errors.push({
3949
- ...parsedPatch.error,
3950
- parentPatchId: dir
3951
- });
3952
- } else if (parsedBase && parsedBase.error) {
3953
- errors.push({
3954
- ...parsedBase.error,
3955
- parentPatchId: dir
3956
- });
3957
- } else {
3958
- const patchId = parsedPatch.data.patchId;
3959
- if (includes && includes.length > 0 && !includes.includes(patchId)) {
3960
- return;
3961
- }
3962
- patches[patchId] = {
3963
- path: parsedPatch.data.path,
3964
- patch: parsedPatch.data.patch,
3965
- parentRef: parsedPatch.data.parentRef,
3966
- baseSha: parsedPatch.data.baseSha,
3967
- createdAt: parsedPatch.data.createdAt,
3968
- authorId: parsedPatch.data.authorId,
3969
- appliedAt: null
3970
- };
3971
- }
3972
- });
3973
4654
 
3974
- // If there are patches, but no head. error
3975
- if (Object.keys(errors).length > 0) {
4655
+ /**
4656
+ * The one read every other read comes out of.
4657
+ *
4658
+ * `getStat` announces from this and `fetchPatches` delivers from this, so the
4659
+ * two cannot disagree. They used to: `getStat` counted the directories on disk
4660
+ * while `fetchPatches` walked the parent links between them, and when a single
4661
+ * record went missing the first said 410 and the second said 359 — with no
4662
+ * error anywhere, because a walk that runs out of links just stops.
4663
+ *
4664
+ * Anything wrong is reported, then repaired, and reset only if repair does not
4665
+ * settle it. In that order, and never silently.
4666
+ */
4667
+ async readStore() {
4668
+ const read = readPatchStore(this.getPatchesDir());
4669
+ if (read.status === "unreadable") {
3976
4670
  return {
3977
- patches,
3978
- errors
4671
+ status: "error",
4672
+ message: read.message
3979
4673
  };
3980
4674
  }
3981
- return {
3982
- patches
3983
- };
3984
- }
3985
- getParentPatchIdFromParentRef(parentRef) {
3986
- return parentRef.type === "head" ? "head" : parentRef.patchId;
4675
+ if (read.problems.length === 0) {
4676
+ return {
4677
+ status: "ok",
4678
+ entries: read.entries
4679
+ };
4680
+ }
4681
+ return this.repairStore(read);
3987
4682
  }
3988
- async fetchPatches(filters) {
3989
- const fetchPatchesRes = await this.fetchPatchesFromFS(!!filters.excludePatchOps);
3990
- const sortedPatches = this.createPatchChain(fetchPatchesRes.patches).map(patchData => {
3991
- if (filters.excludePatchOps) {
4683
+
4684
+ /**
4685
+ * Report, repair, and reset only if repair did not take.
4686
+ *
4687
+ * Reached only when something is actually wrong, so the healthy path — which
4688
+ * is every stat poll — never touches the lock.
4689
+ */
4690
+ async repairStore(read) {
4691
+ const patchesDir = this.getPatchesDir();
4692
+ for (const problem of describePatchStoreProblems(read.problems)) {
4693
+ console.warn("Val: something is wrong with the patch store.", problem);
4694
+ }
4695
+ const locked = await withPatchLock(this.getPatchLockFile(), {
4696
+ ttlMs: 30_000,
4697
+ op: "repair the patch store"
4698
+ }, () => {
4699
+ // Read again under the lock. What was seen above was seen without it, so
4700
+ // a write may have landed since — and repairing against a stale picture
4701
+ // is how the old delete path destroyed live patches.
4702
+ const current = readPatchStore(patchesDir);
4703
+ if (current.status !== "ok") {
4704
+ return {
4705
+ status: "error",
4706
+ message: current.message
4707
+ };
4708
+ }
4709
+ if (current.problems.length === 0) {
3992
4710
  return {
3993
- ...patchData,
3994
- patch: undefined
4711
+ status: "ok",
4712
+ entries: current.entries
3995
4713
  };
3996
4714
  }
3997
- return patchData;
3998
- }).filter(patchData => {
3999
- if (filters.patchIds && filters.patchIds.length > 0) {
4000
- return filters.patchIds.includes(patchData.patchId);
4715
+ for (const action of repairPatchStore(patchesDir, current)) {
4716
+ if (action.type === "removed-unreadable-patch") {
4717
+ console.warn(`Val: removed the unpublished change ${action.name} because ${action.because}. See ${patchesDir}/${PATCH_REPAIR_LOG_FILE_NAME}.`);
4718
+ // Queued rather than only logged: this is someone's unpublished
4719
+ // work being thrown away, and a server console is not somewhere the
4720
+ // person editing is looking.
4721
+ this.removedPatchNotices.push({
4722
+ patchId: action.name,
4723
+ reason: action.because
4724
+ });
4725
+ } else if (action.type === "removed-orphan-directory") {
4726
+ // Not queued. Nothing ever read this - it is a record whose log line
4727
+ // never landed - so there is no edit to tell anyone about.
4728
+ console.warn(`Val: removed the unused patch directory ${action.name}.`);
4729
+ }
4001
4730
  }
4002
- return true;
4731
+ const after = readPatchStore(patchesDir);
4732
+ if (after.status === "ok" && after.problems.length === 0) {
4733
+ return {
4734
+ status: "ok",
4735
+ entries: after.entries
4736
+ };
4737
+ }
4738
+ // Repair did not settle it, so the store is not something anyone can
4739
+ // describe any more. Move it aside — a rename, never a delete — and say
4740
+ // where it went.
4741
+ const reset = resetPatchStore(patchesDir, "it could not be repaired automatically");
4742
+ if ("error" in reset) {
4743
+ return {
4744
+ status: "error",
4745
+ message: reset.error
4746
+ };
4747
+ }
4748
+ console.warn(`Val: the patch store could not be repaired, so it was reset. The previous contents are in ${reset.movedTo}.`);
4749
+ return {
4750
+ status: "ok",
4751
+ entries: []
4752
+ };
4003
4753
  });
4004
- return {
4005
- patches: sortedPatches,
4006
- errors: fetchPatchesRes.errors
4007
- };
4008
- }
4009
- async fetchPatchesFromFS(excludePatchOps) {
4010
- const patches = {};
4011
- const {
4012
- errors,
4013
- patches: allPatches
4014
- } = await this.readPatches();
4015
- for (const [patchIdS, patch] of Object.entries(allPatches)) {
4016
- const patchId = patchIdS;
4017
- patches[patchId] = {
4018
- patch: excludePatchOps ? undefined : patch.patch,
4019
- parentRef: patch.parentRef,
4020
- path: patch.path,
4021
- baseSha: patch.baseSha,
4022
- createdAt: patch.createdAt,
4023
- authorId: patch.authorId,
4024
- appliedAt: patch.appliedAt
4754
+ if (locked.status !== "ok") {
4755
+ // Someone else holds the lock, quite possibly to run this same repair.
4756
+ // Serving what was read is safe — those entries are the ones that ARE
4757
+ // readable — and the next read tries again.
4758
+ console.warn(`Val: could not repair the patch store now. ${locked.message}`);
4759
+ return {
4760
+ status: "ok",
4761
+ entries: read.entries
4025
4762
  };
4026
4763
  }
4027
- if (errors && errors.length > 0) {
4764
+ return locked.value;
4765
+ }
4766
+ async fetchPatches(filters) {
4767
+ const storeRes = await this.readStore();
4768
+ if (storeRes.status === "error") {
4769
+ // Surfaced, not returned as an empty list. An empty answer to "what is
4770
+ // pending" is indistinguishable from "nothing is pending", and the studio
4771
+ // would render published content over unpublished edits without a word.
4772
+ const none = [];
4028
4773
  return {
4029
- patches,
4030
- errors
4774
+ patches: none,
4775
+ error: {
4776
+ message: storeRes.message
4777
+ }
4778
+ // The cast is unavoidable: the return type is conditional on a generic
4779
+ // that TypeScript cannot narrow from a value.
4031
4780
  };
4032
4781
  }
4782
+ const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
4783
+ const patches = storeRes.entries.filter(entry => requested === null || requested.has(entry.patchId)).map(entry => ({
4784
+ patchId: entry.patchId,
4785
+ path: entry.record.path,
4786
+ patch: filters.excludePatchOps ? undefined : entry.record.patch,
4787
+ createdAt: entry.record.createdAt,
4788
+ authorId: entry.record.authorId,
4789
+ baseSha: entry.record.baseSha,
4790
+ // Publishing deletes the whole store, so in fs mode a patch that is
4791
+ // still here has not been applied. `base.json` is read for its parse
4792
+ // errors, not for this.
4793
+ appliedAt: null
4794
+ }));
4033
4795
  return {
4034
4796
  patches
4035
4797
  };
4036
4798
  }
4037
4799
 
4038
- // #region createPatchChain
4039
- createPatchChain(unsortedPatchRecord) {
4040
- var _Object$entries$find;
4041
- // TODO: Error handling
4042
- const nextPatch = {};
4043
- Object.keys(unsortedPatchRecord).forEach(patchId => {
4044
- const patch = unsortedPatchRecord[patchId];
4045
- if (patch.parentRef.type === "head") {
4046
- nextPatch["head"] = patchId;
4047
- } else {
4048
- nextPatch[patch.parentRef.patchId] = patchId;
4049
- }
4050
- });
4051
- const sortedPatches = [];
4052
- 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];
4053
- while (!!nextPatchId && nextPatchId in unsortedPatchRecord) {
4054
- const patch = unsortedPatchRecord[nextPatchId];
4055
- delete patch["parentRef"];
4056
- sortedPatches.push({
4057
- ...patch,
4058
- patchId: nextPatchId
4059
- });
4060
- nextPatchId = nextPatch[nextPatchId];
4061
- }
4062
- return sortedPatches;
4063
- }
4064
-
4065
4800
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4066
4801
  parseJsonFile(filePath, parser) {
4067
4802
  if (!this.host.fileExists(filePath)) {
@@ -4136,53 +4871,60 @@ class ValOpsFS extends ValOps {
4136
4871
  };
4137
4872
  }
4138
4873
  }
4139
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
4140
- const patchDir = this.getParentPatchIdFromParentRef(parentRef);
4141
- try {
4142
- const baseSha = await this.getBaseSha();
4143
- const data = {
4144
- patch,
4145
- patchId,
4146
- parentRef,
4147
- path,
4148
- authorId,
4149
- sessionId,
4150
- baseSha,
4151
- coreVersion: Internal.VERSION.core,
4152
- createdAt: new Date().toISOString()
4153
- };
4154
- const headParentPatchId = "head";
4155
- if (patchDir !== headParentPatchId && !this.host.fileExists(this.getPatchFilePath(headParentPatchId))) {
4156
- console.error("Val: out-of-band patch detected.", this.getPatchFilePath(headParentPatchId));
4157
- return result.err({
4158
- errorType: "patch-head-conflict"
4159
- });
4160
- }
4161
- const writeRes = this.host.tryWriteUf8File(this.getPatchFilePath(patchDir), JSON.stringify(data));
4162
- if (writeRes.type === "error") {
4874
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
4875
+ const patchesDir = this.getPatchesDir();
4876
+ const record = {
4877
+ patch,
4878
+ patchId,
4879
+ path,
4880
+ authorId,
4881
+ sessionId,
4882
+ baseSha: await this.getBaseSha(),
4883
+ coreVersion: Internal.VERSION.core,
4884
+ createdAt: new Date().toISOString()
4885
+ };
4886
+ /*
4887
+ * `parentRef` is ignored here, and that is the point of the log.
4888
+ *
4889
+ * It used to be the directory name, which is how a client working from a
4890
+ * stale view could write a patch whose parent had never landed and strand
4891
+ * every patch behind it. With the order held in one append-only list the
4892
+ * server decides where a patch goes: it goes last. There is no parent to
4893
+ * name, nothing to point at, and so nothing that can point at nothing.
4894
+ *
4895
+ * The parameter stays because `ValOpsHttp` genuinely needs it — the content
4896
+ * api runs its own chain and is told the parent — but in fs mode a stale
4897
+ * parent is not an error to refuse, it is a fact with no consequences.
4898
+ *
4899
+ * What that gives up is early detection of a patch computed against a
4900
+ * different state: two tabs editing the same array can produce an op that no
4901
+ * longer fits. That is caught where it actually shows — applying the patch —
4902
+ * and handled by dropping it and saying so, rather than by refusing writes
4903
+ * up front and making every interleaved edit a round trip.
4904
+ */
4905
+ const locked = await withPatchLock(this.getPatchLockFile(), {
4906
+ ttlMs: 10_000,
4907
+ op: "PUT /patches"
4908
+ }, () => {
4909
+ try {
4910
+ appendPatch(patchesDir, record);
4911
+ } catch (err) {
4163
4912
  return result.err({
4164
4913
  errorType: "other",
4165
- error: writeRes.error,
4166
- message: "Failed to write patch file"
4914
+ message: `Failed to write the patch: ${err instanceof Error ? err.message : "unknown error"}`
4167
4915
  });
4168
4916
  }
4169
4917
  return result.ok({
4170
4918
  patchId
4171
4919
  });
4172
- } catch (err) {
4173
- if (err instanceof Error) {
4174
- return result.err({
4175
- errorType: "other",
4176
- error: err,
4177
- message: err.message
4178
- });
4179
- }
4920
+ });
4921
+ if (locked.status !== "ok") {
4180
4922
  return result.err({
4181
4923
  errorType: "other",
4182
- error: err,
4183
- message: "Unknown error"
4924
+ message: locked.message
4184
4925
  });
4185
4926
  }
4927
+ return locked.value;
4186
4928
  }
4187
4929
  async getSourceFile(path) {
4188
4930
  const filePath = path__default.join(this.rootDir, path);
@@ -4219,10 +4961,14 @@ class ValOpsFS extends ValOps {
4219
4961
  };
4220
4962
  }
4221
4963
  }
4222
- async saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, data, _type, metadata) {
4223
- const patchDir = this.getParentPatchIdFromParentRef(parentRef);
4224
- const patchFilePath = this.getBinaryFilePath(filePath, patchDir);
4225
- const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDir);
4964
+ async saveBase64EncodedBinaryFileFromPatch(filePath, _parentRef, patchId, data, _type, metadata) {
4965
+ // Keyed by the patch's own id, so the parent is not needed and is not asked
4966
+ // for. Uploads arrive before the patch record does, which is fine: the
4967
+ // directory sits there unreferenced until the log line that names it lands,
4968
+ // and repair sweeps it up if that never happens.
4969
+ const patchesDir = this.getPatchesDir();
4970
+ const patchFilePath = patchBinaryFile(patchesDir, patchId, filePath);
4971
+ const metadataFilePath = patchBinaryFileMetadata(patchesDir, patchId, filePath);
4226
4972
  try {
4227
4973
  if (data === null) {
4228
4974
  this.host.deleteFile(patchFilePath);
@@ -4262,15 +5008,7 @@ class ValOpsFS extends ValOps {
4262
5008
  }
4263
5009
  }
4264
5010
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId) {
4265
- const patchDirRes = await this.getParentPatchIdFromPatchId(patchId);
4266
- if (result.isErr(patchDirRes)) {
4267
- return {
4268
- errors: [{
4269
- message: "Failed to get patch dir from patch id"
4270
- }]
4271
- };
4272
- }
4273
- const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDirRes.value);
5011
+ const metadataFilePath = patchBinaryFileMetadata(this.getPatchesDir(), patchId, filePath);
4274
5012
  if (!this.host.fileExists(metadataFilePath)) {
4275
5013
  return {
4276
5014
  errors: [{
@@ -4306,95 +5044,132 @@ class ValOpsFS extends ValOps {
4306
5044
  };
4307
5045
  }
4308
5046
  async getBase64EncodedBinaryFileFromPatch(filePath, patchId) {
4309
- const patchDirRes = await this.getParentPatchIdFromPatchId(patchId);
4310
- if (!result.isOk(patchDirRes)) {
4311
- return null;
4312
- }
4313
- const absPath = this.getBinaryFilePath(filePath, patchDirRes.value);
5047
+ // Straight from the id. This used to read and parse every patch on disk to
5048
+ // work out which directory the file was under, on every single image request.
5049
+ const absPath = patchBinaryFile(this.getPatchesDir(), patchId, filePath);
4314
5050
  if (!this.host.fileExists(absPath)) {
4315
5051
  return null;
4316
5052
  }
4317
5053
  return this.host.readBinaryFile(absPath);
4318
5054
  }
4319
5055
  async deletePatches(patchIds) {
4320
- const deleted = [];
4321
- const patchDirMapRes = await this.getParentPatchIdFromPatchIdMap();
4322
- if (result.isErr(patchDirMapRes)) {
5056
+ const patchesDir = this.getPatchesDir();
5057
+ const requested = new Set(patchIds);
5058
+ const locked = await withPatchLock(this.getPatchLockFile(), {
5059
+ ttlMs: 30_000,
5060
+ op: "DELETE /patches"
5061
+ }, () => {
5062
+ const read = readPatchStore(patchesDir);
5063
+ if (read.status !== "ok") {
5064
+ return {
5065
+ status: "error",
5066
+ message: read.message
5067
+ };
5068
+ }
5069
+ /*
5070
+ * Deleting is now dropping lines from a list.
5071
+ *
5072
+ * It used to be a three-step re-link per surviving patch — rewrite a
5073
+ * record, remove the destination directory, rename another one over it —
5074
+ * with the plan computed from a snapshot taken before hundreds of file
5075
+ * reads, every failure swallowed by a `console.error`, and one step that
5076
+ * unconditionally removed whatever occupied the destination. An
5077
+ * interruption anywhere in it stranded every patch after the hole.
5078
+ */
5079
+ writePatchLogFile(patchesLogFile(patchesDir), read.entries.filter(entry => !requested.has(entry.patchId)).map(entry => ({
5080
+ patchId: entry.patchId,
5081
+ createdAt: entry.record.createdAt,
5082
+ path: entry.record.path
5083
+ })));
5084
+ // The log first, then the directories. A crash in between leaves
5085
+ // directories nothing names, which is inert and swept up on the next
5086
+ // read; the other order would leave the log naming patches that are gone.
5087
+ const deleted = [];
5088
+ const errors = {};
5089
+ for (const patchId of patchIds) {
5090
+ try {
5091
+ fs.rmSync(patchDir(patchesDir, patchId), {
5092
+ recursive: true,
5093
+ force: true
5094
+ });
5095
+ deleted.push(patchId);
5096
+ } catch (err) {
5097
+ // Reported. This endpoint used to answer "deleted" unconditionally —
5098
+ // `deleted` and `errors` were never written to at all — so a delete
5099
+ // that failed looked exactly like one that worked.
5100
+ errors[patchId] = {
5101
+ message: `Could not remove the files for ${patchId}: ${err instanceof Error ? err.message : "unknown error"}`
5102
+ };
5103
+ }
5104
+ }
5105
+ return {
5106
+ status: "ok",
5107
+ deleted,
5108
+ errors
5109
+ };
5110
+ });
5111
+ if (locked.status !== "ok") {
4323
5112
  return {
4324
5113
  error: {
4325
- message: "Failed to get patch dir map"
5114
+ message: locked.message
4326
5115
  }
4327
5116
  };
4328
5117
  }
4329
- const currentPatches = this.createPatchChain((await this.fetchPatchesFromFS(false)).patches);
4330
- this.updateOrderedPatches(computeChangedPatchParentRefs(currentPatches, patchIds), patchDirMapRes.value, patchIds);
5118
+ if (locked.value.status === "error") {
5119
+ return {
5120
+ error: {
5121
+ message: locked.value.message
5122
+ }
5123
+ };
5124
+ }
5125
+ const {
5126
+ deleted,
5127
+ errors
5128
+ } = locked.value;
5129
+ if (Object.keys(errors).length > 0) {
5130
+ return {
5131
+ deleted,
5132
+ errors
5133
+ };
5134
+ }
4331
5135
  return {
4332
5136
  deleted
4333
5137
  };
4334
5138
  }
4335
5139
  async deleteAllPatches() {
4336
- const patchesCacheDir = this.getPatchesDir();
4337
- const tmpDir = path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches-deleted-" + crypto.randomUUID());
4338
- try {
4339
- this.host.moveDir(patchesCacheDir, tmpDir);
4340
- this.host.deleteDir(tmpDir);
4341
- return {};
4342
- } catch (err) {
4343
- if (err instanceof Error) {
5140
+ const patchesDir = this.getPatchesDir();
5141
+ const locked = await withPatchLock(this.getPatchLockFile(), {
5142
+ ttlMs: 30_000,
5143
+ op: "delete all patches"
5144
+ }, () => {
5145
+ if (!fs.existsSync(patchesDir)) {
5146
+ return {};
5147
+ }
5148
+ const tmpDir = path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches-deleted-" + crypto.randomUUID());
5149
+ try {
5150
+ // One rename takes the log and the directories together, so the store
5151
+ // is empty and self-consistent from the first instant rather than
5152
+ // part-way through. It is also the cleanup route for a store this
5153
+ // version refuses to read: discarding everything always works.
5154
+ this.host.moveDir(patchesDir, tmpDir);
5155
+ this.host.deleteDir(tmpDir);
5156
+ return {};
5157
+ } catch (err) {
4344
5158
  return {
4345
5159
  error: {
4346
- message: `Got an error while deleting patches: ${err.message}`
5160
+ message: `Got an error while deleting patches: ${err instanceof Error ? err.message : "unknown error"}`
4347
5161
  }
4348
5162
  };
4349
5163
  }
5164
+ });
5165
+ if (locked.status !== "ok") {
4350
5166
  return {
4351
5167
  error: {
4352
- message: "Got an unexpected error while deleting patches"
5168
+ message: locked.message
4353
5169
  }
4354
5170
  };
4355
5171
  }
4356
- }
4357
- updateOrderedPatches(updates, patchDirMap, deletePatchIds) {
4358
- for (const patchId of deletePatchIds) {
4359
- const patchDir = patchDirMap[patchId];
4360
- if (!patchDir) {
4361
- console.error("Could not find patch dir for patch id scheduled for deletion: ", patchId);
4362
- continue;
4363
- }
4364
- try {
4365
- this.host.deleteDir(this.getFullPatchDir(patchDir));
4366
- } catch (err) {
4367
- console.error("Failed to delete patch dir", err);
4368
- }
4369
- }
4370
- for (const [patchIdS, parentRef] of Object.entries(updates.changedPatches)) {
4371
- const prevParentPatchId = patchDirMap[patchIdS];
4372
- if (!prevParentPatchId) {
4373
- console.error("Could not find previous parent patch id for deleted patch id: ", patchIdS);
4374
- continue;
4375
- }
4376
- const newParentPatchId = parentRef.type === "head" ? "head" : parentRef.patchId;
4377
- const currentPatchDataRes = this.parseJsonFile(this.getPatchFilePath(prevParentPatchId), FSPatch);
4378
- if (currentPatchDataRes.error) {
4379
- console.error("Failed to parse patch file while fixing patch chain after deleted patch", {
4380
- updates
4381
- }, currentPatchDataRes.error);
4382
- continue;
4383
- }
4384
- const newPatchData = currentPatchDataRes.data;
4385
- newPatchData.parentRef = parentRef;
4386
- try {
4387
- this.host.writeUf8File(this.getPatchFilePath(prevParentPatchId), JSON.stringify(newPatchData));
4388
- if (this.host.directoryExists(this.getFullPatchDir(newParentPatchId))) {
4389
- this.host.deleteDir(this.getFullPatchDir(newParentPatchId));
4390
- }
4391
- this.host.moveDir(this.getFullPatchDir(prevParentPatchId), this.getFullPatchDir(newParentPatchId));
4392
- } catch (err) {
4393
- console.error("Failed fix patch chain after deleted patch", {
4394
- updates
4395
- }, err);
4396
- }
4397
- }
5172
+ return locked.value;
4398
5173
  }
4399
5174
  async saveOrUploadFiles(preparedCommit, mode, auth) {
4400
5175
  const updatedFiles = [];
@@ -4457,30 +5232,13 @@ class ValOpsFS extends ValOps {
4457
5232
  uploadedRemoteRefs.push(ref);
4458
5233
  }
4459
5234
  }
4460
- const patchIdToPatchDirMapRes = await this.getParentPatchIdFromPatchIdMap();
4461
- if (result.isErr(patchIdToPatchDirMapRes)) {
4462
- return {
4463
- updatedFiles,
4464
- uploadedRemoteRefs,
4465
- errors
4466
- };
4467
- }
4468
- const patchIdToPatchDirMap = patchIdToPatchDirMapRes.value;
4469
5235
  for (const [ref, {
4470
5236
  patchId
4471
5237
  }] of localFileDescriptors) {
4472
5238
  const filePath = ref;
4473
5239
  const absPath = path__default.join(this.rootDir, ...filePath.split("/"));
4474
5240
  try {
4475
- const patchDir = patchIdToPatchDirMap[patchId];
4476
- if (!patchDir) {
4477
- errors[absPath] = {
4478
- message: "Failed to find PatchDir for PatchId " + patchId,
4479
- filePath: filePath
4480
- };
4481
- continue;
4482
- }
4483
- this.host.copyFile(this.getBinaryFilePath(filePath, patchDir), absPath);
5241
+ this.host.copyFile(patchBinaryFile(this.getPatchesDir(), patchId, filePath), absPath);
4484
5242
  updatedFiles.push(absPath);
4485
5243
  } catch (err) {
4486
5244
  errors[absPath] = {
@@ -4510,14 +5268,7 @@ class ValOpsFS extends ValOps {
4510
5268
  baseSha: await this.getBaseSha(),
4511
5269
  timestamp: new Date().toISOString()
4512
5270
  };
4513
- const patchDir = patchIdToPatchDirMap[patchId];
4514
- if (!patchDir) {
4515
- errors[`patchId:${patchId}`] = {
4516
- message: "Failed to find PatchDir for PatchId " + patchId
4517
- };
4518
- continue;
4519
- }
4520
- const absPath = this.getPatchBaseFile(patchDir);
5271
+ const absPath = patchBaseFile(this.getPatchesDir(), patchId);
4521
5272
  try {
4522
5273
  this.host.writeUf8File(absPath, JSON.stringify(appliedAt));
4523
5274
  } catch (err) {
@@ -4562,47 +5313,18 @@ class ValOpsFS extends ValOps {
4562
5313
  }
4563
5314
  return createMetadataFromBuffer(type, mimeType, buffer);
4564
5315
  }
4565
- async getParentPatchIdFromPatchId(patchId) {
4566
- // This is not great. If needed we should find a better way
4567
- const patches = await this.readPatches();
4568
- if (patches.errors || patches.error) {
4569
- console.error("Failed to read patches", JSON.stringify(patches));
4570
- return result.err("failed-to-read-patches");
4571
- }
4572
- const patch = patches.patches[patchId];
4573
- if (!patch) {
4574
- console.error("Could not find patch with patchId: ", patchId);
4575
- return result.err("patch-not-found");
4576
- }
4577
- return result.ok(this.getParentPatchIdFromParentRef(patch.parentRef));
4578
- }
4579
- async getParentPatchIdFromPatchIdMap() {
4580
- const patches = await this.readPatches();
4581
- if (patches.errors || patches.error) {
4582
- console.error("Failed to read patches", JSON.stringify(patches));
4583
- return result.err("failed-to-read-patches");
4584
- }
4585
- return result.ok(Object.fromEntries(Object.entries(patches.patches).map(([patchId, value]) => [patchId, this.getParentPatchIdFromParentRef(value.parentRef)])));
4586
- }
4587
5316
 
4588
5317
  // #region fs file path helpers
4589
5318
  getPatchesDir() {
4590
5319
  return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, "patches");
4591
5320
  }
4592
- getFullPatchDir(patchDir) {
4593
- return path__default.join(this.getPatchesDir(), patchDir);
4594
- }
4595
- getBinaryFilePath(filePath, patchDir) {
4596
- return path__default.join(this.getFullPatchDir(patchDir), "files", filePath, path__default.basename(filePath));
4597
- }
4598
- getBinaryFileMetadataPath(filePath, patchDir) {
4599
- return path__default.join(this.getFullPatchDir(patchDir), "files", filePath, "metadata.json");
4600
- }
4601
- getPatchFilePath(patchDir) {
4602
- return path__default.join(this.getFullPatchDir(patchDir), "patch.json");
4603
- }
4604
- getPatchBaseFile(patchDir) {
4605
- return path__default.join(this.getFullPatchDir(patchDir), "base.json");
5321
+
5322
+ /**
5323
+ * Deliberately outside the patches directory: delete-all and reset rename that
5324
+ * whole directory, and a lock that moves away with it is not holding anything.
5325
+ */
5326
+ getPatchLockFile() {
5327
+ return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
4606
5328
  }
4607
5329
  }
4608
5330
  class FSOpsHost {
@@ -4682,27 +5404,11 @@ class FSOpsHost {
4682
5404
  fs.mkdirSync(path__default.dirname(to), {
4683
5405
  recursive: true
4684
5406
  });
4685
- fs.copyFileSync(from, to);
4686
- }
4687
- }
4688
- const FSPatch = z.object({
4689
- path: z.string().refine(p => p.startsWith("/") && p.includes(".val."), "Path is not valid. Must start with '/' and include '.val.'"),
4690
- patch: Patch,
4691
- patchId: z.string(),
4692
- baseSha: z.string(),
4693
- parentRef: ParentRef,
4694
- authorId: z.string().refine(p => true).nullable(),
4695
- createdAt: z.string().datetime(),
4696
- coreVersion: z.string().nullable(),
4697
- // TODO: use this to check if patch is compatible with current core version?
4698
- sessionId: z.string().nullable()
4699
- });
4700
- const FSPatchBase = z.object({
4701
- baseSha: z.string().refine(p => true),
4702
- timestamp: z.string().datetime()
4703
- });
5407
+ fs.copyFileSync(from, to);
5408
+ }
5409
+ }
4704
5410
 
4705
- const textEncoder$1 = new TextEncoder();
5411
+ const textEncoder$2 = new TextEncoder();
4706
5412
  const PatchId = z.string().refine(s => !!s); // TODO: validate
4707
5413
  const CommitSha = z.string().refine(s => !!s); // TODO: validate
4708
5414
  z.string().refine(s => !!s); // TODO: validate
@@ -5393,7 +6099,7 @@ class ValOpsHttp extends ValOps {
5393
6099
  });
5394
6100
  params.set("body_sha",
5395
6101
  // We use this for cache invalidation
5396
- Internal.getSHA256Hash(textEncoder$1.encode(stringifiedFiles)));
6102
+ Internal.getSHA256Hash(textEncoder$2.encode(stringifiedFiles)));
5397
6103
  return fetch(`${this.contentUrl}/v1/${this.project}/files?${params}`, {
5398
6104
  method: "PUT",
5399
6105
  // Yes, PUT is weird. Weirder to have a body in a GET request.
@@ -6726,7 +7432,7 @@ const ValServer = (valModules, options, callbacks) => {
6726
7432
  status: 409,
6727
7433
  json: {
6728
7434
  type: "patch-head-conflict",
6729
- message: "Patch id conflict"
7435
+ message: "The change was written against a different starting point than the server has."
6730
7436
  }
6731
7437
  };
6732
7438
  } else {
@@ -9121,15 +9827,21 @@ _input // TODO: use buffer to determine mimetype
9121
9827
  };
9122
9828
  }
9123
9829
 
9830
+ /**
9831
+ * The media path a validation error is about, if it is about media at all.
9832
+ *
9833
+ * There is no schema in hand here — a `ValidationError` carries only the value
9834
+ * it flagged — so the shape is all there is to go on.
9835
+ */
9124
9836
  function getValidationErrorFileRef(validationError) {
9125
- const maybeRef = validationError.value && typeof validationError.value === "object" && FILE_REF_PROP in validationError.value && typeof validationError.value[FILE_REF_PROP] === "string" ? validationError.value[FILE_REF_PROP] : undefined;
9126
- if (!maybeRef) {
9837
+ const maybePath = validationError.value && typeof validationError.value === "object" && "path" in validationError.value && typeof validationError.value.path === "string" ? validationError.value.path : undefined;
9838
+ if (!maybePath) {
9127
9839
  return null;
9128
9840
  }
9129
- return maybeRef;
9841
+ return maybePath;
9130
9842
  }
9131
9843
 
9132
- const textEncoder = new TextEncoder();
9844
+ const textEncoder$1 = new TextEncoder();
9133
9845
  async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
9134
9846
  const remoteRefDataRes = Internal.remote.splitRemoteRef(ref);
9135
9847
  if (remoteRefDataRes.status === "error") {
@@ -9159,10 +9871,10 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
9159
9871
  const fileExt = getFileExt(relativeFilePath);
9160
9872
  const currentValidationHash = Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata,
9161
9873
  // TODO: validate
9162
- fileHash, textEncoder);
9874
+ fileHash, textEncoder$1);
9163
9875
  const validationHashFromRemoteRefValues = Internal.remote.getValidationHash(remoteRefDataRes.version, schema, getFileExt(remoteRefDataRes.filePath), metadata,
9164
9876
  // TODO: validate
9165
- fileHash, textEncoder);
9877
+ fileHash, textEncoder$1);
9166
9878
  if (
9167
9879
  // Current validation hash is the same as the remote validation hash
9168
9880
  // We assume the uploaded file exists and is correct
@@ -9208,7 +9920,7 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
9208
9920
  ...metadata,
9209
9921
  ...currentMetadata
9210
9922
  };
9211
- const nextValidationHash = Internal.remote.getValidationHash(coreVersion, schema, fileExt, updatedMetadata, fileBufferRes.fileHash, textEncoder);
9923
+ const nextValidationHash = Internal.remote.getValidationHash(coreVersion, schema, fileExt, updatedMetadata, fileBufferRes.fileHash, textEncoder$1);
9212
9924
  if (!updatedMetadata.mimeType) {
9213
9925
  return {
9214
9926
  status: "error",
@@ -9338,6 +10050,36 @@ function parentPatchPathOfMediaEntry(sourcePath, entryKey) {
9338
10050
  }
9339
10051
 
9340
10052
  // TODO: find a better name? transformFixesToPatch?
10053
+ /**
10054
+ * A whole media value, for the fixes that replace one outright.
10055
+ *
10056
+ * The three remote fixes each wrote this shape out by hand, and one of them was
10057
+ * left behind when the shape changed. One name, so the next change cannot miss a
10058
+ * site.
10059
+ *
10060
+ * `path` last is deliberate: it is the field the fix is about, and a stray
10061
+ * `path` inside the metadata must not win over it.
10062
+ */
10063
+ function mediaValue(path, metadata) {
10064
+ const fields = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
10065
+ return {
10066
+ ...fields,
10067
+ path
10068
+ };
10069
+ }
10070
+
10071
+ /**
10072
+ * The media value a validation error flagged, read as a plain record.
10073
+ *
10074
+ * A `ValidationError` carries only a value, never the schema that rejected it,
10075
+ * so the shape is all there is to go on here.
10076
+ */
10077
+ function mediaValueOf(value) {
10078
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
10079
+ return undefined;
10080
+ }
10081
+ return value;
10082
+ }
9341
10083
  async function createFixPatch(config, apply, sourcePath, validationError, remoteFiles, moduleSource, moduleSchema) {
9342
10084
  const remainingErrors = [];
9343
10085
  const patch = [];
@@ -9351,57 +10093,38 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9351
10093
  fixes: undefined
9352
10094
  });
9353
10095
  } else if (fix === "image:check-metadata") {
9354
- const currentValue = validationError.value;
9355
- const metadataIsCorrect =
9356
- // metadata is a prop that is an object
9357
- typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object" &&
9358
- // width is correct
9359
- "width" in currentValue.metadata && currentValue.metadata.width === imageMetadata.width &&
9360
- // height is correct
9361
- "height" in currentValue.metadata && currentValue.metadata.height === imageMetadata.height &&
9362
- // mimeType is correct
9363
- "mimeType" in currentValue.metadata && currentValue.metadata.mimeType === imageMetadata.mimeType;
9364
- // skips if the metadata is already correct
9365
- if (!metadataIsCorrect) {
10096
+ const currentValue = mediaValueOf(validationError.value);
10097
+ const derived = {
10098
+ width: imageMetadata.width,
10099
+ height: imageMetadata.height,
10100
+ mimeType: imageMetadata.mimeType
10101
+ };
10102
+ // One op per field, not one for the whole object: `alt` and `hotspot`
10103
+ // are authored and sit next to these, so a whole-object write would
10104
+ // throw away what a person typed.
10105
+ const wrong = Object.entries(derived).filter(([field, expected]) => (currentValue === null || currentValue === void 0 ? void 0 : currentValue[field]) !== expected);
10106
+ if (wrong.length > 0) {
9366
10107
  if (apply) {
9367
- patch.push({
9368
- op: "replace",
9369
- path: sourceToPatchPath(sourcePath).concat("metadata"),
9370
- value: {
9371
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9372
- ...currentValue.metadata,
9373
- width: imageMetadata.width,
9374
- height: imageMetadata.height,
9375
- mimeType: imageMetadata.mimeType
9376
- }
9377
- });
9378
- } else {
9379
- if (typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object") {
9380
- if (!("width" in currentValue.metadata) || currentValue.metadata.width !== imageMetadata.width) {
9381
- remainingErrors.push({
9382
- message: "Image metadata width is incorrect! Found: " + ("width" in currentValue.metadata ? currentValue.metadata.width : "<empty>") + ". Expected: " + imageMetadata.width,
9383
- fixes: undefined
9384
- });
9385
- }
9386
- if (!("height" in currentValue.metadata) || currentValue.metadata.height !== imageMetadata.height) {
9387
- remainingErrors.push({
9388
- message: "Image metadata height is incorrect! Found: " + ("height" in currentValue.metadata ? currentValue.metadata.height : "<empty>") + ". Expected: " + imageMetadata.height,
9389
- fixes: undefined
9390
- });
9391
- }
9392
- if (!("mimeType" in currentValue.metadata) || currentValue.metadata.mimeType !== imageMetadata.mimeType) {
9393
- remainingErrors.push({
9394
- message: "Image metadata mimeType is incorrect! Found: " + ("mimeType" in currentValue.metadata ? currentValue.metadata.mimeType : "<empty>") + ". Expected: " + imageMetadata.mimeType,
9395
- fixes: undefined
9396
- });
9397
- }
9398
- } else {
10108
+ for (const [field, expected] of wrong) {
10109
+ patch.push({
10110
+ op: "add",
10111
+ path: sourceToPatchPath(sourcePath).concat(field),
10112
+ value: expected
10113
+ });
10114
+ }
10115
+ } else if (currentValue) {
10116
+ for (const [field, expected] of wrong) {
9399
10117
  remainingErrors.push({
9400
- ...validationError,
9401
- message: "Image metadata is not an object!",
10118
+ message: `Image ${field} is incorrect! Found: ` + (currentValue[field] ?? "<empty>") + ". Expected: " + expected,
9402
10119
  fixes: undefined
9403
10120
  });
9404
10121
  }
10122
+ } else {
10123
+ remainingErrors.push({
10124
+ ...validationError,
10125
+ message: "Image is not an object!",
10126
+ fixes: undefined
10127
+ });
9405
10128
  }
9406
10129
  }
9407
10130
  } else if (fix === "image:add-metadata") {
@@ -9412,14 +10135,19 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9412
10135
  fixes: undefined
9413
10136
  });
9414
10137
  } else {
10138
+ const patchPath = sourceToPatchPath(sourcePath);
9415
10139
  patch.push({
9416
10140
  op: "add",
9417
- path: sourceToPatchPath(sourcePath).concat("metadata"),
9418
- value: {
9419
- width: imageMetadata.width,
9420
- height: imageMetadata.height,
9421
- mimeType: imageMetadata.mimeType
9422
- }
10141
+ path: patchPath.concat("width"),
10142
+ value: imageMetadata.width
10143
+ }, {
10144
+ op: "add",
10145
+ path: patchPath.concat("height"),
10146
+ value: imageMetadata.height
10147
+ }, {
10148
+ op: "add",
10149
+ path: patchPath.concat("mimeType"),
10150
+ value: imageMetadata.mimeType
9423
10151
  });
9424
10152
  }
9425
10153
  }
@@ -9432,53 +10160,32 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9432
10160
  fixes: undefined
9433
10161
  });
9434
10162
  } else if (fix === "file:check-metadata") {
9435
- const currentValue = validationError.value;
9436
- const metadataIsCorrect =
9437
- // metadata is a prop that is an object
9438
- typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object" &&
9439
- // mimeType is correct
9440
- "mimeType" in currentValue.metadata && currentValue.metadata.mimeType === fileMetadata.mimeType;
9441
-
9442
- // skips if the metadata is already correct
9443
- if (!metadataIsCorrect) {
10163
+ const currentValue = mediaValueOf(validationError.value);
10164
+ if ((currentValue === null || currentValue === void 0 ? void 0 : currentValue.mimeType) !== fileMetadata.mimeType) {
9444
10165
  if (apply) {
9445
10166
  patch.push({
9446
- op: "replace",
9447
- path: sourceToPatchPath(sourcePath).concat("metadata"),
9448
- value: {
9449
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9450
- ...currentValue.metadata,
9451
- ...(fileMetadata.mimeType ? {
9452
- mimeType: fileMetadata.mimeType
9453
- } : {})
9454
- }
10167
+ op: "add",
10168
+ path: sourceToPatchPath(sourcePath).concat("mimeType"),
10169
+ value: fileMetadata.mimeType ?? null
10170
+ });
10171
+ } else if (currentValue) {
10172
+ remainingErrors.push({
10173
+ message: "File mimeType is incorrect! Found: " + (currentValue.mimeType ?? "<empty>") + ". Expected: " + fileMetadata.mimeType,
10174
+ fixes: undefined
9455
10175
  });
9456
10176
  } else {
9457
- if (typeof currentValue === "object" && currentValue && "metadata" in currentValue && currentValue.metadata && typeof currentValue.metadata === "object") {
9458
- if (!("mimeType" in currentValue.metadata) || currentValue.metadata.mimeType !== fileMetadata.mimeType) {
9459
- remainingErrors.push({
9460
- message: "File metadata mimeType is incorrect! Found: " + ("mimeType" in currentValue.metadata ? currentValue.metadata.mimeType : "<empty>") + ". Expected: " + fileMetadata.mimeType,
9461
- fixes: undefined
9462
- });
9463
- }
9464
- } else {
9465
- remainingErrors.push({
9466
- ...validationError,
9467
- message: "Image metadata is not an object!",
9468
- fixes: undefined
9469
- });
9470
- }
10177
+ remainingErrors.push({
10178
+ ...validationError,
10179
+ message: "File is not an object!",
10180
+ fixes: undefined
10181
+ });
9471
10182
  }
9472
10183
  }
9473
10184
  } else if (fix === "file:add-metadata") {
9474
10185
  patch.push({
9475
10186
  op: "add",
9476
- path: sourceToPatchPath(sourcePath).concat("metadata"),
9477
- value: {
9478
- ...(fileMetadata.mimeType ? {
9479
- mimeType: fileMetadata.mimeType
9480
- } : {})
9481
- }
10187
+ path: sourceToPatchPath(sourcePath).concat("mimeType"),
10188
+ value: fileMetadata.mimeType ?? null
9482
10189
  });
9483
10190
  }
9484
10191
  } else if (fix === "image:upload-remote" || fix === "file:upload-remote") {
@@ -9506,11 +10213,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9506
10213
  } else {
9507
10214
  patch.push({
9508
10215
  op: "replace",
9509
- value: {
9510
- _type: "remote",
9511
- _ref: remoteFile.ref,
9512
- metadata
9513
- },
10216
+ value: mediaValue(remoteFile.ref, metadata),
9514
10217
  path: sourceToPatchPath(sourcePath)
9515
10218
  });
9516
10219
  }
@@ -9579,7 +10282,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9579
10282
  });
9580
10283
  continue;
9581
10284
  }
9582
- const splitRemoteRefDataRes = Internal.remote.splitRemoteRef(v._ref);
10285
+ const splitRemoteRefDataRes = Internal.remote.splitRemoteRef(v.path);
9583
10286
  if (splitRemoteRefDataRes.status === "error") {
9584
10287
  remainingErrors.push({
9585
10288
  ...validationError,
@@ -9588,7 +10291,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9588
10291
  });
9589
10292
  continue;
9590
10293
  }
9591
- const url = v._ref;
10294
+ const url = v.path;
9592
10295
  const filePath = splitRemoteRefDataRes.filePath;
9593
10296
  if (!filePath) {
9594
10297
  remainingErrors.push({
@@ -9619,20 +10322,10 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9619
10322
  });
9620
10323
  continue;
9621
10324
  }
9622
- const value = {
9623
- [VAL_EXTENSION]: "file",
9624
- [FILE_REF_PROP]: `/${filePath}`,
9625
- ...(fix === "image:download-remote" ? {
9626
- [FILE_REF_SUBTYPE_TAG]: "image"
9627
- } : {})
9628
- };
9629
10325
  patch.push({
9630
10326
  op: "replace",
9631
10327
  path: sourceToPatchPath(sourcePath),
9632
- value: v.metadata ? {
9633
- ...value,
9634
- metadata: v.metadata
9635
- } : value
10328
+ value: mediaValue(`/${filePath}`, v.metadata)
9636
10329
  });
9637
10330
  } else if (fix === "images:check-all-files" || fix === "files:check-all-files") {
9638
10331
  if (!moduleSource || typeof moduleSource !== "object") {
@@ -9755,7 +10448,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9755
10448
  schema: schemaAtPath
9756
10449
  } = Internal.resolvePath(modulePath, moduleSource, moduleSchema);
9757
10450
  if (schemaAtPath.type === "image" || schemaAtPath.type === "file") {
9758
- const res = await checkRemoteRef(config.remoteHost, v._ref, config.projectRoot, schemaAtPath, v.metadata);
10451
+ const res = await checkRemoteRef(config.remoteHost, v.path, config.projectRoot, schemaAtPath, v.metadata);
9759
10452
  if (res.status === "success") ; else if (res.status === "error") {
9760
10453
  remainingErrors.push({
9761
10454
  ...validationError,
@@ -9767,11 +10460,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9767
10460
  patch.push({
9768
10461
  op: "replace",
9769
10462
  path: sourceToPatchPath(sourcePath),
9770
- value: {
9771
- _type: "remote",
9772
- _ref: res.ref,
9773
- metadata: res.metadata
9774
- }
10463
+ value: mediaValue(res.ref, res.metadata)
9775
10464
  });
9776
10465
  } else {
9777
10466
  remainingErrors.push({
@@ -9797,80 +10486,695 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
9797
10486
  }
9798
10487
  }
9799
10488
  }
9800
- if (!validationError.fixes || validationError.fixes.length === 0) {
9801
- remainingErrors.push(validationError);
10489
+ if (!validationError.fixes || validationError.fixes.length === 0) {
10490
+ remainingErrors.push(validationError);
10491
+ }
10492
+ return {
10493
+ patch,
10494
+ remainingErrors
10495
+ };
10496
+ }
10497
+ function getRemoteValueFromValidationError(v) {
10498
+ if (v.value && typeof v.value !== "object") {
10499
+ return {
10500
+ success: false,
10501
+ message: "Unexpected error while checking remote (not an object)"
10502
+ };
10503
+ }
10504
+ if (!v.value) {
10505
+ return {
10506
+ success: false,
10507
+ message: "Unexpected error while checking remote (no value)"
10508
+ };
10509
+ }
10510
+ if (typeof v.value !== "object" || v.value === null || !("path" in v.value)) {
10511
+ return {
10512
+ success: false,
10513
+ message: "Unexpected error while checking remote (no path in value)"
10514
+ };
10515
+ }
10516
+ if (typeof v.value.path !== "string") {
10517
+ return {
10518
+ success: false,
10519
+ message: "Unexpected error while checking remote (path is not a string)"
10520
+ };
10521
+ }
10522
+ // Everything but the path is what Val read from the bytes.
10523
+ const {
10524
+ path,
10525
+ ...metadata
10526
+ } = v.value;
10527
+ return {
10528
+ success: true,
10529
+ path,
10530
+ metadata
10531
+ };
10532
+ }
10533
+ async function getImageMetadata(projectRoot, validationError) {
10534
+ const fileRef = getValidationErrorFileRef(validationError);
10535
+ if (!fileRef) {
10536
+ // TODO:
10537
+ throw Error("Cannot fix image without a file reference");
10538
+ }
10539
+ const filename = path__default.join(projectRoot, fileRef);
10540
+ const buffer = fs.readFileSync(filename);
10541
+ return extractImageMetadata(filename, buffer);
10542
+ }
10543
+ async function getFileMetadata(projectRoot, validationError) {
10544
+ const fileRef = getValidationErrorFileRef(validationError);
10545
+ if (!fileRef) {
10546
+ // TODO:
10547
+ throw Error("Cannot fix file without a file reference");
10548
+ }
10549
+ const filename = path__default.join(projectRoot, fileRef);
10550
+ fs.readFileSync(filename);
10551
+ return extractFileMetadata(fileRef);
10552
+ }
10553
+
10554
+ /**
10555
+ * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
10556
+ * its own `*.val.json`, replacing the inline value with
10557
+ * `c.json(() => import("./<key>.val.json"))`.
10558
+ *
10559
+ * This is the fix for the `jsonValues:extract-entry` validation error. It is not
10560
+ * expressible as a patch (a patch edits one `.val.ts` and cannot create the
10561
+ * backing JSON file), so it writes both files directly — JSON first, so a
10562
+ * failure part-way through never leaves the module pointing at a file that does
10563
+ * not exist.
10564
+ *
10565
+ * Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
10566
+ * up in the module's root record/router object literal.
10567
+ */
10568
+ function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
10569
+ const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
10570
+ const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
10571
+ if (!sourceFile) {
10572
+ throw Error(`Source file ${valTsPath} not found`);
10573
+ }
10574
+ const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
10575
+ if (result.isErr(pathsRes)) {
10576
+ throw Error(formatPatchSourceError(pathsRes.error));
10577
+ }
10578
+ const {
10579
+ jsonPath,
10580
+ importPath
10581
+ } = pathsRes.value;
10582
+ const absoluteJsonPath = path__default.join(rootDir, jsonPath);
10583
+ if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
10584
+ throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
10585
+ }
10586
+
10587
+ // Remove the inline property, then add the `c.json(...)` reference back. The
10588
+ // entry moves to the end of the record: entry ORDER in a jsonValues record is
10589
+ // not meaningful (the Studio and the runtime key entries by name), and
10590
+ // insert-in-place would mean reimplementing insertValJsonEntry.
10591
+ const removed = removeValJsonEntry(sourceFile, [], entryKey);
10592
+ if (result.isErr(removed)) {
10593
+ throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
10594
+ }
10595
+ const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
10596
+ if (result.isErr(inserted)) {
10597
+ throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
10598
+ }
10599
+ sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
10600
+ sourceFileHandler.writeSourceFile(inserted.value);
10601
+ }
10602
+ function formatOpsError(error, sourceFile) {
10603
+ if (error instanceof PatchError) {
10604
+ return error.message;
10605
+ }
10606
+ return flatMapErrors(error, e => formatSyntaxError(e, sourceFile)).join("\n");
10607
+ }
10608
+
10609
+ /**
10610
+ * The fix handlers behind `val validate --fix`, and the editor quick fixes that
10611
+ * must agree with it.
10612
+ *
10613
+ * ## Why this lives in `@valbuild/server`
10614
+ *
10615
+ * A fix is two layers. `createFixPatch` is the second: given a validation
10616
+ * error it produces a patch. The first is everything that has to happen before a
10617
+ * patch can be produced — read the file, check it is actually on disk, extract
10618
+ * metadata, pick a remote bucket, upload the bytes, download them back. That
10619
+ * layer used to live in `packages/cli/src/runValidation.ts`, which made it
10620
+ * reachable only from the CLI: `@valbuild/cli` exports nothing but `./cli`, and
10621
+ * `@valbuild/language-server` cannot depend on it anyway without creating a
10622
+ * cycle (the CLI depends on the language server).
10623
+ *
10624
+ * The consequence was that an editor could offer the fixes needing no
10625
+ * precondition and had to reimplement or skip the rest. Both happened: the
10626
+ * VS Code extension grew its own remote-upload client, and the language server
10627
+ * declined to offer remote fixes at all.
10628
+ *
10629
+ * So the layer sits here, next to `createFixPatch`, and the CLI and the language
10630
+ * server are both callers. {@link FixHandlerContext} is deliberately made of
10631
+ * things any caller already has — a `Service`, an `IValFSHost`, a project root —
10632
+ * rather than of CLI concepts.
10633
+ *
10634
+ * What stays in the CLI is the driver: the `runValidation` async generator that
10635
+ * walks every module, decides what to report, and renders it to a terminal.
10636
+ */
10637
+ const textEncoder = new TextEncoder();
10638
+
10639
+ // Types for handler system
10640
+
10641
+ // Handler functions
10642
+ async function handleFileMetadata(ctx) {
10643
+ var _fileSource$source;
10644
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
10645
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
10646
+ return {
10647
+ success: false,
10648
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
10649
+ };
10650
+ }
10651
+ const fileSource = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
10652
+
10653
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10654
+ const fileRefProp = (_fileSource$source = fileSource.source) === null || _fileSource$source === void 0 ? void 0 : _fileSource$source.path;
10655
+ if (!fileRefProp) {
10656
+ return {
10657
+ success: false,
10658
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
10659
+ };
10660
+ }
10661
+ const filePath = path__default.join(ctx.projectRoot, fileRefProp);
10662
+ if (!ctx.fs.fileExists(filePath)) {
10663
+ return {
10664
+ success: false,
10665
+ errorMessage: `File ${filePath} does not exist`
10666
+ };
10667
+ }
10668
+ return {
10669
+ success: true,
10670
+ shouldApplyPatch: true
10671
+ };
10672
+ }
10673
+
10674
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
10675
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
10676
+ // how they derive the local file ref, metadata and serialized image/file
10677
+ // schema; everything from auth through upload is identical.
10678
+ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
10679
+ const filePath = path__default.join(ctx.projectRoot, fileRef);
10680
+ if (!ctx.fs.fileExists(filePath)) {
10681
+ return {
10682
+ success: false,
10683
+ errorMessage: `File ${filePath} does not exist`
10684
+ };
10685
+ }
10686
+ const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
10687
+ if (!ctx.fs.fileExists(patFile)) {
10688
+ return {
10689
+ success: false,
10690
+ errorMessage: `File: ${path__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`
10691
+ };
10692
+ }
10693
+ const patFileContent = ctx.fs.readFile(patFile);
10694
+ if (patFileContent === undefined) {
10695
+ return {
10696
+ success: false,
10697
+ errorMessage: `Could not read personal access token file at ${patFile}`
10698
+ };
10699
+ }
10700
+ const parsedPatFile = parsePersonalAccessTokenFile(patFileContent);
10701
+ if (!parsedPatFile.success) {
10702
+ return {
10703
+ success: false,
10704
+ errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`
10705
+ };
10706
+ }
10707
+ const {
10708
+ pat
10709
+ } = parsedPatFile.data;
10710
+ if (ctx.remoteFiles[ctx.sourcePath]) {
10711
+ return {
10712
+ success: true,
10713
+ events: [{
10714
+ type: "remote-already-uploaded",
10715
+ filePath
10716
+ }]
10717
+ };
10718
+ }
10719
+ const projectName = ctx.project;
10720
+ let publicProjectId = ctx.publicProjectId;
10721
+ let remoteFileBuckets = ctx.remoteFileBuckets;
10722
+ let remoteFilesCounter = ctx.remoteFilesCounter;
10723
+ if (!publicProjectId || !remoteFileBuckets) {
10724
+ if (!projectName) {
10725
+ return {
10726
+ success: false,
10727
+ errorMessage: "Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable"
10728
+ };
10729
+ }
10730
+ const settingsRes = await ctx.remote.getSettings(projectName, {
10731
+ pat
10732
+ });
10733
+ if (!settingsRes.success) {
10734
+ return {
10735
+ success: false,
10736
+ errorMessage: `Could not get public project id: ${settingsRes.message}.`
10737
+ };
10738
+ }
10739
+ publicProjectId = settingsRes.data.publicProjectId;
10740
+ remoteFileBuckets = settingsRes.data.remoteFileBuckets.map(b => b.bucket);
10741
+ }
10742
+ if (!publicProjectId) {
10743
+ return {
10744
+ success: false,
10745
+ errorMessage: "Could not get public project id"
10746
+ };
10747
+ }
10748
+ if (!projectName) {
10749
+ return {
10750
+ success: false,
10751
+ errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
10752
+ };
10753
+ }
10754
+ remoteFilesCounter += 1;
10755
+ const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
10756
+ if (!bucket) {
10757
+ return {
10758
+ success: false,
10759
+ errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`
10760
+ };
10761
+ }
10762
+ const fileBuffer = ctx.fs.readBuffer(filePath);
10763
+ if (fileBuffer === undefined) {
10764
+ return {
10765
+ success: false,
10766
+ errorMessage: `Error reading file: ${filePath}`
10767
+ };
10768
+ }
10769
+ const relativeFilePath = path__default.relative(ctx.projectRoot, filePath).split(path__default.sep).join("/");
10770
+ if (!relativeFilePath.startsWith("public/")) {
10771
+ return {
10772
+ success: false,
10773
+ errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
10774
+ };
10775
+ }
10776
+ const fileHash = Internal.remote.getFileHash(fileBuffer);
10777
+ const coreVersion = Internal.VERSION.core || "unknown";
10778
+ const fileExt = getFileExt(filePath);
10779
+ const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
10780
+ publicProjectId,
10781
+ coreVersion,
10782
+ bucket,
10783
+ validationHash: Internal.remote.getValidationHash(coreVersion, schema, fileExt, metadata, fileHash, textEncoder),
10784
+ fileHash,
10785
+ filePath: relativeFilePath
10786
+ });
10787
+ const remoteFileUpload = await ctx.remote.uploadFile(projectName, bucket, fileHash, fileExt, fileBuffer, {
10788
+ pat
10789
+ });
10790
+ if (!remoteFileUpload.success) {
10791
+ return {
10792
+ success: false,
10793
+ errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`
10794
+ };
9802
10795
  }
10796
+ ctx.remoteFiles[ctx.sourcePath] = {
10797
+ ref,
10798
+ metadata
10799
+ };
9803
10800
  return {
9804
- patch,
9805
- remainingErrors
10801
+ success: true,
10802
+ shouldApplyPatch: true,
10803
+ publicProjectId,
10804
+ remoteFileBuckets,
10805
+ remoteFilesCounter,
10806
+ events: [{
10807
+ type: "remote-uploading",
10808
+ ref
10809
+ }, {
10810
+ type: "remote-uploaded",
10811
+ ref
10812
+ }]
9806
10813
  };
9807
10814
  }
9808
- function getRemoteValueFromValidationError(v) {
9809
- if (v.value && typeof v.value !== "object") {
10815
+ async function handleRemoteFileUpload(ctx) {
10816
+ var _resolvedRemoteFileAt;
10817
+ if (!ctx.fix) {
9810
10818
  return {
9811
10819
  success: false,
9812
- message: "Unexpected error while checking remote (not an object)"
10820
+ // No sourcePath in the message: the reported location already points at it.
10821
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
9813
10822
  };
9814
10823
  }
9815
- if (!v.value) {
10824
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
10825
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
9816
10826
  return {
9817
10827
  success: false,
9818
- message: "Unexpected error while checking remote (no value)"
10828
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
9819
10829
  };
9820
10830
  }
9821
- if (typeof v.value !== "object" || v.value === null || !(FILE_REF_PROP in v.value)) {
10831
+ const resolvedRemoteFileAtSourcePath = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
10832
+
10833
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10834
+ const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt.path;
10835
+ if (!fileRefProp) {
9822
10836
  return {
9823
10837
  success: false,
9824
- message: "Unexpected error while checking remote (no _ref in value)"
10838
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
9825
10839
  };
9826
10840
  }
9827
- if (typeof v.value._ref !== "string") {
10841
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
10842
+ if (!resolveRemoteFileSchema) {
9828
10843
  return {
9829
10844
  success: false,
9830
- message: "Unexpected error while checking remote (_ref is not a string)"
10845
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
9831
10846
  };
9832
10847
  }
9833
- let metadata;
9834
- if ("metadata" in v.value && typeof v.value.metadata === "object") {
9835
- if (v.value.metadata === null) {
9836
- return {
9837
- success: false,
9838
- message: "Unexpected error while checking remote (metadata is null)"
9839
- };
10848
+ if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
10849
+ return {
10850
+ success: false,
10851
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
10852
+ };
10853
+ }
10854
+
10855
+ // The metadata is everything the media source carries besides its path: the
10856
+ // schema above already established that this IS media.
10857
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
10858
+ const fileSourceMetadata = actualRemoteFileSource && typeof actualRemoteFileSource === "object" && "path" in actualRemoteFileSource ? (() => {
10859
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10860
+ const {
10861
+ path: _path,
10862
+ ...metadata
10863
+ } = actualRemoteFileSource;
10864
+ return metadata;
10865
+ })() : undefined;
10866
+ return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
10867
+ }
10868
+
10869
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
10870
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
10871
+ // path and the value is bare metadata (no FileSource), so we derive the file
10872
+ // ref from the key and synthesize the image/file schema from the record's
10873
+ // media options.
10874
+ async function handleRemoteGalleryFileUpload(ctx) {
10875
+ var _ctx$validationError$;
10876
+ if (!ctx.fix) {
10877
+ return {
10878
+ success: false,
10879
+ // No sourcePath in the message: the reported location already points at it.
10880
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
10881
+ };
10882
+ }
10883
+ const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
10884
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
10885
+
10886
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
10887
+ // that key as the error value.
10888
+ const fileRef = ctx.validationError.value;
10889
+ if (typeof fileRef !== "string") {
10890
+ return {
10891
+ success: false,
10892
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
10893
+ };
10894
+ }
10895
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
10896
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
10897
+ return {
10898
+ success: false,
10899
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
10900
+ };
10901
+ }
10902
+ const resolved = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
10903
+ const entrySource = resolved.source;
10904
+ const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
10905
+
10906
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
10907
+ // synthesize the serialized image/file schema (matching how single fields
10908
+ // serialize) for the remote ref's validation hash.
10909
+ //
10910
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
10911
+ // from the entry's PARENT path - not from the module root, which is only the
10912
+ // record when the gallery is the whole module. A nested gallery
10913
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
10914
+ // with no options and bake a validation hash into the remote ref that can
10915
+ // never validate, so a mismatch fails fast instead of uploading.
10916
+ const [, parentModulePath] = Internal.splitModuleFilePathAndModulePath(Internal.parentOfSourcePath(ctx.sourcePath));
10917
+ const recordSchema = Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
10918
+ if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
10919
+ return {
10920
+ success: false,
10921
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
10922
+ };
10923
+ }
10924
+ const {
10925
+ accept,
10926
+ directory
10927
+ } = recordSchema;
10928
+ const schema = mediaType === "image" ? {
10929
+ type: "image",
10930
+ opt: false,
10931
+ options: {
10932
+ ...(accept ? {
10933
+ accept
10934
+ } : {}),
10935
+ ...(directory ? {
10936
+ directory
10937
+ } : {})
10938
+ }
10939
+ } : {
10940
+ type: "file",
10941
+ opt: false,
10942
+ options: {
10943
+ ...(accept ? {
10944
+ accept
10945
+ } : {})
9840
10946
  }
9841
- if (Array.isArray(v.value.metadata)) {
10947
+ };
10948
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
10949
+ }
10950
+ async function handleRemoteFileDownload(ctx) {
10951
+ if (ctx.fix) {
10952
+ return {
10953
+ success: true,
10954
+ shouldApplyPatch: true,
10955
+ events: [{
10956
+ type: "remote-downloading",
10957
+ sourcePath: ctx.sourcePath
10958
+ }]
10959
+ };
10960
+ } else {
10961
+ return {
10962
+ success: false,
10963
+ errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`
10964
+ };
10965
+ }
10966
+ }
10967
+ async function handleRemoteFileCheck() {
10968
+ // Skip - no action needed
10969
+ return {
10970
+ success: true,
10971
+ shouldApplyPatch: true
10972
+ };
10973
+ }
10974
+ async function handleUniqueFolderCheck(ctx) {
10975
+ const value = ctx.validationError.value;
10976
+ if (!value || typeof value.directory !== "string") {
10977
+ return {
10978
+ success: false,
10979
+ errorMessage: `Unexpected value in unique folder check for ${ctx.sourcePath}`
10980
+ };
10981
+ }
10982
+ const {
10983
+ directory
10984
+ } = value;
10985
+ const conflicts = [];
10986
+ for (const file of ctx.valFiles) {
10987
+ const otherModuleFilePath = `/${file}`;
10988
+ if (otherModuleFilePath === ctx.moduleFilePath) continue;
10989
+ const otherModule = await ctx.service.get(otherModuleFilePath, "", {
10990
+ validate: false
10991
+ });
10992
+ const schema = otherModule.schema;
10993
+ if ((schema === null || schema === void 0 ? void 0 : schema.type) === "record" && schema.directory === directory && schema.mediaType) {
10994
+ conflicts.push(otherModuleFilePath);
10995
+ }
10996
+ }
10997
+ if (conflicts.length > 0) {
10998
+ return {
10999
+ success: false,
11000
+ errorMessage: `Gallery directory '${directory}' in ${ctx.moduleFilePath} is also used by: ${conflicts.join(", ")}. Each gallery must use a unique directory.`
11001
+ };
11002
+ }
11003
+ return {
11004
+ success: true
11005
+ };
11006
+ }
11007
+
11008
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
11009
+ // entries by a remote URL while keeping the file on disk; everything else is
11010
+ // already a local path and is returned unchanged.
11011
+ function remoteKeyToLocalPath(key) {
11012
+ const remoteRefRes = Internal.remote.splitRemoteRef(key);
11013
+ if (remoteRefRes.status === "success") {
11014
+ return `/${remoteRefRes.filePath}`;
11015
+ }
11016
+ return key;
11017
+ }
11018
+ async function handleCheckAllFiles(ctx) {
11019
+ const value = ctx.validationError.value;
11020
+ if (!value || typeof value.directory !== "string") {
11021
+ return {
11022
+ success: false,
11023
+ errorMessage: `Unexpected value in check-all-files for ${ctx.sourcePath}`
11024
+ };
11025
+ }
11026
+ const {
11027
+ directory
11028
+ } = value;
11029
+ const source = ctx.valModule.source;
11030
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
11031
+ return {
11032
+ success: false,
11033
+ errorMessage: `Could not get source for ${ctx.sourcePath}`
11034
+ };
11035
+ }
11036
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
11037
+ // entries by a remote URL, but the file is kept on disk at its local path, so
11038
+ // normalize remote-URL keys back to that local path for the on-disk checks.
11039
+ const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
11040
+
11041
+ // Check that all tracked files exist on disk
11042
+ const missingTrackedFiles = Array.from(trackedFiles).filter(f => {
11043
+ return !ctx.fs.fileExists(path__default.join(ctx.projectRoot, f));
11044
+ });
11045
+ if (missingTrackedFiles.length > 0) {
11046
+ if (!ctx.fix) {
9842
11047
  return {
9843
11048
  success: false,
9844
- message: "Unexpected error while checking remote (metadata is an array)"
11049
+ 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.`
9845
11050
  };
9846
11051
  }
9847
- metadata = v.value.metadata;
11052
+ // fix: true — let createFixPatch remove the missing entries
11053
+ return {
11054
+ success: true,
11055
+ shouldApplyPatch: true
11056
+ };
11057
+ }
11058
+ const dirPath = path__default.join(ctx.projectRoot, directory);
11059
+ const filesInDir = [];
11060
+ try {
11061
+ const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, ["**/*"]);
11062
+ for (const entry of entries) {
11063
+ const relPath = "/" + path__default.relative(ctx.projectRoot, entry).split(path__default.sep).join("/");
11064
+ filesInDir.push(relPath);
11065
+ }
11066
+ } catch {
11067
+ // directory doesn't exist — no untracked files possible
11068
+ }
11069
+ const untrackedFiles = filesInDir.filter(f => !trackedFiles.has(f));
11070
+ if (untrackedFiles.length > 0) {
11071
+ return {
11072
+ success: false,
11073
+ errorMessage: `Gallery in ${ctx.moduleFilePath} has files not tracked: ${untrackedFiles.join(", ")}. Add these files to the gallery or remove them from the directory.`
11074
+ };
9848
11075
  }
11076
+
11077
+ // All files accounted for — trigger metadata verification via createFixPatch
9849
11078
  return {
9850
11079
  success: true,
9851
- _ref: v.value._ref,
9852
- metadata
11080
+ shouldApplyPatch: true
9853
11081
  };
9854
11082
  }
9855
- async function getImageMetadata(projectRoot, validationError) {
9856
- const fileRef = getValidationErrorFileRef(validationError);
9857
- if (!fileRef) {
9858
- // TODO:
9859
- throw Error("Cannot fix image without a file reference");
11083
+ async function handleJsonValuesExtractEntry(ctx) {
11084
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
11085
+ const parts = Internal.splitModulePath(modulePath);
11086
+ // Root-only by contract (see findNestedJsonValuesRecords): the entry is a
11087
+ // direct child of the module's root record/router, so the path is one segment.
11088
+ if (parts.length !== 1) {
11089
+ return {
11090
+ success: false,
11091
+ errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`
11092
+ };
9860
11093
  }
9861
- const filename = path__default.join(projectRoot, fileRef);
9862
- const buffer = fs.readFileSync(filename);
9863
- return extractImageMetadata(filename, buffer);
9864
- }
9865
- async function getFileMetadata(projectRoot, validationError) {
9866
- const fileRef = getValidationErrorFileRef(validationError);
9867
- if (!fileRef) {
9868
- // TODO:
9869
- throw Error("Cannot fix file without a file reference");
11094
+ const entryKey = parts[0];
11095
+ const source = ctx.valModule.source;
11096
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
11097
+ return {
11098
+ success: false,
11099
+ errorMessage: `Could not get source for ${ctx.moduleFilePath}`
11100
+ };
9870
11101
  }
9871
- const filename = path__default.join(projectRoot, fileRef);
9872
- fs.readFileSync(filename);
9873
- return extractFileMetadata(fileRef);
11102
+ const content = source[entryKey];
11103
+ if (content === undefined) {
11104
+ return {
11105
+ success: false,
11106
+ errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`
11107
+ };
11108
+ }
11109
+ if (!ctx.fix) {
11110
+ return {
11111
+ success: true,
11112
+ fixableErrorMessage: ctx.validationError.message
11113
+ };
11114
+ }
11115
+ try {
11116
+ extractJsonValuesEntry(ctx.moduleFilePath, ctx.projectRoot, entryKey, content, ctx.service.sourceFileHandler);
11117
+ } catch (err) {
11118
+ return {
11119
+ success: false,
11120
+ errorMessage: err instanceof Error ? err.message : String(err)
11121
+ };
11122
+ }
11123
+ return {
11124
+ success: true,
11125
+ appliedFix: true
11126
+ };
11127
+ }
11128
+
11129
+ // Fix handler registry. `keyof:check-keys` and `router:check-route` are
11130
+ // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
11131
+ // this registry, so they're excluded from the key set.
11132
+ const currentFixHandlers = {
11133
+ "image:check-metadata": handleFileMetadata,
11134
+ "image:add-metadata": handleFileMetadata,
11135
+ "file:check-metadata": handleFileMetadata,
11136
+ "file:add-metadata": handleFileMetadata,
11137
+ "image:upload-remote": handleRemoteFileUpload,
11138
+ "file:upload-remote": handleRemoteFileUpload,
11139
+ "images:upload-remote": handleRemoteGalleryFileUpload,
11140
+ "files:upload-remote": handleRemoteGalleryFileUpload,
11141
+ "image:download-remote": handleRemoteFileDownload,
11142
+ "file:download-remote": handleRemoteFileDownload,
11143
+ "image:check-remote": handleRemoteFileCheck,
11144
+ "images:check-remote": handleRemoteFileCheck,
11145
+ "file:check-remote": handleRemoteFileCheck,
11146
+ "files:check-remote": handleRemoteFileCheck,
11147
+ "images:check-unique-folder": handleUniqueFolderCheck,
11148
+ "files:check-unique-folder": handleUniqueFolderCheck,
11149
+ "images:check-all-files": handleCheckAllFiles,
11150
+ "files:check-all-files": handleCheckAllFiles,
11151
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry
11152
+ };
11153
+ const deprecatedFixHandlers = {
11154
+ "image:replace-metadata": handleFileMetadata
11155
+ };
11156
+ const fixHandlers = {
11157
+ ...deprecatedFixHandlers,
11158
+ ...currentFixHandlers
11159
+ };
11160
+ function createDefaultValFSHost() {
11161
+ return {
11162
+ ...ts.sys,
11163
+ writeFile: (fileName, data, encoding) => {
11164
+ fs.mkdirSync(path__default.dirname(fileName), {
11165
+ recursive: true
11166
+ });
11167
+ fs.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
11168
+ },
11169
+ rmFile: fs.rmSync,
11170
+ readBuffer: fileName => {
11171
+ try {
11172
+ return fs.readFileSync(fileName);
11173
+ } catch {
11174
+ return undefined;
11175
+ }
11176
+ }
11177
+ };
9874
11178
  }
9875
11179
 
9876
11180
  function validateMetadata(actualMetadata, expectedMetadata) {
@@ -10165,9 +11469,9 @@ function getModulePathRange(modulePath, modulePathMap,
10165
11469
  // Which part of an object/record member to point at. For an object property
10166
11470
  // the resolved node's own range is the *key* (property name); the *value*
10167
11471
  // range is stored under `children.val`. Array elements, leaf literals and
10168
- // `c.image`/`c.file` `_ref`/`metadata` nodes have no `val` child, so "value"
10169
- // falls back to the node's own range for those. Defaults to "key" to preserve
10170
- // existing callers.
11472
+ // Array elements and leaf literals have no `val` child, so "value" falls back
11473
+ // to the node's own range for those. Defaults to "key" to preserve existing
11474
+ // callers.
10171
11475
  target = "key") {
10172
11476
  var _range$children;
10173
11477
  // Handle invalid module paths gracefully
@@ -10220,7 +11524,7 @@ target = "key") {
10220
11524
  *
10221
11525
  * NOTE: do not compute the start as `end.character - node.getWidth()`. That
10222
11526
  * identity only holds while the node stays on a single line - for a multi-line
10223
- * node (an object inside an array, a `c.image` metadata argument, ...) it
11527
+ * node (an object inside an array, a media object's `path`, ...) it
10224
11528
  * reports the *closing* line and a negative character.
10225
11529
  */
10226
11530
  function rangeOf(node, sourceFile) {
@@ -10284,39 +11588,6 @@ function traverse(node, sourceFile) {
10284
11588
  if (ts.isArrayLiteralExpression(node)) {
10285
11589
  return traverseArrayLiteral(node, sourceFile);
10286
11590
  }
10287
- if (ts.isCallExpression(node)) {
10288
- return traverseCallExpression(node, sourceFile);
10289
- }
10290
- }
10291
- function traverseCallExpression(node, sourceFile) {
10292
- if (ts.isPropertyAccessExpression(node.expression)) {
10293
- if (node.expression.expression.getText(sourceFile) === "c" && (node.expression.name.getText(sourceFile) === "file" || node.expression.name.getText(sourceFile) === "image")) {
10294
- const val = {
10295
- children: {},
10296
- ...rangeOf(node, sourceFile)
10297
- };
10298
- if (node.arguments[0]) {
10299
- const _ref = {
10300
- children: {},
10301
- ...rangeOf(node.arguments[0], sourceFile)
10302
- };
10303
- if (!node.arguments[1]) {
10304
- return {
10305
- val,
10306
- _ref
10307
- };
10308
- }
10309
- return {
10310
- val,
10311
- _ref,
10312
- metadata: {
10313
- children: {},
10314
- ...rangeOf(node.arguments[1], sourceFile)
10315
- }
10316
- };
10317
- }
10318
- }
10319
- }
10320
11591
  }
10321
11592
  function traverseArrayLiteral(node, sourceFile) {
10322
11593
  return node.elements.reduce((acc, element, index) => {
@@ -10390,61 +11661,6 @@ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
10390
11661
  return resolveExistingJsonPath(moduleFilePath, entry.importPath);
10391
11662
  }
10392
11663
 
10393
- /**
10394
- * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
10395
- * its own `*.val.json`, replacing the inline value with
10396
- * `c.json(() => import("./<key>.val.json"))`.
10397
- *
10398
- * This is the fix for the `jsonValues:extract-entry` validation error. It is not
10399
- * expressible as a patch (a patch edits one `.val.ts` and cannot create the
10400
- * backing JSON file), so it writes both files directly — JSON first, so a
10401
- * failure part-way through never leaves the module pointing at a file that does
10402
- * not exist.
10403
- *
10404
- * Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
10405
- * up in the module's root record/router object literal.
10406
- */
10407
- function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
10408
- const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
10409
- const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
10410
- if (!sourceFile) {
10411
- throw Error(`Source file ${valTsPath} not found`);
10412
- }
10413
- const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
10414
- if (result.isErr(pathsRes)) {
10415
- throw Error(formatPatchSourceError(pathsRes.error));
10416
- }
10417
- const {
10418
- jsonPath,
10419
- importPath
10420
- } = pathsRes.value;
10421
- const absoluteJsonPath = path__default.join(rootDir, jsonPath);
10422
- if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
10423
- throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
10424
- }
10425
-
10426
- // Remove the inline property, then add the `c.json(...)` reference back. The
10427
- // entry moves to the end of the record: entry ORDER in a jsonValues record is
10428
- // not meaningful (the Studio and the runtime key entries by name), and
10429
- // insert-in-place would mean reimplementing insertValJsonEntry.
10430
- const removed = removeValJsonEntry(sourceFile, [], entryKey);
10431
- if (result.isErr(removed)) {
10432
- throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
10433
- }
10434
- const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
10435
- if (result.isErr(inserted)) {
10436
- throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
10437
- }
10438
- sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
10439
- sourceFileHandler.writeSourceFile(inserted.value);
10440
- }
10441
- function formatOpsError(error, sourceFile) {
10442
- if (error instanceof PatchError) {
10443
- return error.message;
10444
- }
10445
- return flatMapErrors(error, e => formatSyntaxError(e, sourceFile)).join("\n");
10446
- }
10447
-
10448
11664
  /**
10449
11665
  * Replays a `val debug` snapshot: applies its patches the way /save does and
10450
11666
  * validates the result.
@@ -10544,4 +11760,4 @@ function readCapturedReport(snapshotDir) {
10544
11760
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
10545
11761
  }
10546
11762
 
10547
- export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValServer, decodeJwt, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };
11763
+ export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValServer, currentFixHandlers, decodeJwt, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };