@valbuild/server 0.131.0 → 0.132.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,7 +1,7 @@
1
1
  import ts from 'typescript';
2
2
  import { pipe, result, array } from '@valbuild/core/fp';
3
3
  import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
4
- import { derefPatch, Internal, extractValModules, computeValModuleShas, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
4
+ import { derefPatch, Internal, VAL_EXTENSION, extractValModules, computeValModuleShas, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
5
5
  export { hasRemoteFileSchema } from '@valbuild/core';
6
6
  import * as path from 'path';
7
7
  import path__default from 'path';
@@ -1097,6 +1097,176 @@ function descend(schema, key) {
1097
1097
  }
1098
1098
  }
1099
1099
 
1100
+ /**
1101
+ * Is this op a write of the WHOLE record at the root of a `.jsonValues()`
1102
+ * module?
1103
+ *
1104
+ * The one op {@link classifyJsonValuesOp} cannot classify: it finds the entry
1105
+ * key by walking the op path, and the root path has no segments to walk, so a
1106
+ * root write reads as `normal` and gets applied to the `.val.ts` - writing
1107
+ * markers over the `c.json(() => import(...))` calls that make the entries load
1108
+ * at all. {@link expandJsonValuesRootOp} turns it into ops that DO name a key,
1109
+ * which everything downstream already handles.
1110
+ *
1111
+ * `add` counts as well as `replace`: at the root both mean "the document is now
1112
+ * this" (see `JSONOps`), so both have to be expanded or the unexpanded one is
1113
+ * the same bug again.
1114
+ *
1115
+ * The value has to BE a record, and that is part of the question rather than a
1116
+ * check inside the expansion: a `.jsonValues()` record can be nullable, and
1117
+ * `null` at the root means the module no longer has a record at all - there are
1118
+ * no entries to write, and it is an ordinary `.val.ts` write, exactly as it was
1119
+ * before any of this existed. Same for any other non-record value: not a
1120
+ * whole-record write, so not this conversion's to route.
1121
+ */
1122
+ function isJsonValuesRootOp(schema, op) {
1123
+ return (op.op === "replace" || op.op === "add") && op.path.length === 0 && isJsonValuesRecord$1(op.value) && schema.type === "record" && schema.jsonValues === true;
1124
+ }
1125
+ function isJsonValuesRecord$1(value) {
1126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1127
+ }
1128
+
1129
+ /**
1130
+ * One entry as it stands right now, for {@link expandJsonValuesRootOp} to
1131
+ * expand against.
1132
+ *
1133
+ * The key set decides add-vs-replace-vs-remove. The content, where the caller
1134
+ * has it, decides whether an op is emitted at all: an entry whose content is
1135
+ * already what the root write says is left alone rather than rewritten, so
1136
+ * putting a module back does not touch every `*.val.json` it did not change.
1137
+ * `undefined` means "this entry exists, content unknown" - which is what the
1138
+ * Studio's draft source has, because the source holds markers - and yields a
1139
+ * `replace`, the safe answer.
1140
+ */
1141
+
1142
+ /**
1143
+ * Fans a whole-record write at a `.jsonValues()` module's root out into ops
1144
+ * that each name an entry key.
1145
+ *
1146
+ * This is THE conversion, and it has exactly one implementation on purpose: the
1147
+ * commit flow (`ValOps.prepare`) and the read side that builds draft content
1148
+ * ({@link applyJsonValuesEntryPatches}) both expand through here, so a draft
1149
+ * cannot show something other than what publishing writes. A second
1150
+ * implementation of the same rule would differ silently, which is the whole
1151
+ * failure mode this exists to prevent.
1152
+ *
1153
+ * The value must be the entries' CONTENT. A module's Source is markers, not
1154
+ * content, so a Source handed over here is refused rather than written: that is
1155
+ * the shape of the original bug (a revert replaying the archived Source), and
1156
+ * it is not recoverable afterwards - the markers replace the entries' content
1157
+ * on disk.
1158
+ */
1159
+ function expandJsonValuesRootOp(op, currentEntries) {
1160
+ const valueRes = jsonValuesRootValue(op);
1161
+ if (result.isErr(valueRes)) {
1162
+ return valueRes;
1163
+ }
1164
+ const value = valueRes.value;
1165
+ const expanded = [];
1166
+ for (const entryKey of Object.keys(value)) {
1167
+ const write = writeForEntry(value, entryKey, currentEntries);
1168
+ if (write !== null) {
1169
+ expanded.push(write);
1170
+ }
1171
+ }
1172
+ for (const entryKey of currentEntries.keys()) {
1173
+ if (!Object.prototype.hasOwnProperty.call(value, entryKey)) {
1174
+ expanded.push({
1175
+ op: "remove",
1176
+ path: [entryKey]
1177
+ });
1178
+ }
1179
+ }
1180
+ return result.ok(expanded);
1181
+ }
1182
+
1183
+ /**
1184
+ * What a whole-record write says about ONE entry: the same rule
1185
+ * {@link expandJsonValuesRootOp} applies to all of them.
1186
+ *
1187
+ * For a reader that is about one entry. Expanding the whole record to find the
1188
+ * one op that names its key costs an op per entry per entry read - the record
1189
+ * squared, on exactly the large records this feature is for. The rule itself is
1190
+ * `writeForEntry`, shared with the expansion above, so the two cannot come to
1191
+ * disagree; only the loop around it differs.
1192
+ *
1193
+ * `null` means the write says nothing about this entry: it holds that content
1194
+ * already, or the record does not name it and it does not exist.
1195
+ */
1196
+ function expandJsonValuesRootOpForKey(op, entryKey, currentEntries) {
1197
+ const valueRes = jsonValuesRootValue(op);
1198
+ if (result.isErr(valueRes)) {
1199
+ return valueRes;
1200
+ }
1201
+ const value = valueRes.value;
1202
+ if (Object.prototype.hasOwnProperty.call(value, entryKey)) {
1203
+ return result.ok(writeForEntry(value, entryKey, currentEntries));
1204
+ }
1205
+ if (currentEntries.has(entryKey)) {
1206
+ return result.ok({
1207
+ op: "remove",
1208
+ path: [entryKey]
1209
+ });
1210
+ }
1211
+ return result.ok(null);
1212
+ }
1213
+
1214
+ /**
1215
+ * The record a whole-record write carries, once it is known to be one.
1216
+ *
1217
+ * Every entry's value is checked here rather than where each one is written:
1218
+ * a marker anywhere in the record means the writer handed over a module's
1219
+ * Source, and the answer to that is to refuse the write, not to write the rest
1220
+ * of it. Both entry points ask, so an entry read reports it too rather than
1221
+ * showing a draft of a patch that publishing will refuse.
1222
+ */
1223
+ function jsonValuesRootValue(op) {
1224
+ if (op.op !== "replace" && op.op !== "add") {
1225
+ return result.err(new PatchError(`Cannot '${op.op}' the root of a .jsonValues() record: only add and replace write the whole record`));
1226
+ }
1227
+ const value = op.value;
1228
+ if (!isJsonValuesRecord$1(value)) {
1229
+ return result.err(new PatchError("Cannot write the root of a .jsonValues() record with a non-record value: it must be an object of entry key to entry content"));
1230
+ }
1231
+ for (const entryKey of Object.keys(value)) {
1232
+ /*
1233
+ * A marker is what a module's Source holds WHERE THE CONTENT IS NOT: it
1234
+ * says the content is in the entry's own file. Handed back as an entry's
1235
+ * value - which is what replaying an archived Source does - it would
1236
+ * replace that content with the note saying where it used to be, and
1237
+ * nothing downstream could tell the difference.
1238
+ */
1239
+ if (Internal.isJson(value[entryKey])) {
1240
+ return result.err(new PatchError(`Cannot write the .jsonValues() entry '${entryKey}' from a '${VAL_EXTENSION}: "json"' marker: a marker says the content is in the entry's own file, so write the entry's CONTENT here, not the module's Source`));
1241
+ }
1242
+ }
1243
+ return result.ok(value);
1244
+ }
1245
+
1246
+ /** THE rule, for one entry the record names. `null` = nothing to write. */
1247
+ function writeForEntry(value, entryKey, currentEntries) {
1248
+ const content = value[entryKey];
1249
+ if (!currentEntries.has(entryKey)) {
1250
+ return {
1251
+ op: "add",
1252
+ path: [entryKey],
1253
+ value: content
1254
+ };
1255
+ }
1256
+ const current = currentEntries.get(entryKey);
1257
+ if (current !== undefined && deepEqual(current, content)) {
1258
+ // Unchanged: emitting a replace here would rewrite the entry's file with
1259
+ // the bytes it already holds, so putting a module back would show up as a
1260
+ // change to every entry in it.
1261
+ return null;
1262
+ }
1263
+ return {
1264
+ op: "replace",
1265
+ path: [entryKey],
1266
+ value: content
1267
+ };
1268
+ }
1269
+
1100
1270
  /**
1101
1271
  * Finds every `.jsonValues()` record in a module's schema that is NOT the
1102
1272
  * module's root, returning the path to each within the module source.
@@ -1270,88 +1440,110 @@ function applyJsonValuesEntryPatches(args) {
1270
1440
  patch
1271
1441
  } of patches) {
1272
1442
  let touched = false;
1273
- for (const op of patch) {
1274
- const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1275
- kind: "normal"
1276
- };
1277
- if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1278
- continue;
1443
+ for (const rawOp of patch) {
1444
+ /**
1445
+ * A write of the WHOLE record becomes what it says about THIS entry.
1446
+ *
1447
+ * Through the same rule the commit flow expands with, against the same
1448
+ * view of this entry: present or not, and with what content. That is what
1449
+ * makes the draft this produces and the files a publish writes agree -
1450
+ * two expansions of one rule would not.
1451
+ */
1452
+ let ops = [rawOp];
1453
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, rawOp)) {
1454
+ const write = expandJsonValuesRootOpForKey(rawOp, entryKey, new Map(content === undefined ? [] : [[entryKey, content]]));
1455
+ if (result.isErr(write)) {
1456
+ return {
1457
+ kind: "error",
1458
+ message: write.error.message,
1459
+ patchId
1460
+ };
1461
+ }
1462
+ ops = write.value === null ? [] : [write.value];
1279
1463
  }
1280
- touched = true;
1281
- // A file op does not edit the entry's content — its bytes go through the
1282
- // upload pipeline. What the entry needs is the `patch_id` saying those
1283
- // bytes are not committed yet, so `mediaUrl` serves them from the patch
1284
- // directory instead of a `/public` path that holds nothing. Without this a
1285
- // just-uploaded image inside an entry renders broken.
1286
- if (op.op === "file") {
1287
- if (op.value === null || content === undefined) {
1288
- // A delete carries no bytes to point at, and there is nothing to mark
1289
- // on an entry that does not exist.
1464
+ for (const op of ops) {
1465
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1466
+ kind: "normal"
1467
+ };
1468
+ if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1290
1469
  continue;
1291
1470
  }
1292
- const applied = applyPatch(deepClone(content), jsonOps$2, [{
1293
- op: "add",
1294
- path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1295
- value: patchId
1296
- }]);
1297
- if (result.isErr(applied)) {
1471
+ touched = true;
1472
+ // A file op does not edit the entry's content — its bytes go through the
1473
+ // upload pipeline. What the entry needs is the `patch_id` saying those
1474
+ // bytes are not committed yet, so `mediaUrl` serves them from the patch
1475
+ // directory instead of a `/public` path that holds nothing. Without this a
1476
+ // just-uploaded image inside an entry renders broken.
1477
+ if (op.op === "file") {
1478
+ if (op.value === null || content === undefined) {
1479
+ // A delete carries no bytes to point at, and there is nothing to mark
1480
+ // on an entry that does not exist.
1481
+ continue;
1482
+ }
1483
+ const applied = applyPatch(deepClone(content), jsonOps$2, [{
1484
+ op: "add",
1485
+ path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1486
+ value: patchId
1487
+ }]);
1488
+ if (result.isErr(applied)) {
1489
+ return {
1490
+ kind: "error",
1491
+ message: applied.error.message,
1492
+ patchId
1493
+ };
1494
+ }
1495
+ content = applied.value;
1496
+ continue;
1497
+ }
1498
+ if (cls.subPath.length === 0) {
1499
+ if (op.op === "add" || op.op === "replace") {
1500
+ content = op.value;
1501
+ deleted = false;
1502
+ } else if (op.op === "remove") {
1503
+ content = undefined;
1504
+ deleted = true;
1505
+ } else if (op.op === "test") {
1506
+ // An assertion, not a mutation: the content is unchanged either way, and
1507
+ // the commit path is where a failing `test` is reported. Falling through
1508
+ // to the move/copy error below turned a no-op into a permanent load
1509
+ // failure for the whole entry.
1510
+ continue;
1511
+ } else {
1512
+ // move/copy INTO this key: the content comes from the source entry,
1513
+ // which the caller must resolve (it is a different `*.val.json`).
1514
+ return {
1515
+ kind: "error",
1516
+ message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
1517
+ patchId
1518
+ };
1519
+ }
1520
+ continue;
1521
+ }
1522
+ if (content === undefined) {
1298
1523
  return {
1299
1524
  kind: "error",
1300
- message: applied.error.message,
1525
+ message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
1301
1526
  patchId
1302
1527
  };
1303
1528
  }
1304
- content = applied.value;
1305
- continue;
1306
- }
1307
- if (cls.subPath.length === 0) {
1308
- if (op.op === "add" || op.op === "replace") {
1309
- content = op.value;
1310
- deleted = false;
1311
- } else if (op.op === "remove") {
1312
- content = undefined;
1313
- deleted = true;
1314
- } else if (op.op === "test") {
1315
- // An assertion, not a mutation: the content is unchanged either way, and
1316
- // the commit path is where a failing `test` is reported. Falling through
1317
- // to the move/copy error below turned a no-op into a permanent load
1318
- // failure for the whole entry.
1319
- continue;
1320
- } else {
1321
- // move/copy INTO this key: the content comes from the source entry,
1322
- // which the caller must resolve (it is a different `*.val.json`).
1529
+ const rebased = rebaseContentOp(op, cls.recordPath.length + 1);
1530
+ if (result.isErr(rebased)) {
1323
1531
  return {
1324
1532
  kind: "error",
1325
- message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
1533
+ message: rebased.error.message,
1326
1534
  patchId
1327
1535
  };
1328
1536
  }
1329
- continue;
1330
- }
1331
- if (content === undefined) {
1332
- return {
1333
- kind: "error",
1334
- message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
1335
- patchId
1336
- };
1337
- }
1338
- const rebased = rebaseContentOp(op, cls.recordPath.length + 1);
1339
- if (result.isErr(rebased)) {
1340
- return {
1341
- kind: "error",
1342
- message: rebased.error.message,
1343
- patchId
1344
- };
1345
- }
1346
- const applied = applyPatch(deepClone(content), jsonOps$2, [rebased.value]);
1347
- if (result.isErr(applied)) {
1348
- return {
1349
- kind: "error",
1350
- message: applied.error.message,
1351
- patchId
1352
- };
1537
+ const applied = applyPatch(deepClone(content), jsonOps$2, [rebased.value]);
1538
+ if (result.isErr(applied)) {
1539
+ return {
1540
+ kind: "error",
1541
+ message: applied.error.message,
1542
+ patchId
1543
+ };
1544
+ }
1545
+ content = applied.value;
1353
1546
  }
1354
- content = applied.value;
1355
1547
  }
1356
1548
  if (touched) {
1357
1549
  appliedPatchIds.push(patchId);
@@ -1568,6 +1760,34 @@ function analyzeJsonValuesEntries(source) {
1568
1760
  }
1569
1761
  return entries;
1570
1762
  }
1763
+
1764
+ /**
1765
+ * Every key of a `.jsonValues()` record's source literal, thunked or not.
1766
+ *
1767
+ * {@link analyzeJsonValuesEntries} answers "which entries have a file behind
1768
+ * them", which is the question for resolving a path. This is the other one:
1769
+ * which keys the record HAS. They differ on a value that is not a
1770
+ * `c.json(...)` call - written inline, or mid-edit - and telling an existing
1771
+ * key from a new one is what decides whether a whole-record write adds a
1772
+ * `c.json(...)` thunk or replaces one. Using the thunk set for that would
1773
+ * insert a second property with a key the literal already has.
1774
+ */
1775
+ function jsonValuesRecordKeys(source) {
1776
+ const keys = [];
1777
+ if (!ts.isObjectLiteralExpression(source)) {
1778
+ return keys;
1779
+ }
1780
+ for (const prop of source.properties) {
1781
+ if (!ts.isPropertyAssignment(prop)) {
1782
+ continue;
1783
+ }
1784
+ const key = getPropertyKey(prop.name);
1785
+ if (key !== null) {
1786
+ keys.push(key);
1787
+ }
1788
+ }
1789
+ return keys;
1790
+ }
1571
1791
  function getPropertyKey(name) {
1572
1792
  if (ts.isStringLiteralLike(name)) {
1573
1793
  return name.text;
@@ -1874,6 +2094,21 @@ class Service {
1874
2094
  kind: "normal"
1875
2095
  };
1876
2096
  for (const op of patch) {
2097
+ /*
2098
+ * A write of the WHOLE record is refused here, not applied.
2099
+ *
2100
+ * `classifyJsonValuesOp` reads a root path as `normal` - there is no
2101
+ * segment naming an entry - so it would go straight into the `.val.ts`,
2102
+ * replacing the record and the `c.json(() => import(...))` calls that
2103
+ * make its entries load. `ValOps.prepare` expands such a write into
2104
+ * per-entry ops; that needs each entry's file read and written, which
2105
+ * `patchValFile` below does not do. Nothing that reaches here produces
2106
+ * one today (`val validate --fix` only corrects values inside an entry),
2107
+ * so this refuses rather than silently doing the wrong thing.
2108
+ */
2109
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, op)) {
2110
+ throw Error(`Cannot ${op.op} the whole .jsonValues() record of ${moduleFilePath} through Service.patch: its entries are separate files, so write them per entry`);
2111
+ }
1877
2112
  const cls = classify(op.path);
1878
2113
  // `move` and `copy` read a SECOND path, and `rebaseContentOp` slices
1879
2114
  // `from` by the same prefix it slices `path` by. So an op whose two ends
@@ -2314,6 +2549,15 @@ function encodeJwt(payload, sessionKey) {
2314
2549
 
2315
2550
  /* eslint-disable @typescript-eslint/no-unused-vars */
2316
2551
  const jsonOps = new JSONOps();
2552
+ /**
2553
+ * How many `.jsonValues()` entry files a whole-record write reads at a time.
2554
+ *
2555
+ * Reading them is a network round trip per entry in `ValOpsHttp`, and a commit
2556
+ * cannot start until they are in. One at a time is too slow for the records
2557
+ * this is for; all at once is a burst of hundreds of requests at the content
2558
+ * host.
2559
+ */
2560
+ const JSON_ENTRY_READ_CONCURRENCY = 8;
2317
2561
  const tsOps = new TSOps(document => {
2318
2562
  return pipe(analyzeValModule(document), result.map(({
2319
2563
  source
@@ -3025,74 +3269,139 @@ class ValOps {
3025
3269
  // poisoning the rest of this module's patch chain). See the per-op
3026
3270
  // routing below.
3027
3271
  const serializedSchema = jsonValuesSchemaFor(path);
3028
- for (const op of patchData.patch) {
3029
- if (op.op === "file") {
3030
- // A file op inside a `.jsonValues()` entry has nothing to inject
3031
- // HERE: the entry is an opaque marker in the module source, so an
3032
- // `add` reaching into it fails and poisons the rest of this
3033
- // module's chain. `applyJsonValuesEntryPatches` writes the patch_id
3034
- // into the entry's draft content instead.
3035
- const fileCls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3036
- kind: "normal"
3037
- };
3038
- if (fileCls.kind === "entry" && fileCls.subPath.length > 0) {
3039
- continue;
3040
- }
3041
- if (op.value !== null) {
3042
- // NOTE: We insert the last patch_id that modify a file
3043
- // when constructing the url we use the patch id (and the file path)
3044
- // to fetch the right file
3045
- // NOTE: overwrite and use last patch_id if multiple patches modify the same file
3046
- fileFixOps[op.path.join("/")] = [{
3047
- op: "add",
3048
- path: op.path.concat(...(op.nestedFilePath || [])).concat("patch_id"),
3049
- value: patchId
3050
- }];
3272
+ /**
3273
+ * The record's keys as they stand while this patch is walked.
3274
+ *
3275
+ * Only the key set: the module source holds markers, so there is no
3276
+ * content here to compare a whole-record write against. Seeded from the
3277
+ * source and kept current as the ops below add and remove entries,
3278
+ * because `patchedSources` is only written once the whole patch has
3279
+ * been applied.
3280
+ */
3281
+ let entryKeys = null;
3282
+ const currentEntryKeys = () => {
3283
+ if (entryKeys === null) {
3284
+ entryKeys = new Map();
3285
+ const source = patchedSources[path];
3286
+ if (typeof source === "object" && source !== null && !Array.isArray(source)) {
3287
+ for (const key of Object.keys(source)) {
3288
+ entryKeys.set(key, undefined);
3289
+ }
3051
3290
  }
3052
- // null value = delete: no patch_id to inject; the "remove" op in
3053
- // the patch already removes the metadata entry from the source
3054
- } else {
3055
- const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3056
- kind: "normal"
3057
- };
3058
- if (cls.kind === "normal") {
3059
- applicableOps.push(op);
3060
- } else if (cls.subPath.length > 0) ; else if (op.op === "add" || op.op === "replace") {
3061
- // Whole-entry add/replace: keep the record's KEY SET correct for
3062
- // drafts by writing the marker rather than the content. Record
3063
- // validation only asserts `isJson`, and
3064
- // `validateJsonValuesEntries` skips thunkless markers by design.
3065
- applicableOps.push({
3066
- op: op.op,
3067
- path: op.path,
3068
- value: {
3069
- [VAL_EXTENSION]: "json",
3070
- patch_id: patchId
3071
- }
3291
+ }
3292
+ return entryKeys;
3293
+ };
3294
+ /**
3295
+ * Set when a whole-record write could not be expanded.
3296
+ *
3297
+ * Nothing of this patch is applied then: the ops before the failure are
3298
+ * half of an edit, and half of a record write is a record missing
3299
+ * entries.
3300
+ */
3301
+ let unexpandable = false;
3302
+ for (const rawOp of patchData.patch) {
3303
+ /**
3304
+ * A write of the WHOLE record fans out into per-entry ops first,
3305
+ * through the same expansion the commit flow and the entry read path
3306
+ * use. Left as it is, a root write would put the value straight into
3307
+ * the module source - where the entries are markers, so the record
3308
+ * would hold content the Studio then tries to load as entries.
3309
+ */
3310
+ let ops = [rawOp];
3311
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, rawOp)) {
3312
+ const expandedRes = expandJsonValuesRootOp(rawOp, currentEntryKeys());
3313
+ if (result.isErr(expandedRes)) {
3314
+ if (!errors[path]) {
3315
+ errors[path] = [];
3316
+ }
3317
+ errors[path].push({
3318
+ patchId,
3319
+ skipped: false,
3320
+ error: expandedRes.error
3072
3321
  });
3073
- } else if (op.op === "remove") {
3074
- applicableOps.push(op);
3322
+ unexpandable = true;
3323
+ break;
3324
+ }
3325
+ ops = expandedRes.value;
3326
+ }
3327
+ for (const op of ops) {
3328
+ if (op.op === "file") {
3329
+ // A file op inside a `.jsonValues()` entry has nothing to inject
3330
+ // HERE: the entry is an opaque marker in the module source, so an
3331
+ // `add` reaching into it fails and poisons the rest of this
3332
+ // module's chain. `applyJsonValuesEntryPatches` writes the patch_id
3333
+ // into the entry's draft content instead.
3334
+ const fileCls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3335
+ kind: "normal"
3336
+ };
3337
+ if (fileCls.kind === "entry" && fileCls.subPath.length > 0) {
3338
+ continue;
3339
+ }
3340
+ if (op.value !== null) {
3341
+ // NOTE: We insert the last patch_id that modify a file
3342
+ // when constructing the url we use the patch id (and the file path)
3343
+ // to fetch the right file
3344
+ // NOTE: overwrite and use last patch_id if multiple patches modify the same file
3345
+ fileFixOps[op.path.join("/")] = [{
3346
+ op: "add",
3347
+ path: op.path.concat(...(op.nestedFilePath || [])).concat("patch_id"),
3348
+ value: patchId
3349
+ }];
3350
+ }
3351
+ // null value = delete: no patch_id to inject; the "remove" op in
3352
+ // the patch already removes the metadata entry from the source
3075
3353
  } else {
3076
- // move/copy of a whole entry: the destination key must appear, and
3077
- // for a move the source key must disappear. Both are key-set
3078
- // changes we can express with markers.
3079
- applicableOps.push({
3080
- op: "add",
3081
- path: op.path,
3082
- value: {
3083
- [VAL_EXTENSION]: "json",
3084
- patch_id: patchId
3085
- }
3086
- });
3087
- if (op.op === "move" && array.isNonEmpty(op.from)) {
3354
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3355
+ kind: "normal"
3356
+ };
3357
+ if (cls.kind === "normal") {
3358
+ applicableOps.push(op);
3359
+ } else if (cls.subPath.length > 0) ; else if (op.op === "add" || op.op === "replace") {
3360
+ // Whole-entry add/replace: keep the record's KEY SET correct for
3361
+ // drafts by writing the marker rather than the content. Record
3362
+ // validation only asserts `isJson`, and
3363
+ // `validateJsonValuesEntries` skips thunkless markers by design.
3088
3364
  applicableOps.push({
3089
- op: "remove",
3090
- path: op.from
3365
+ op: op.op,
3366
+ path: op.path,
3367
+ value: {
3368
+ [VAL_EXTENSION]: "json",
3369
+ patch_id: patchId
3370
+ }
3371
+ });
3372
+ currentEntryKeys().set(cls.entryKey, undefined);
3373
+ } else if (op.op === "remove") {
3374
+ applicableOps.push(op);
3375
+ currentEntryKeys().delete(cls.entryKey);
3376
+ } else {
3377
+ // move/copy of a whole entry: the destination key must appear, and
3378
+ // for a move the source key must disappear. Both are key-set
3379
+ // changes we can express with markers.
3380
+ applicableOps.push({
3381
+ op: "add",
3382
+ path: op.path,
3383
+ value: {
3384
+ [VAL_EXTENSION]: "json",
3385
+ patch_id: patchId
3386
+ }
3091
3387
  });
3388
+ currentEntryKeys().set(cls.entryKey, undefined);
3389
+ if (op.op === "move" && array.isNonEmpty(op.from)) {
3390
+ applicableOps.push({
3391
+ op: "remove",
3392
+ path: op.from
3393
+ });
3394
+ if (op.from.length === 1) {
3395
+ currentEntryKeys().delete(op.from[0]);
3396
+ }
3397
+ }
3092
3398
  }
3093
3399
  }
3094
3400
  }
3095
3401
  }
3402
+ if (unexpandable) {
3403
+ continue;
3404
+ }
3096
3405
  const patchRes = applyPatch(deepClone(patchedSources[path]),
3097
3406
  // applyPatch mutates the source. On add operations it adds more than once? There is something strange going on... deepClone seems to fix, but is that the right solution?
3098
3407
  jsonOps, applicableOps.concat(...Object.values(fileFixOps)));
@@ -3505,22 +3814,34 @@ class ValOps {
3505
3814
  // content ops in the same commit resolve to the freshly-created file.
3506
3815
  const entryKeyToJsonPath = new Map();
3507
3816
 
3508
- // Lazily analyzed `c.json(() => import("..."))` entries of the ORIGINAL
3509
- // `.val.ts` (import paths are authoritative for existing/hand-placed files).
3510
- let analyzerEntries = null;
3817
+ // Lazily analyzed entries of the ORIGINAL `.val.ts`: the
3818
+ // `c.json(() => import("..."))` thunks (import paths are authoritative
3819
+ // for existing/hand-placed files) and the record's whole key set, which
3820
+ // is what a write of the whole record is expanded against.
3821
+ let analyzed = null;
3822
+ const analyzeEntries = () => {
3823
+ if (analyzed === null) {
3824
+ const analysis = analyzeValModule(originalSourceFile);
3825
+ if (result.isErr(analysis)) {
3826
+ return result.err(analysis.error);
3827
+ }
3828
+ analyzed = {
3829
+ thunks: analyzeJsonValuesEntries(analysis.value.source),
3830
+ keys: jsonValuesRecordKeys(analysis.value.source)
3831
+ };
3832
+ }
3833
+ return result.ok(analyzed);
3834
+ };
3511
3835
  const resolveEntryJsonPath = entryKey => {
3512
3836
  const added = entryKeyToJsonPath.get(entryKey);
3513
3837
  if (added !== undefined) {
3514
3838
  return result.ok(added);
3515
3839
  }
3516
- if (analyzerEntries === null) {
3517
- const analysis = analyzeValModule(originalSourceFile);
3518
- if (result.isErr(analysis)) {
3519
- return result.err(analysis.error);
3520
- }
3521
- analyzerEntries = analyzeJsonValuesEntries(analysis.value.source);
3840
+ const analyzedRes = analyzeEntries();
3841
+ if (result.isErr(analyzedRes)) {
3842
+ return analyzedRes;
3522
3843
  }
3523
- const entry = analyzerEntries.get(entryKey);
3844
+ const entry = analyzedRes.value.thunks.get(entryKey);
3524
3845
  if (!entry) {
3525
3846
  return result.err({
3526
3847
  message: `Could not find jsonValues entry '${entryKey}' in ${path}`,
@@ -3558,6 +3879,73 @@ class ValOps {
3558
3879
  });
3559
3880
  }
3560
3881
  };
3882
+ /**
3883
+ * Every entry the module has AS THINGS STAND, with its content.
3884
+ *
3885
+ * What {@link expandJsonValuesRootOp} expands a write of the whole record
3886
+ * against. The keys come from the `.val.ts` the commit started from plus
3887
+ * what this commit has already added or removed; the content comes from
3888
+ * each entry's file, so an entry the write leaves as it is gets no op and
3889
+ * its file is not rewritten.
3890
+ *
3891
+ * Content is read WITHOUT going through `loadEntryContent`, whose cache is
3892
+ * also the write set: everything in it is written back at the flush, so
3893
+ * reading 200 entries to compare them would rewrite all 200. Unreadable
3894
+ * here means "content unknown", not an error - the expansion then emits a
3895
+ * `replace`, which is what a module with a broken entry file needs anyway.
3896
+ */
3897
+ const currentJsonEntries = async () => {
3898
+ const analyzedRes = analyzeEntries();
3899
+ if (result.isErr(analyzedRes)) {
3900
+ return analyzedRes;
3901
+ }
3902
+ const keys = new Set(analyzedRes.value.keys);
3903
+ for (const [entryKey, content] of jsonEntryContentsByKey) {
3904
+ if (content === null) {
3905
+ keys.delete(entryKey);
3906
+ } else {
3907
+ keys.add(entryKey);
3908
+ }
3909
+ }
3910
+ const readEntry = async entryKey => {
3911
+ const pending = jsonEntryContentsByKey.get(entryKey);
3912
+ if (pending !== undefined && pending !== null) {
3913
+ return pending;
3914
+ }
3915
+ const jsonPathRes = resolveEntryJsonPath(entryKey);
3916
+ if (result.isErr(jsonPathRes)) {
3917
+ return undefined;
3918
+ }
3919
+ const res = await this.getSourceFile(jsonPathRes.value);
3920
+ if (res.error) {
3921
+ return undefined;
3922
+ }
3923
+ try {
3924
+ const parsed = JSON.parse(res.data);
3925
+ return parsed;
3926
+ } catch {
3927
+ return undefined;
3928
+ }
3929
+ };
3930
+ /*
3931
+ * Read in batches rather than one after another: `getSourceFile` is a
3932
+ * network round trip in `ValOpsHttp`, so a record with hundreds of
3933
+ * entries would otherwise spend hundreds of them in series before the
3934
+ * commit could even start - long enough to time out. The keys are
3935
+ * walked in order and the results assembled in that order, so what the
3936
+ * expansion sees does not depend on which read finished first.
3937
+ */
3938
+ const entries = new Map();
3939
+ const entryKeys = Array.from(keys);
3940
+ for (let i = 0; i < entryKeys.length; i += JSON_ENTRY_READ_CONCURRENCY) {
3941
+ const batch = entryKeys.slice(i, i + JSON_ENTRY_READ_CONCURRENCY);
3942
+ const read = await Promise.all(batch.map(readEntry));
3943
+ for (let j = 0; j < batch.length; j++) {
3944
+ entries.set(batch[j], read[j]);
3945
+ }
3946
+ }
3947
+ return result.ok(entries);
3948
+ };
3561
3949
  const collectPatchError = (err, patchId, op) => {
3562
3950
  console.error("Could not patch", JSON.stringify({
3563
3951
  path,
@@ -3598,212 +3986,248 @@ class ValOps {
3598
3986
  // Where this patch's errors start, so the unappliable-patch report below
3599
3987
  // can name what went wrong rather than just that something did.
3600
3988
  const errorsBefore = errors.length;
3601
- for (const op of sourceFileOps) {
3602
- const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3603
- kind: "normal"
3604
- };
3605
- // `move` / `copy` also READ from a path: classify that too, so an op
3606
- // that moves a value out of (or into) a jsonValues entry cannot slip
3607
- // through as a plain `.val.ts` op.
3608
- const fromCls = serializedSchema && (op.op === "move" || op.op === "copy") ? classifyJsonValuesOp(serializedSchema, op.from) : {
3609
- kind: "normal"
3610
- };
3611
- if (cls.kind === "normal" && fromCls.kind === "normal") {
3612
- const patchRes = applyPatch(tsSourceFile, tsOps, [op]);
3613
- if (result.isErr(patchRes)) {
3614
- collectPatchError(patchRes.error, patchId, op);
3989
+ for (const rawOp of sourceFileOps) {
3990
+ /**
3991
+ * A write of the WHOLE record fans out into per-entry ops first.
3992
+ *
3993
+ * The only op `classifyJsonValuesOp` cannot route, because it finds
3994
+ * the entry key by walking the op path and the root path has none. So
3995
+ * a patch author never has to know whether a record is
3996
+ * `.jsonValues()`: they write the module as if it were ordinary
3997
+ * source, and it arrives here as adds, replaces and removes that name
3998
+ * a key - the shapes everything below already handles. The read side
3999
+ * expands through the same function, so a draft cannot show something
4000
+ * other than what this writes.
4001
+ */
4002
+ let ops = [rawOp];
4003
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, rawOp)) {
4004
+ const currentRes = await currentJsonEntries();
4005
+ if (result.isErr(currentRes)) {
4006
+ errors.push(currentRes.error);
3615
4007
  patchHadError = true;
3616
4008
  break;
3617
4009
  }
3618
- tsSourceFile = patchRes.value;
3619
- tsChanged = true;
3620
- continue;
3621
- }
3622
- if (cls.kind === "normal") {
3623
- errors.push({
3624
- message: `Cannot '${op.op}' a value out of a jsonValues entry and into the module source`,
3625
- filePath: path
3626
- });
3627
- patchHadError = true;
3628
- break;
3629
- }
3630
- // Nested `.jsonValues()` records are not supported: only the read path
3631
- // for a module's ROOT record/router is implemented end to end. This is
3632
- // also rejected up front in `initSources`; this is defense in depth.
3633
- if (cls.recordPath.length > 0 || fromCls.kind === "entry" && fromCls.recordPath.length > 0) {
3634
- errors.push({
3635
- message: `Nested .jsonValues() records are not supported: '${cls.recordPath.join(".")}' in ${path}. Use .jsonValues() only on a module's root record/router.`,
3636
- filePath: path
3637
- });
3638
- patchHadError = true;
3639
- break;
4010
+ const expandedRes = expandJsonValuesRootOp(rawOp, currentRes.value);
4011
+ if (result.isErr(expandedRes)) {
4012
+ errors.push({
4013
+ message: expandedRes.error.message,
4014
+ filePath: path
4015
+ });
4016
+ patchHadError = true;
4017
+ break;
4018
+ }
4019
+ ops = expandedRes.value;
3640
4020
  }
3641
- // The op targets a `.jsonValues()` entry.
3642
- if (cls.subPath.length === 0) {
3643
- // Structural / whole-entry op.
3644
- if (op.op === "add") {
3645
- const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
3646
- if (result.isErr(newPathsRes)) {
3647
- errors.push(newPathsRes.error);
3648
- patchHadError = true;
3649
- break;
3650
- }
3651
- const {
3652
- jsonPath,
3653
- importPath
3654
- } = newPathsRes.value;
3655
- const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
3656
- if (result.isErr(insRes)) {
3657
- collectPatchError(insRes.error, patchId, op);
3658
- patchHadError = true;
3659
- break;
3660
- }
3661
- tsSourceFile = insRes.value;
3662
- tsChanged = true;
3663
- jsonEntryContents.set(jsonPath, op.value);
3664
- jsonEntryContentsByKey.set(cls.entryKey, op.value);
3665
- entryKeyToJsonPath.set(cls.entryKey, jsonPath);
3666
- } else if (op.op === "remove") {
3667
- const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3668
- if (result.isErr(jsonPathRes)) {
3669
- errors.push(jsonPathRes.error);
3670
- patchHadError = true;
3671
- break;
3672
- }
3673
- const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey);
3674
- if (result.isErr(remRes)) {
3675
- collectPatchError(remRes.error, patchId, op);
4021
+ for (const op of ops) {
4022
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
4023
+ kind: "normal"
4024
+ };
4025
+ // `move` / `copy` also READ from a path: classify that too, so an op
4026
+ // that moves a value out of (or into) a jsonValues entry cannot slip
4027
+ // through as a plain `.val.ts` op.
4028
+ const fromCls = serializedSchema && (op.op === "move" || op.op === "copy") ? classifyJsonValuesOp(serializedSchema, op.from) : {
4029
+ kind: "normal"
4030
+ };
4031
+ if (cls.kind === "normal" && fromCls.kind === "normal") {
4032
+ const patchRes = applyPatch(tsSourceFile, tsOps, [op]);
4033
+ if (result.isErr(patchRes)) {
4034
+ collectPatchError(patchRes.error, patchId, op);
3676
4035
  patchHadError = true;
3677
4036
  break;
3678
4037
  }
3679
- tsSourceFile = remRes.value;
4038
+ tsSourceFile = patchRes.value;
3680
4039
  tsChanged = true;
3681
- jsonEntryContents.set(jsonPathRes.value, null);
3682
- jsonEntryContentsByKey.set(cls.entryKey, null);
3683
- } else if (op.op === "replace") {
3684
- const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3685
- if (result.isErr(jsonPathRes)) {
3686
- errors.push(jsonPathRes.error);
4040
+ continue;
4041
+ }
4042
+ if (cls.kind === "normal") {
4043
+ errors.push({
4044
+ message: `Cannot '${op.op}' a value out of a jsonValues entry and into the module source`,
4045
+ filePath: path
4046
+ });
4047
+ patchHadError = true;
4048
+ break;
4049
+ }
4050
+ // Nested `.jsonValues()` records are not supported: only the read path
4051
+ // for a module's ROOT record/router is implemented end to end. This is
4052
+ // also rejected up front in `initSources`; this is defense in depth.
4053
+ if (cls.recordPath.length > 0 || fromCls.kind === "entry" && fromCls.recordPath.length > 0) {
4054
+ errors.push({
4055
+ message: `Nested .jsonValues() records are not supported: '${cls.recordPath.join(".")}' in ${path}. Use .jsonValues() only on a module's root record/router.`,
4056
+ filePath: path
4057
+ });
4058
+ patchHadError = true;
4059
+ break;
4060
+ }
4061
+ // The op targets a `.jsonValues()` entry.
4062
+ if (cls.subPath.length === 0) {
4063
+ // Structural / whole-entry op.
4064
+ if (op.op === "add") {
4065
+ const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
4066
+ if (result.isErr(newPathsRes)) {
4067
+ errors.push(newPathsRes.error);
4068
+ patchHadError = true;
4069
+ break;
4070
+ }
4071
+ const {
4072
+ jsonPath,
4073
+ importPath
4074
+ } = newPathsRes.value;
4075
+ const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
4076
+ if (result.isErr(insRes)) {
4077
+ collectPatchError(insRes.error, patchId, op);
4078
+ patchHadError = true;
4079
+ break;
4080
+ }
4081
+ tsSourceFile = insRes.value;
4082
+ tsChanged = true;
4083
+ jsonEntryContents.set(jsonPath, op.value);
4084
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
4085
+ entryKeyToJsonPath.set(cls.entryKey, jsonPath);
4086
+ } else if (op.op === "remove") {
4087
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
4088
+ if (result.isErr(jsonPathRes)) {
4089
+ errors.push(jsonPathRes.error);
4090
+ patchHadError = true;
4091
+ break;
4092
+ }
4093
+ const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey);
4094
+ if (result.isErr(remRes)) {
4095
+ collectPatchError(remRes.error, patchId, op);
4096
+ patchHadError = true;
4097
+ break;
4098
+ }
4099
+ tsSourceFile = remRes.value;
4100
+ tsChanged = true;
4101
+ jsonEntryContents.set(jsonPathRes.value, null);
4102
+ jsonEntryContentsByKey.set(cls.entryKey, null);
4103
+ } else if (op.op === "replace") {
4104
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
4105
+ if (result.isErr(jsonPathRes)) {
4106
+ errors.push(jsonPathRes.error);
4107
+ patchHadError = true;
4108
+ break;
4109
+ }
4110
+ jsonEntryContents.set(jsonPathRes.value, op.value);
4111
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
4112
+ } else if (op.op === "move" || op.op === "copy") {
4113
+ // Rename (move) or duplicate (copy) a whole entry. The new entry
4114
+ // gets its own `*.val.json` written with the source entry's
4115
+ // content plus a `c.json(...)` thunk; a move additionally drops
4116
+ // the old thunk and deletes the old file.
4117
+ if (fromCls.kind !== "entry" || fromCls.subPath.length !== 0 || fromCls.recordPath.join("\0") !== cls.recordPath.join("\0")) {
4118
+ errors.push({
4119
+ message: `Cannot '${op.op}' a jsonValues entry across records or from a non-entry path (from '${op.from.join(".")}' to '${op.path.join(".")}')`,
4120
+ filePath: path
4121
+ });
4122
+ patchHadError = true;
4123
+ break;
4124
+ }
4125
+ const fromKey = fromCls.entryKey;
4126
+ const fromPathRes = resolveEntryJsonPath(fromKey);
4127
+ if (result.isErr(fromPathRes)) {
4128
+ errors.push(fromPathRes.error);
4129
+ patchHadError = true;
4130
+ break;
4131
+ }
4132
+ // Load BEFORE marking anything deleted: `loadEntryContent` errors
4133
+ // on a path that has already been nulled in this commit.
4134
+ const contentRes = await loadEntryContent(fromPathRes.value);
4135
+ if (result.isErr(contentRes)) {
4136
+ errors.push(contentRes.error);
4137
+ patchHadError = true;
4138
+ break;
4139
+ }
4140
+ const content = deepClone(contentRes.value);
4141
+ if (op.op === "move") {
4142
+ const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, fromKey);
4143
+ if (result.isErr(remRes)) {
4144
+ collectPatchError(remRes.error, patchId, op);
4145
+ patchHadError = true;
4146
+ break;
4147
+ }
4148
+ tsSourceFile = remRes.value;
4149
+ }
4150
+ // LOCKED convention: the destination always uses the generated
4151
+ // path, so renaming a hand-placed file relocates it.
4152
+ const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
4153
+ if (result.isErr(newPathsRes)) {
4154
+ errors.push(newPathsRes.error);
4155
+ patchHadError = true;
4156
+ break;
4157
+ }
4158
+ const {
4159
+ jsonPath,
4160
+ importPath
4161
+ } = newPathsRes.value;
4162
+ const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
4163
+ if (result.isErr(insRes)) {
4164
+ collectPatchError(insRes.error, patchId, op);
4165
+ patchHadError = true;
4166
+ break;
4167
+ }
4168
+ tsSourceFile = insRes.value;
4169
+ tsChanged = true;
4170
+ jsonEntryContents.set(jsonPath, content);
4171
+ jsonEntryContentsByKey.set(cls.entryKey, content);
4172
+ entryKeyToJsonPath.set(cls.entryKey, jsonPath);
4173
+ if (op.op === "move" && fromPathRes.value !== jsonPath) {
4174
+ jsonEntryContents.set(fromPathRes.value, null);
4175
+ jsonEntryContentsByKey.set(fromKey, null);
4176
+ }
4177
+ } else {
4178
+ errors.push({
4179
+ message: `Unsupported op '${op.op}' on jsonValues entry '${cls.entryKey}' (supported: add, remove, replace, move, copy)`,
4180
+ filePath: path
4181
+ });
3687
4182
  patchHadError = true;
3688
4183
  break;
3689
4184
  }
3690
- jsonEntryContents.set(jsonPathRes.value, op.value);
3691
- jsonEntryContentsByKey.set(cls.entryKey, op.value);
3692
- } else if (op.op === "move" || op.op === "copy") {
3693
- // Rename (move) or duplicate (copy) a whole entry. The new entry
3694
- // gets its own `*.val.json` written with the source entry's
3695
- // content plus a `c.json(...)` thunk; a move additionally drops
3696
- // the old thunk and deletes the old file.
3697
- if (fromCls.kind !== "entry" || fromCls.subPath.length !== 0 || fromCls.recordPath.join("\0") !== cls.recordPath.join("\0")) {
4185
+ } else {
4186
+ // Content sub-op: replay against the entry's `*.val.json`.
4187
+ // `rebaseContentOp` slices `from` by the same prefix as `path`, so a
4188
+ // cross-entry move/copy would silently corrupt the target entry.
4189
+ if ((op.op === "move" || op.op === "copy") && (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey)) {
3698
4190
  errors.push({
3699
- message: `Cannot '${op.op}' a jsonValues entry across records or from a non-entry path (from '${op.from.join(".")}' to '${op.path.join(".")}')`,
4191
+ message: `Cannot '${op.op}' between different jsonValues entries`,
3700
4192
  filePath: path
3701
4193
  });
3702
4194
  patchHadError = true;
3703
4195
  break;
3704
4196
  }
3705
- const fromKey = fromCls.entryKey;
3706
- const fromPathRes = resolveEntryJsonPath(fromKey);
3707
- if (result.isErr(fromPathRes)) {
3708
- errors.push(fromPathRes.error);
4197
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
4198
+ if (result.isErr(jsonPathRes)) {
4199
+ errors.push(jsonPathRes.error);
3709
4200
  patchHadError = true;
3710
4201
  break;
3711
4202
  }
3712
- // Load BEFORE marking anything deleted: `loadEntryContent` errors
3713
- // on a path that has already been nulled in this commit.
3714
- const contentRes = await loadEntryContent(fromPathRes.value);
4203
+ const jsonPath = jsonPathRes.value;
4204
+ const contentRes = await loadEntryContent(jsonPath);
3715
4205
  if (result.isErr(contentRes)) {
3716
4206
  errors.push(contentRes.error);
3717
4207
  patchHadError = true;
3718
4208
  break;
3719
4209
  }
3720
- const content = deepClone(contentRes.value);
3721
- if (op.op === "move") {
3722
- const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, fromKey);
3723
- if (result.isErr(remRes)) {
3724
- collectPatchError(remRes.error, patchId, op);
3725
- patchHadError = true;
3726
- break;
3727
- }
3728
- tsSourceFile = remRes.value;
3729
- }
3730
- // LOCKED convention: the destination always uses the generated
3731
- // path, so renaming a hand-placed file relocates it.
3732
- const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
3733
- if (result.isErr(newPathsRes)) {
3734
- errors.push(newPathsRes.error);
4210
+ const rebasedRes = rebaseContentOp(op, cls.recordPath.length + 1);
4211
+ if (result.isErr(rebasedRes)) {
4212
+ errors.push({
4213
+ message: rebasedRes.error.message,
4214
+ filePath: jsonPath
4215
+ });
3735
4216
  patchHadError = true;
3736
4217
  break;
3737
4218
  }
3738
- const {
3739
- jsonPath,
3740
- importPath
3741
- } = newPathsRes.value;
3742
- const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
3743
- if (result.isErr(insRes)) {
3744
- collectPatchError(insRes.error, patchId, op);
4219
+ const applied = applyPatch(deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
4220
+ if (result.isErr(applied)) {
4221
+ collectPatchError(applied.error, patchId, op);
3745
4222
  patchHadError = true;
3746
4223
  break;
3747
4224
  }
3748
- tsSourceFile = insRes.value;
3749
- tsChanged = true;
3750
- jsonEntryContents.set(jsonPath, content);
3751
- jsonEntryContentsByKey.set(cls.entryKey, content);
3752
- entryKeyToJsonPath.set(cls.entryKey, jsonPath);
3753
- if (op.op === "move" && fromPathRes.value !== jsonPath) {
3754
- jsonEntryContents.set(fromPathRes.value, null);
3755
- jsonEntryContentsByKey.set(fromKey, null);
3756
- }
3757
- } else {
3758
- errors.push({
3759
- message: `Unsupported op '${op.op}' on jsonValues entry '${cls.entryKey}' (supported: add, remove, replace, move, copy)`,
3760
- filePath: path
3761
- });
3762
- patchHadError = true;
3763
- break;
3764
- }
3765
- } else {
3766
- // Content sub-op: replay against the entry's `*.val.json`.
3767
- // `rebaseContentOp` slices `from` by the same prefix as `path`, so a
3768
- // cross-entry move/copy would silently corrupt the target entry.
3769
- if ((op.op === "move" || op.op === "copy") && (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey)) {
3770
- errors.push({
3771
- message: `Cannot '${op.op}' between different jsonValues entries`,
3772
- filePath: path
3773
- });
3774
- patchHadError = true;
3775
- break;
3776
- }
3777
- const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3778
- if (result.isErr(jsonPathRes)) {
3779
- errors.push(jsonPathRes.error);
3780
- patchHadError = true;
3781
- break;
3782
- }
3783
- const jsonPath = jsonPathRes.value;
3784
- const contentRes = await loadEntryContent(jsonPath);
3785
- if (result.isErr(contentRes)) {
3786
- errors.push(contentRes.error);
3787
- patchHadError = true;
3788
- break;
4225
+ jsonEntryContents.set(jsonPath, applied.value);
4226
+ jsonEntryContentsByKey.set(cls.entryKey, applied.value);
3789
4227
  }
3790
- const rebasedRes = rebaseContentOp(op, cls.recordPath.length + 1);
3791
- if (result.isErr(rebasedRes)) {
3792
- errors.push({
3793
- message: rebasedRes.error.message,
3794
- filePath: jsonPath
3795
- });
3796
- patchHadError = true;
3797
- break;
3798
- }
3799
- const applied = applyPatch(deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
3800
- if (result.isErr(applied)) {
3801
- collectPatchError(applied.error, patchId, op);
3802
- patchHadError = true;
3803
- break;
3804
- }
3805
- jsonEntryContents.set(jsonPath, applied.value);
3806
- jsonEntryContentsByKey.set(cls.entryKey, applied.value);
4228
+ }
4229
+ if (patchHadError) {
4230
+ break;
3807
4231
  }
3808
4232
  }
3809
4233
  if (patchHadError) {
@@ -7136,6 +7560,18 @@ const SavePatchFileResponse = z.object({
7136
7560
  const CommitResponse = z.object({
7137
7561
  updatedFiles: z.array(z.string()),
7138
7562
  commit: CommitSha,
7563
+ /*
7564
+ * Optional because a content service that predates them sends neither, and
7565
+ * this client talks to whichever one the project is on. Absent means NOT
7566
+ * REPORTED: `parent: undefined` is not "a root commit", and a caller that
7567
+ * reads it as one would conclude the history starts here.
7568
+ *
7569
+ * Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
7570
+ * from a service that versions separately and are passed through as what it
7571
+ * said, not as something this end vouched for.
7572
+ */
7573
+ parent: z.string().optional(),
7574
+ tree: z.string().optional(),
7139
7575
  branch: z.string()
7140
7576
  });
7141
7577
  // #region history wire schemas
@@ -8463,7 +8899,19 @@ class ValOpsHttp extends ValOps {
8463
8899
  return {
8464
8900
  updatedFiles: parsed.data.updatedFiles,
8465
8901
  commit: parsed.data.commit,
8466
- branch: parsed.data.branch
8902
+ branch: parsed.data.branch,
8903
+ /*
8904
+ * Spread rather than set, so a service that did not report them
8905
+ * leaves the keys ABSENT rather than present-and-undefined. A
8906
+ * caller doing `'parent' in result` then gets the truthful answer,
8907
+ * and JSON round-trips of this object do not grow null fields.
8908
+ */
8909
+ ...(parsed.data.parent !== undefined ? {
8910
+ parent: parsed.data.parent
8911
+ } : {}),
8912
+ ...(parsed.data.tree !== undefined ? {
8913
+ tree: parsed.data.tree
8914
+ } : {})
8467
8915
  };
8468
8916
  }
8469
8917
  return {
@@ -10017,6 +10465,67 @@ function parsePersonalAccessTokenFile(content) {
10017
10465
 
10018
10466
  const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
10019
10467
 
10468
+ /**
10469
+ * The value of `VAL_ENV` that means "this is the Val app".
10470
+ *
10471
+ * The Val app builds a project in a browser and runs it in a Worker isolate.
10472
+ * There is no disk there and there never will be, so `fs` mode is never the
10473
+ * right fall-through -- and the content is Val's own, reached over HTTP at a
10474
+ * commit, exactly as it is for any other deployed app. So this names `http`.
10475
+ *
10476
+ * What makes the app unusual is not where its content comes from but what
10477
+ * publishing means: the browser rebuilds the site and the new build is served
10478
+ * immediately, instead of a host noticing a commit and redeploying. That is a
10479
+ * difference in what happens AFTER the commit, and `publishOverride` is where
10480
+ * a host says so -- not a difference in where patches, files or sources live.
10481
+ *
10482
+ * A host says WHERE it runs, which is a fact it knows. Which Val mode that
10483
+ * implies is Val's to derive, and that is the whole reason this exists next to
10484
+ * `VAL_MODE` rather than the platform naming a mode itself: one is a
10485
+ * description of an environment, the other an assertion about Val's internals,
10486
+ * and only the first stays true when the internals move. They have already
10487
+ * moved once -- this meant `memory` while the app kept its own patch store --
10488
+ * and no platform had to be changed to follow.
10489
+ */
10490
+ const VAL_APP_ENV = "app";
10491
+
10492
+ /** Which mode the environment SAYS this is, and which variable said so. */
10493
+
10494
+ /**
10495
+ * `null` is "the environment did not say", which is the normal case.
10496
+ *
10497
+ * The two variables differ in what can be DONE with an answer, and the
10498
+ * difference is whether the environment holds everything the mode needs.
10499
+ * `http` does -- an api key, a secret, a project, a commit and a branch are all
10500
+ * env vars -- so `VAL_ENV=app` SELECTS it, and the checks in
10501
+ * {@link initHandlerOptions} name whichever one is missing. `memory` does not:
10502
+ * it needs the host's own source files, which nothing in an environment can
10503
+ * supply, so `VAL_MODE=memory` can only ever turn a fall-through into an error.
10504
+ */
10505
+ function namedMode() {
10506
+ const declared = process.env.VAL_MODE;
10507
+ /*
10508
+ * An empty value counts as unset, which is what `VAL_MODE=` in a shell or a
10509
+ * CI settings page means. An explicit `VAL_MODE` otherwise wins over
10510
+ * `VAL_ENV`: naming a mode outright says something more specific than naming
10511
+ * an environment does, including when what it names is wrong and has to be
10512
+ * refused.
10513
+ */
10514
+ if (declared !== undefined && declared !== "") {
10515
+ return {
10516
+ mode: declared,
10517
+ from: "VAL_MODE"
10518
+ };
10519
+ }
10520
+ if (process.env.VAL_ENV === VAL_APP_ENV) {
10521
+ return {
10522
+ mode: "http",
10523
+ from: "VAL_ENV"
10524
+ };
10525
+ }
10526
+ return null;
10527
+ }
10528
+
10020
10529
  /**
10021
10530
  * Resolve options plus environment into a concrete {@link ValServerConfig}.
10022
10531
  *
@@ -10070,11 +10579,13 @@ async function initHandlerOptions(route, opts, config) {
10070
10579
  };
10071
10580
  }
10072
10581
  /*
10073
- * `VAL_MODE=memory` says the host MEANT to hold the source, and did not.
10582
+ * The environment saying 'memory' means the host MEANT to hold the source,
10583
+ * and did not. Either variable can say it: `VAL_MODE=memory` outright, or
10584
+ * `VAL_ENV=app`, which names an environment that has no disk.
10074
10585
  *
10075
- * It cannot SELECT memory mode -- nothing in the environment can supply
10586
+ * Neither can SELECT memory mode -- nothing in the environment can supply
10076
10587
  * `sourceFiles`, and a mode turned on without them is a server with no
10077
- * content in it. What it does is turn the fall-through into an error.
10588
+ * content in it. What they do is turn the fall-through into an error.
10078
10589
  *
10079
10590
  * Without it, a host that forgot to pass its source got `fs` mode, and `fs`
10080
10591
  * mode in a Worker isolate reaches for a working tree that is not there: the
@@ -10083,20 +10594,37 @@ async function initHandlerOptions(route, opts, config) {
10083
10594
  * environment that runs Val without a disk can set this once and get a
10084
10595
  * sentence instead.
10085
10596
  */
10086
- const declaredMode = process.env.VAL_MODE;
10087
- if (declaredMode === "memory") {
10088
- throw new Error("VAL_MODE is 'memory', but no `sourceFiles` were given here, so there " + "is no source to serve. Memory mode cannot be turned on by the " + "environment: it needs the project's own source, and only the host " + "that holds it can hand it over. On TanStack Start that is the " + "`sourceFiles` option, passed to `initValServer` AND to " + "`initValContent`, which has a Val server of its own and is " + "configured separately. @valbuild/next has no memory mode yet, so " + "for a Next app this variable is set on an environment Val cannot " + "serve from. Unset " + "VAL_MODE to go back to the inferred mode instead ('http' when " + "VAL_API_KEY and VAL_SECRET are both set, 'fs' otherwise).");
10597
+ const declared = namedMode();
10598
+ if ((declared === null || declared === void 0 ? void 0 : declared.mode) === "memory") {
10599
+ throw new Error("VAL_MODE is 'memory', but no `sourceFiles` were given here, so there " + "is no source to serve. Memory mode cannot be turned on by the " + "environment: it needs the project's own source, and only the host " + "that holds it can hand it over. On TanStack Start that is the " + "`sourceFiles` option, passed to `initValServer` AND to " + "`initValContent`, which has a Val server of its own and is " + "configured separately. @valbuild/next has no memory mode yet, so " + "for a Next app this variable is set on an environment Val cannot " + "serve from. Unset VAL_MODE to go back to the inferred mode instead " + "('http' when VAL_API_KEY and VAL_SECRET are both set, 'fs' " + "otherwise).");
10089
10600
  }
10090
- // An empty value counts as unset, which is what `VAL_MODE=` in a shell or a
10091
- // CI settings page means. Every other value is refused rather than ignored:
10092
- // ignoring `VAL_MODE=memry` would leave the app in `fs` mode, which is the
10093
- // exact failure this variable exists to catch.
10094
- if (declaredMode !== undefined && declaredMode !== "") {
10095
- throw new Error(`VAL_MODE is '${declaredMode}', which is not a mode Val knows. The only ` + "value it accepts is 'memory', which asserts that the host supplies " + "`sourceFiles`. 'fs' and 'http' are inferred rather than named: " + "'http' when VAL_API_KEY and VAL_SECRET are both set, 'fs' otherwise.");
10601
+ /*
10602
+ * Every other value is refused rather than ignored: ignoring `VAL_MODE=memry`
10603
+ * would leave the app in `fs` mode, which is the exact failure this variable
10604
+ * exists to catch.
10605
+ *
10606
+ * `VAL_ENV` is excluded by name rather than by its value happening to pass:
10607
+ * it names 'http', which is selected below, and a reader who sees only
10608
+ * `declared !== null` here would reasonably conclude that 'http' is a
10609
+ * `VAL_MODE` value -- it is not, and the message below says so.
10610
+ */
10611
+ if (declared !== null && declared.from === "VAL_MODE") {
10612
+ throw new Error(`VAL_MODE is '${declared.mode}', which is not a mode Val knows. The only ` + "value it accepts is 'memory', which asserts that the host supplies " + "`sourceFiles`. 'fs' and 'http' are inferred rather than named: " + "'http' when VAL_API_KEY and VAL_SECRET are both set, 'fs' otherwise.");
10096
10613
  }
10097
10614
  const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
10098
10615
  const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
10099
- const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
10616
+ /*
10617
+ * The app's environment selects http mode, rather than leaving it to be
10618
+ * inferred from a credential being present.
10619
+ *
10620
+ * The difference shows when something is MISSING. Inference reads an absent
10621
+ * api key as "not a proxy" and falls through to `fs`, which in an isolate
10622
+ * reaches for a working tree that is not there -- an `EPERM` on
10623
+ * `.val/patches.lock`, several layers below the mistake. Selecting the mode
10624
+ * means the checks below run instead, and each one names what it wanted.
10625
+ */
10626
+ const isAppEnv = (declared === null || declared === void 0 ? void 0 : declared.from) === "VAL_ENV";
10627
+ const isProxyMode = opts.mode === "proxy" || isAppEnv || opts.mode === undefined && (maybeApiKey || maybeValSecret);
10100
10628
  const valEnableRedirectUrl = opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL;
10101
10629
  const valDisableRedirectUrl = opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL;
10102
10630
  const maybeValProject = opts.project || process.env.VAL_PROJECT;
@@ -10108,19 +10636,28 @@ async function initHandlerOptions(route, opts, config) {
10108
10636
  });
10109
10637
  if (isProxyMode) {
10110
10638
  var _opts$versions, _opts$versions2;
10639
+ /*
10640
+ * Why this app is in http mode, in the message that says what is missing.
10641
+ *
10642
+ * "must be set in proxy mode" is a fine sentence for a developer who wrote
10643
+ * `mode: "proxy"` and a poor one for an app that never mentioned a mode:
10644
+ * there, the answer to "why am I in proxy mode?" is a variable set by the
10645
+ * platform, in a file the reader of this error is not looking at.
10646
+ */
10647
+ const because = isAppEnv ? " (VAL_ENV is 'app', which is the Val app: its content is Val's own " + "and is read over HTTP at a commit, so http mode is the mode and " + "these are what it needs)" : "";
10111
10648
  if (!maybeApiKey || !maybeValSecret) {
10112
- throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode");
10649
+ throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode" + because);
10113
10650
  }
10114
10651
  const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
10115
10652
  if (!maybeGitCommit) {
10116
- throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode");
10653
+ throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode" + because);
10117
10654
  }
10118
10655
  const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
10119
10656
  if (!maybeGitBranch) {
10120
- throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode");
10657
+ throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode" + because);
10121
10658
  }
10122
10659
  if (!maybeValProject) {
10123
- throw new Error("Proxy mode does not work unless the 'project' option in val.config is defined or the VAL_PROJECT env var is set.");
10660
+ throw new Error("Proxy mode does not work unless the 'project' option in val.config is defined or the VAL_PROJECT env var is set." + because);
10124
10661
  }
10125
10662
  const coreVersion = (_opts$versions = opts.versions) === null || _opts$versions === void 0 ? void 0 : _opts$versions.core;
10126
10663
  if (!coreVersion) {