@valbuild/server 0.132.0 → 0.133.0

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.
@@ -1131,6 +1131,176 @@ function descend(schema, key) {
1131
1131
  }
1132
1132
  }
1133
1133
 
1134
+ /**
1135
+ * Is this op a write of the WHOLE record at the root of a `.jsonValues()`
1136
+ * module?
1137
+ *
1138
+ * The one op {@link classifyJsonValuesOp} cannot classify: it finds the entry
1139
+ * key by walking the op path, and the root path has no segments to walk, so a
1140
+ * root write reads as `normal` and gets applied to the `.val.ts` - writing
1141
+ * markers over the `c.json(() => import(...))` calls that make the entries load
1142
+ * at all. {@link expandJsonValuesRootOp} turns it into ops that DO name a key,
1143
+ * which everything downstream already handles.
1144
+ *
1145
+ * `add` counts as well as `replace`: at the root both mean "the document is now
1146
+ * this" (see `JSONOps`), so both have to be expanded or the unexpanded one is
1147
+ * the same bug again.
1148
+ *
1149
+ * The value has to BE a record, and that is part of the question rather than a
1150
+ * check inside the expansion: a `.jsonValues()` record can be nullable, and
1151
+ * `null` at the root means the module no longer has a record at all - there are
1152
+ * no entries to write, and it is an ordinary `.val.ts` write, exactly as it was
1153
+ * before any of this existed. Same for any other non-record value: not a
1154
+ * whole-record write, so not this conversion's to route.
1155
+ */
1156
+ function isJsonValuesRootOp(schema, op) {
1157
+ return (op.op === "replace" || op.op === "add") && op.path.length === 0 && isJsonValuesRecord$1(op.value) && schema.type === "record" && schema.jsonValues === true;
1158
+ }
1159
+ function isJsonValuesRecord$1(value) {
1160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1161
+ }
1162
+
1163
+ /**
1164
+ * One entry as it stands right now, for {@link expandJsonValuesRootOp} to
1165
+ * expand against.
1166
+ *
1167
+ * The key set decides add-vs-replace-vs-remove. The content, where the caller
1168
+ * has it, decides whether an op is emitted at all: an entry whose content is
1169
+ * already what the root write says is left alone rather than rewritten, so
1170
+ * putting a module back does not touch every `*.val.json` it did not change.
1171
+ * `undefined` means "this entry exists, content unknown" - which is what the
1172
+ * Studio's draft source has, because the source holds markers - and yields a
1173
+ * `replace`, the safe answer.
1174
+ */
1175
+
1176
+ /**
1177
+ * Fans a whole-record write at a `.jsonValues()` module's root out into ops
1178
+ * that each name an entry key.
1179
+ *
1180
+ * This is THE conversion, and it has exactly one implementation on purpose: the
1181
+ * commit flow (`ValOps.prepare`) and the read side that builds draft content
1182
+ * ({@link applyJsonValuesEntryPatches}) both expand through here, so a draft
1183
+ * cannot show something other than what publishing writes. A second
1184
+ * implementation of the same rule would differ silently, which is the whole
1185
+ * failure mode this exists to prevent.
1186
+ *
1187
+ * The value must be the entries' CONTENT. A module's Source is markers, not
1188
+ * content, so a Source handed over here is refused rather than written: that is
1189
+ * the shape of the original bug (a revert replaying the archived Source), and
1190
+ * it is not recoverable afterwards - the markers replace the entries' content
1191
+ * on disk.
1192
+ */
1193
+ function expandJsonValuesRootOp(op, currentEntries) {
1194
+ const valueRes = jsonValuesRootValue(op);
1195
+ if (fp.result.isErr(valueRes)) {
1196
+ return valueRes;
1197
+ }
1198
+ const value = valueRes.value;
1199
+ const expanded = [];
1200
+ for (const entryKey of Object.keys(value)) {
1201
+ const write = writeForEntry(value, entryKey, currentEntries);
1202
+ if (write !== null) {
1203
+ expanded.push(write);
1204
+ }
1205
+ }
1206
+ for (const entryKey of currentEntries.keys()) {
1207
+ if (!Object.prototype.hasOwnProperty.call(value, entryKey)) {
1208
+ expanded.push({
1209
+ op: "remove",
1210
+ path: [entryKey]
1211
+ });
1212
+ }
1213
+ }
1214
+ return fp.result.ok(expanded);
1215
+ }
1216
+
1217
+ /**
1218
+ * What a whole-record write says about ONE entry: the same rule
1219
+ * {@link expandJsonValuesRootOp} applies to all of them.
1220
+ *
1221
+ * For a reader that is about one entry. Expanding the whole record to find the
1222
+ * one op that names its key costs an op per entry per entry read - the record
1223
+ * squared, on exactly the large records this feature is for. The rule itself is
1224
+ * `writeForEntry`, shared with the expansion above, so the two cannot come to
1225
+ * disagree; only the loop around it differs.
1226
+ *
1227
+ * `null` means the write says nothing about this entry: it holds that content
1228
+ * already, or the record does not name it and it does not exist.
1229
+ */
1230
+ function expandJsonValuesRootOpForKey(op, entryKey, currentEntries) {
1231
+ const valueRes = jsonValuesRootValue(op);
1232
+ if (fp.result.isErr(valueRes)) {
1233
+ return valueRes;
1234
+ }
1235
+ const value = valueRes.value;
1236
+ if (Object.prototype.hasOwnProperty.call(value, entryKey)) {
1237
+ return fp.result.ok(writeForEntry(value, entryKey, currentEntries));
1238
+ }
1239
+ if (currentEntries.has(entryKey)) {
1240
+ return fp.result.ok({
1241
+ op: "remove",
1242
+ path: [entryKey]
1243
+ });
1244
+ }
1245
+ return fp.result.ok(null);
1246
+ }
1247
+
1248
+ /**
1249
+ * The record a whole-record write carries, once it is known to be one.
1250
+ *
1251
+ * Every entry's value is checked here rather than where each one is written:
1252
+ * a marker anywhere in the record means the writer handed over a module's
1253
+ * Source, and the answer to that is to refuse the write, not to write the rest
1254
+ * of it. Both entry points ask, so an entry read reports it too rather than
1255
+ * showing a draft of a patch that publishing will refuse.
1256
+ */
1257
+ function jsonValuesRootValue(op) {
1258
+ if (op.op !== "replace" && op.op !== "add") {
1259
+ return fp.result.err(new patch.PatchError(`Cannot '${op.op}' the root of a .jsonValues() record: only add and replace write the whole record`));
1260
+ }
1261
+ const value = op.value;
1262
+ if (!isJsonValuesRecord$1(value)) {
1263
+ return fp.result.err(new patch.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"));
1264
+ }
1265
+ for (const entryKey of Object.keys(value)) {
1266
+ /*
1267
+ * A marker is what a module's Source holds WHERE THE CONTENT IS NOT: it
1268
+ * says the content is in the entry's own file. Handed back as an entry's
1269
+ * value - which is what replaying an archived Source does - it would
1270
+ * replace that content with the note saying where it used to be, and
1271
+ * nothing downstream could tell the difference.
1272
+ */
1273
+ if (core.Internal.isJson(value[entryKey])) {
1274
+ return fp.result.err(new patch.PatchError(`Cannot write the .jsonValues() entry '${entryKey}' from a '${core.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`));
1275
+ }
1276
+ }
1277
+ return fp.result.ok(value);
1278
+ }
1279
+
1280
+ /** THE rule, for one entry the record names. `null` = nothing to write. */
1281
+ function writeForEntry(value, entryKey, currentEntries) {
1282
+ const content = value[entryKey];
1283
+ if (!currentEntries.has(entryKey)) {
1284
+ return {
1285
+ op: "add",
1286
+ path: [entryKey],
1287
+ value: content
1288
+ };
1289
+ }
1290
+ const current = currentEntries.get(entryKey);
1291
+ if (current !== undefined && patch.deepEqual(current, content)) {
1292
+ // Unchanged: emitting a replace here would rewrite the entry's file with
1293
+ // the bytes it already holds, so putting a module back would show up as a
1294
+ // change to every entry in it.
1295
+ return null;
1296
+ }
1297
+ return {
1298
+ op: "replace",
1299
+ path: [entryKey],
1300
+ value: content
1301
+ };
1302
+ }
1303
+
1134
1304
  /**
1135
1305
  * Finds every `.jsonValues()` record in a module's schema that is NOT the
1136
1306
  * module's root, returning the path to each within the module source.
@@ -1304,88 +1474,110 @@ function applyJsonValuesEntryPatches(args) {
1304
1474
  patch: patch$1
1305
1475
  } of patches) {
1306
1476
  let touched = false;
1307
- for (const op of patch$1) {
1308
- const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1309
- kind: "normal"
1310
- };
1311
- if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1312
- continue;
1477
+ for (const rawOp of patch$1) {
1478
+ /**
1479
+ * A write of the WHOLE record becomes what it says about THIS entry.
1480
+ *
1481
+ * Through the same rule the commit flow expands with, against the same
1482
+ * view of this entry: present or not, and with what content. That is what
1483
+ * makes the draft this produces and the files a publish writes agree -
1484
+ * two expansions of one rule would not.
1485
+ */
1486
+ let ops = [rawOp];
1487
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, rawOp)) {
1488
+ const write = expandJsonValuesRootOpForKey(rawOp, entryKey, new Map(content === undefined ? [] : [[entryKey, content]]));
1489
+ if (fp.result.isErr(write)) {
1490
+ return {
1491
+ kind: "error",
1492
+ message: write.error.message,
1493
+ patchId
1494
+ };
1495
+ }
1496
+ ops = write.value === null ? [] : [write.value];
1313
1497
  }
1314
- touched = true;
1315
- // A file op does not edit the entry's content — its bytes go through the
1316
- // upload pipeline. What the entry needs is the `patch_id` saying those
1317
- // bytes are not committed yet, so `mediaUrl` serves them from the patch
1318
- // directory instead of a `/public` path that holds nothing. Without this a
1319
- // just-uploaded image inside an entry renders broken.
1320
- if (op.op === "file") {
1321
- if (op.value === null || content === undefined) {
1322
- // A delete carries no bytes to point at, and there is nothing to mark
1323
- // on an entry that does not exist.
1498
+ for (const op of ops) {
1499
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1500
+ kind: "normal"
1501
+ };
1502
+ if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1324
1503
  continue;
1325
1504
  }
1326
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [{
1327
- op: "add",
1328
- path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1329
- value: patchId
1330
- }]);
1331
- if (fp.result.isErr(applied)) {
1505
+ touched = true;
1506
+ // A file op does not edit the entry's content — its bytes go through the
1507
+ // upload pipeline. What the entry needs is the `patch_id` saying those
1508
+ // bytes are not committed yet, so `mediaUrl` serves them from the patch
1509
+ // directory instead of a `/public` path that holds nothing. Without this a
1510
+ // just-uploaded image inside an entry renders broken.
1511
+ if (op.op === "file") {
1512
+ if (op.value === null || content === undefined) {
1513
+ // A delete carries no bytes to point at, and there is nothing to mark
1514
+ // on an entry that does not exist.
1515
+ continue;
1516
+ }
1517
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [{
1518
+ op: "add",
1519
+ path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1520
+ value: patchId
1521
+ }]);
1522
+ if (fp.result.isErr(applied)) {
1523
+ return {
1524
+ kind: "error",
1525
+ message: applied.error.message,
1526
+ patchId
1527
+ };
1528
+ }
1529
+ content = applied.value;
1530
+ continue;
1531
+ }
1532
+ if (cls.subPath.length === 0) {
1533
+ if (op.op === "add" || op.op === "replace") {
1534
+ content = op.value;
1535
+ deleted = false;
1536
+ } else if (op.op === "remove") {
1537
+ content = undefined;
1538
+ deleted = true;
1539
+ } else if (op.op === "test") {
1540
+ // An assertion, not a mutation: the content is unchanged either way, and
1541
+ // the commit path is where a failing `test` is reported. Falling through
1542
+ // to the move/copy error below turned a no-op into a permanent load
1543
+ // failure for the whole entry.
1544
+ continue;
1545
+ } else {
1546
+ // move/copy INTO this key: the content comes from the source entry,
1547
+ // which the caller must resolve (it is a different `*.val.json`).
1548
+ return {
1549
+ kind: "error",
1550
+ message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
1551
+ patchId
1552
+ };
1553
+ }
1554
+ continue;
1555
+ }
1556
+ if (content === undefined) {
1332
1557
  return {
1333
1558
  kind: "error",
1334
- message: applied.error.message,
1559
+ message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
1335
1560
  patchId
1336
1561
  };
1337
1562
  }
1338
- content = applied.value;
1339
- continue;
1340
- }
1341
- if (cls.subPath.length === 0) {
1342
- if (op.op === "add" || op.op === "replace") {
1343
- content = op.value;
1344
- deleted = false;
1345
- } else if (op.op === "remove") {
1346
- content = undefined;
1347
- deleted = true;
1348
- } else if (op.op === "test") {
1349
- // An assertion, not a mutation: the content is unchanged either way, and
1350
- // the commit path is where a failing `test` is reported. Falling through
1351
- // to the move/copy error below turned a no-op into a permanent load
1352
- // failure for the whole entry.
1353
- continue;
1354
- } else {
1355
- // move/copy INTO this key: the content comes from the source entry,
1356
- // which the caller must resolve (it is a different `*.val.json`).
1563
+ const rebased = rebaseContentOp(op, cls.recordPath.length + 1);
1564
+ if (fp.result.isErr(rebased)) {
1357
1565
  return {
1358
1566
  kind: "error",
1359
- message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
1567
+ message: rebased.error.message,
1360
1568
  patchId
1361
1569
  };
1362
1570
  }
1363
- continue;
1364
- }
1365
- if (content === undefined) {
1366
- return {
1367
- kind: "error",
1368
- message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
1369
- patchId
1370
- };
1371
- }
1372
- const rebased = rebaseContentOp(op, cls.recordPath.length + 1);
1373
- if (fp.result.isErr(rebased)) {
1374
- return {
1375
- kind: "error",
1376
- message: rebased.error.message,
1377
- patchId
1378
- };
1379
- }
1380
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [rebased.value]);
1381
- if (fp.result.isErr(applied)) {
1382
- return {
1383
- kind: "error",
1384
- message: applied.error.message,
1385
- patchId
1386
- };
1571
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [rebased.value]);
1572
+ if (fp.result.isErr(applied)) {
1573
+ return {
1574
+ kind: "error",
1575
+ message: applied.error.message,
1576
+ patchId
1577
+ };
1578
+ }
1579
+ content = applied.value;
1387
1580
  }
1388
- content = applied.value;
1389
1581
  }
1390
1582
  if (touched) {
1391
1583
  appliedPatchIds.push(patchId);
@@ -1602,6 +1794,34 @@ function analyzeJsonValuesEntries(source) {
1602
1794
  }
1603
1795
  return entries;
1604
1796
  }
1797
+
1798
+ /**
1799
+ * Every key of a `.jsonValues()` record's source literal, thunked or not.
1800
+ *
1801
+ * {@link analyzeJsonValuesEntries} answers "which entries have a file behind
1802
+ * them", which is the question for resolving a path. This is the other one:
1803
+ * which keys the record HAS. They differ on a value that is not a
1804
+ * `c.json(...)` call - written inline, or mid-edit - and telling an existing
1805
+ * key from a new one is what decides whether a whole-record write adds a
1806
+ * `c.json(...)` thunk or replaces one. Using the thunk set for that would
1807
+ * insert a second property with a key the literal already has.
1808
+ */
1809
+ function jsonValuesRecordKeys(source) {
1810
+ const keys = [];
1811
+ if (!ts__default["default"].isObjectLiteralExpression(source)) {
1812
+ return keys;
1813
+ }
1814
+ for (const prop of source.properties) {
1815
+ if (!ts__default["default"].isPropertyAssignment(prop)) {
1816
+ continue;
1817
+ }
1818
+ const key = getPropertyKey(prop.name);
1819
+ if (key !== null) {
1820
+ keys.push(key);
1821
+ }
1822
+ }
1823
+ return keys;
1824
+ }
1605
1825
  function getPropertyKey(name) {
1606
1826
  if (ts__default["default"].isStringLiteralLike(name)) {
1607
1827
  return name.text;
@@ -1908,6 +2128,21 @@ class Service {
1908
2128
  kind: "normal"
1909
2129
  };
1910
2130
  for (const op of patch) {
2131
+ /*
2132
+ * A write of the WHOLE record is refused here, not applied.
2133
+ *
2134
+ * `classifyJsonValuesOp` reads a root path as `normal` - there is no
2135
+ * segment naming an entry - so it would go straight into the `.val.ts`,
2136
+ * replacing the record and the `c.json(() => import(...))` calls that
2137
+ * make its entries load. `ValOps.prepare` expands such a write into
2138
+ * per-entry ops; that needs each entry's file read and written, which
2139
+ * `patchValFile` below does not do. Nothing that reaches here produces
2140
+ * one today (`val validate --fix` only corrects values inside an entry),
2141
+ * so this refuses rather than silently doing the wrong thing.
2142
+ */
2143
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, op)) {
2144
+ throw Error(`Cannot ${op.op} the whole .jsonValues() record of ${moduleFilePath} through Service.patch: its entries are separate files, so write them per entry`);
2145
+ }
1911
2146
  const cls = classify(op.path);
1912
2147
  // `move` and `copy` read a SECOND path, and `rebaseContentOp` slices
1913
2148
  // `from` by the same prefix it slices `path` by. So an op whose two ends
@@ -2348,6 +2583,15 @@ function encodeJwt(payload, sessionKey) {
2348
2583
 
2349
2584
  /* eslint-disable @typescript-eslint/no-unused-vars */
2350
2585
  const jsonOps = new patch.JSONOps();
2586
+ /**
2587
+ * How many `.jsonValues()` entry files a whole-record write reads at a time.
2588
+ *
2589
+ * Reading them is a network round trip per entry in `ValOpsHttp`, and a commit
2590
+ * cannot start until they are in. One at a time is too slow for the records
2591
+ * this is for; all at once is a burst of hundreds of requests at the content
2592
+ * host.
2593
+ */
2594
+ const JSON_ENTRY_READ_CONCURRENCY = 8;
2351
2595
  const tsOps = new TSOps(document => {
2352
2596
  return fp.pipe(analyzeValModule(document), fp.result.map(({
2353
2597
  source
@@ -3059,74 +3303,139 @@ class ValOps {
3059
3303
  // poisoning the rest of this module's patch chain). See the per-op
3060
3304
  // routing below.
3061
3305
  const serializedSchema = jsonValuesSchemaFor(path);
3062
- for (const op of patchData.patch) {
3063
- if (op.op === "file") {
3064
- // A file op inside a `.jsonValues()` entry has nothing to inject
3065
- // HERE: the entry is an opaque marker in the module source, so an
3066
- // `add` reaching into it fails and poisons the rest of this
3067
- // module's chain. `applyJsonValuesEntryPatches` writes the patch_id
3068
- // into the entry's draft content instead.
3069
- const fileCls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3070
- kind: "normal"
3071
- };
3072
- if (fileCls.kind === "entry" && fileCls.subPath.length > 0) {
3073
- continue;
3074
- }
3075
- if (op.value !== null) {
3076
- // NOTE: We insert the last patch_id that modify a file
3077
- // when constructing the url we use the patch id (and the file path)
3078
- // to fetch the right file
3079
- // NOTE: overwrite and use last patch_id if multiple patches modify the same file
3080
- fileFixOps[op.path.join("/")] = [{
3081
- op: "add",
3082
- path: op.path.concat(...(op.nestedFilePath || [])).concat("patch_id"),
3083
- value: patchId
3084
- }];
3306
+ /**
3307
+ * The record's keys as they stand while this patch is walked.
3308
+ *
3309
+ * Only the key set: the module source holds markers, so there is no
3310
+ * content here to compare a whole-record write against. Seeded from the
3311
+ * source and kept current as the ops below add and remove entries,
3312
+ * because `patchedSources` is only written once the whole patch has
3313
+ * been applied.
3314
+ */
3315
+ let entryKeys = null;
3316
+ const currentEntryKeys = () => {
3317
+ if (entryKeys === null) {
3318
+ entryKeys = new Map();
3319
+ const source = patchedSources[path];
3320
+ if (typeof source === "object" && source !== null && !Array.isArray(source)) {
3321
+ for (const key of Object.keys(source)) {
3322
+ entryKeys.set(key, undefined);
3323
+ }
3085
3324
  }
3086
- // null value = delete: no patch_id to inject; the "remove" op in
3087
- // the patch already removes the metadata entry from the source
3088
- } else {
3089
- const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3090
- kind: "normal"
3091
- };
3092
- if (cls.kind === "normal") {
3093
- applicableOps.push(op);
3094
- } else if (cls.subPath.length > 0) ; else if (op.op === "add" || op.op === "replace") {
3095
- // Whole-entry add/replace: keep the record's KEY SET correct for
3096
- // drafts by writing the marker rather than the content. Record
3097
- // validation only asserts `isJson`, and
3098
- // `validateJsonValuesEntries` skips thunkless markers by design.
3099
- applicableOps.push({
3100
- op: op.op,
3101
- path: op.path,
3102
- value: {
3103
- [core.VAL_EXTENSION]: "json",
3104
- patch_id: patchId
3105
- }
3325
+ }
3326
+ return entryKeys;
3327
+ };
3328
+ /**
3329
+ * Set when a whole-record write could not be expanded.
3330
+ *
3331
+ * Nothing of this patch is applied then: the ops before the failure are
3332
+ * half of an edit, and half of a record write is a record missing
3333
+ * entries.
3334
+ */
3335
+ let unexpandable = false;
3336
+ for (const rawOp of patchData.patch) {
3337
+ /**
3338
+ * A write of the WHOLE record fans out into per-entry ops first,
3339
+ * through the same expansion the commit flow and the entry read path
3340
+ * use. Left as it is, a root write would put the value straight into
3341
+ * the module source - where the entries are markers, so the record
3342
+ * would hold content the Studio then tries to load as entries.
3343
+ */
3344
+ let ops = [rawOp];
3345
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, rawOp)) {
3346
+ const expandedRes = expandJsonValuesRootOp(rawOp, currentEntryKeys());
3347
+ if (fp.result.isErr(expandedRes)) {
3348
+ if (!errors[path]) {
3349
+ errors[path] = [];
3350
+ }
3351
+ errors[path].push({
3352
+ patchId,
3353
+ skipped: false,
3354
+ error: expandedRes.error
3106
3355
  });
3107
- } else if (op.op === "remove") {
3108
- applicableOps.push(op);
3356
+ unexpandable = true;
3357
+ break;
3358
+ }
3359
+ ops = expandedRes.value;
3360
+ }
3361
+ for (const op of ops) {
3362
+ if (op.op === "file") {
3363
+ // A file op inside a `.jsonValues()` entry has nothing to inject
3364
+ // HERE: the entry is an opaque marker in the module source, so an
3365
+ // `add` reaching into it fails and poisons the rest of this
3366
+ // module's chain. `applyJsonValuesEntryPatches` writes the patch_id
3367
+ // into the entry's draft content instead.
3368
+ const fileCls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3369
+ kind: "normal"
3370
+ };
3371
+ if (fileCls.kind === "entry" && fileCls.subPath.length > 0) {
3372
+ continue;
3373
+ }
3374
+ if (op.value !== null) {
3375
+ // NOTE: We insert the last patch_id that modify a file
3376
+ // when constructing the url we use the patch id (and the file path)
3377
+ // to fetch the right file
3378
+ // NOTE: overwrite and use last patch_id if multiple patches modify the same file
3379
+ fileFixOps[op.path.join("/")] = [{
3380
+ op: "add",
3381
+ path: op.path.concat(...(op.nestedFilePath || [])).concat("patch_id"),
3382
+ value: patchId
3383
+ }];
3384
+ }
3385
+ // null value = delete: no patch_id to inject; the "remove" op in
3386
+ // the patch already removes the metadata entry from the source
3109
3387
  } else {
3110
- // move/copy of a whole entry: the destination key must appear, and
3111
- // for a move the source key must disappear. Both are key-set
3112
- // changes we can express with markers.
3113
- applicableOps.push({
3114
- op: "add",
3115
- path: op.path,
3116
- value: {
3117
- [core.VAL_EXTENSION]: "json",
3118
- patch_id: patchId
3119
- }
3120
- });
3121
- if (op.op === "move" && fp.array.isNonEmpty(op.from)) {
3388
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3389
+ kind: "normal"
3390
+ };
3391
+ if (cls.kind === "normal") {
3392
+ applicableOps.push(op);
3393
+ } else if (cls.subPath.length > 0) ; else if (op.op === "add" || op.op === "replace") {
3394
+ // Whole-entry add/replace: keep the record's KEY SET correct for
3395
+ // drafts by writing the marker rather than the content. Record
3396
+ // validation only asserts `isJson`, and
3397
+ // `validateJsonValuesEntries` skips thunkless markers by design.
3122
3398
  applicableOps.push({
3123
- op: "remove",
3124
- path: op.from
3399
+ op: op.op,
3400
+ path: op.path,
3401
+ value: {
3402
+ [core.VAL_EXTENSION]: "json",
3403
+ patch_id: patchId
3404
+ }
3125
3405
  });
3406
+ currentEntryKeys().set(cls.entryKey, undefined);
3407
+ } else if (op.op === "remove") {
3408
+ applicableOps.push(op);
3409
+ currentEntryKeys().delete(cls.entryKey);
3410
+ } else {
3411
+ // move/copy of a whole entry: the destination key must appear, and
3412
+ // for a move the source key must disappear. Both are key-set
3413
+ // changes we can express with markers.
3414
+ applicableOps.push({
3415
+ op: "add",
3416
+ path: op.path,
3417
+ value: {
3418
+ [core.VAL_EXTENSION]: "json",
3419
+ patch_id: patchId
3420
+ }
3421
+ });
3422
+ currentEntryKeys().set(cls.entryKey, undefined);
3423
+ if (op.op === "move" && fp.array.isNonEmpty(op.from)) {
3424
+ applicableOps.push({
3425
+ op: "remove",
3426
+ path: op.from
3427
+ });
3428
+ if (op.from.length === 1) {
3429
+ currentEntryKeys().delete(op.from[0]);
3430
+ }
3431
+ }
3126
3432
  }
3127
3433
  }
3128
3434
  }
3129
3435
  }
3436
+ if (unexpandable) {
3437
+ continue;
3438
+ }
3130
3439
  const patchRes = patch.applyPatch(patch.deepClone(patchedSources[path]),
3131
3440
  // 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?
3132
3441
  jsonOps, applicableOps.concat(...Object.values(fileFixOps)));
@@ -3539,22 +3848,34 @@ class ValOps {
3539
3848
  // content ops in the same commit resolve to the freshly-created file.
3540
3849
  const entryKeyToJsonPath = new Map();
3541
3850
 
3542
- // Lazily analyzed `c.json(() => import("..."))` entries of the ORIGINAL
3543
- // `.val.ts` (import paths are authoritative for existing/hand-placed files).
3544
- let analyzerEntries = null;
3851
+ // Lazily analyzed entries of the ORIGINAL `.val.ts`: the
3852
+ // `c.json(() => import("..."))` thunks (import paths are authoritative
3853
+ // for existing/hand-placed files) and the record's whole key set, which
3854
+ // is what a write of the whole record is expanded against.
3855
+ let analyzed = null;
3856
+ const analyzeEntries = () => {
3857
+ if (analyzed === null) {
3858
+ const analysis = analyzeValModule(originalSourceFile);
3859
+ if (fp.result.isErr(analysis)) {
3860
+ return fp.result.err(analysis.error);
3861
+ }
3862
+ analyzed = {
3863
+ thunks: analyzeJsonValuesEntries(analysis.value.source),
3864
+ keys: jsonValuesRecordKeys(analysis.value.source)
3865
+ };
3866
+ }
3867
+ return fp.result.ok(analyzed);
3868
+ };
3545
3869
  const resolveEntryJsonPath = entryKey => {
3546
3870
  const added = entryKeyToJsonPath.get(entryKey);
3547
3871
  if (added !== undefined) {
3548
3872
  return fp.result.ok(added);
3549
3873
  }
3550
- if (analyzerEntries === null) {
3551
- const analysis = analyzeValModule(originalSourceFile);
3552
- if (fp.result.isErr(analysis)) {
3553
- return fp.result.err(analysis.error);
3554
- }
3555
- analyzerEntries = analyzeJsonValuesEntries(analysis.value.source);
3874
+ const analyzedRes = analyzeEntries();
3875
+ if (fp.result.isErr(analyzedRes)) {
3876
+ return analyzedRes;
3556
3877
  }
3557
- const entry = analyzerEntries.get(entryKey);
3878
+ const entry = analyzedRes.value.thunks.get(entryKey);
3558
3879
  if (!entry) {
3559
3880
  return fp.result.err({
3560
3881
  message: `Could not find jsonValues entry '${entryKey}' in ${path}`,
@@ -3592,6 +3913,73 @@ class ValOps {
3592
3913
  });
3593
3914
  }
3594
3915
  };
3916
+ /**
3917
+ * Every entry the module has AS THINGS STAND, with its content.
3918
+ *
3919
+ * What {@link expandJsonValuesRootOp} expands a write of the whole record
3920
+ * against. The keys come from the `.val.ts` the commit started from plus
3921
+ * what this commit has already added or removed; the content comes from
3922
+ * each entry's file, so an entry the write leaves as it is gets no op and
3923
+ * its file is not rewritten.
3924
+ *
3925
+ * Content is read WITHOUT going through `loadEntryContent`, whose cache is
3926
+ * also the write set: everything in it is written back at the flush, so
3927
+ * reading 200 entries to compare them would rewrite all 200. Unreadable
3928
+ * here means "content unknown", not an error - the expansion then emits a
3929
+ * `replace`, which is what a module with a broken entry file needs anyway.
3930
+ */
3931
+ const currentJsonEntries = async () => {
3932
+ const analyzedRes = analyzeEntries();
3933
+ if (fp.result.isErr(analyzedRes)) {
3934
+ return analyzedRes;
3935
+ }
3936
+ const keys = new Set(analyzedRes.value.keys);
3937
+ for (const [entryKey, content] of jsonEntryContentsByKey) {
3938
+ if (content === null) {
3939
+ keys.delete(entryKey);
3940
+ } else {
3941
+ keys.add(entryKey);
3942
+ }
3943
+ }
3944
+ const readEntry = async entryKey => {
3945
+ const pending = jsonEntryContentsByKey.get(entryKey);
3946
+ if (pending !== undefined && pending !== null) {
3947
+ return pending;
3948
+ }
3949
+ const jsonPathRes = resolveEntryJsonPath(entryKey);
3950
+ if (fp.result.isErr(jsonPathRes)) {
3951
+ return undefined;
3952
+ }
3953
+ const res = await this.getSourceFile(jsonPathRes.value);
3954
+ if (res.error) {
3955
+ return undefined;
3956
+ }
3957
+ try {
3958
+ const parsed = JSON.parse(res.data);
3959
+ return parsed;
3960
+ } catch {
3961
+ return undefined;
3962
+ }
3963
+ };
3964
+ /*
3965
+ * Read in batches rather than one after another: `getSourceFile` is a
3966
+ * network round trip in `ValOpsHttp`, so a record with hundreds of
3967
+ * entries would otherwise spend hundreds of them in series before the
3968
+ * commit could even start - long enough to time out. The keys are
3969
+ * walked in order and the results assembled in that order, so what the
3970
+ * expansion sees does not depend on which read finished first.
3971
+ */
3972
+ const entries = new Map();
3973
+ const entryKeys = Array.from(keys);
3974
+ for (let i = 0; i < entryKeys.length; i += JSON_ENTRY_READ_CONCURRENCY) {
3975
+ const batch = entryKeys.slice(i, i + JSON_ENTRY_READ_CONCURRENCY);
3976
+ const read = await Promise.all(batch.map(readEntry));
3977
+ for (let j = 0; j < batch.length; j++) {
3978
+ entries.set(batch[j], read[j]);
3979
+ }
3980
+ }
3981
+ return fp.result.ok(entries);
3982
+ };
3595
3983
  const collectPatchError = (err, patchId, op) => {
3596
3984
  console.error("Could not patch", JSON.stringify({
3597
3985
  path,
@@ -3632,212 +4020,248 @@ class ValOps {
3632
4020
  // Where this patch's errors start, so the unappliable-patch report below
3633
4021
  // can name what went wrong rather than just that something did.
3634
4022
  const errorsBefore = errors.length;
3635
- for (const op of sourceFileOps) {
3636
- const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
3637
- kind: "normal"
3638
- };
3639
- // `move` / `copy` also READ from a path: classify that too, so an op
3640
- // that moves a value out of (or into) a jsonValues entry cannot slip
3641
- // through as a plain `.val.ts` op.
3642
- const fromCls = serializedSchema && (op.op === "move" || op.op === "copy") ? classifyJsonValuesOp(serializedSchema, op.from) : {
3643
- kind: "normal"
3644
- };
3645
- if (cls.kind === "normal" && fromCls.kind === "normal") {
3646
- const patchRes = patch.applyPatch(tsSourceFile, tsOps, [op]);
3647
- if (fp.result.isErr(patchRes)) {
3648
- collectPatchError(patchRes.error, patchId, op);
4023
+ for (const rawOp of sourceFileOps) {
4024
+ /**
4025
+ * A write of the WHOLE record fans out into per-entry ops first.
4026
+ *
4027
+ * The only op `classifyJsonValuesOp` cannot route, because it finds
4028
+ * the entry key by walking the op path and the root path has none. So
4029
+ * a patch author never has to know whether a record is
4030
+ * `.jsonValues()`: they write the module as if it were ordinary
4031
+ * source, and it arrives here as adds, replaces and removes that name
4032
+ * a key - the shapes everything below already handles. The read side
4033
+ * expands through the same function, so a draft cannot show something
4034
+ * other than what this writes.
4035
+ */
4036
+ let ops = [rawOp];
4037
+ if (serializedSchema && isJsonValuesRootOp(serializedSchema, rawOp)) {
4038
+ const currentRes = await currentJsonEntries();
4039
+ if (fp.result.isErr(currentRes)) {
4040
+ errors.push(currentRes.error);
3649
4041
  patchHadError = true;
3650
4042
  break;
3651
4043
  }
3652
- tsSourceFile = patchRes.value;
3653
- tsChanged = true;
3654
- continue;
3655
- }
3656
- if (cls.kind === "normal") {
3657
- errors.push({
3658
- message: `Cannot '${op.op}' a value out of a jsonValues entry and into the module source`,
3659
- filePath: path
3660
- });
3661
- patchHadError = true;
3662
- break;
3663
- }
3664
- // Nested `.jsonValues()` records are not supported: only the read path
3665
- // for a module's ROOT record/router is implemented end to end. This is
3666
- // also rejected up front in `initSources`; this is defense in depth.
3667
- if (cls.recordPath.length > 0 || fromCls.kind === "entry" && fromCls.recordPath.length > 0) {
3668
- errors.push({
3669
- message: `Nested .jsonValues() records are not supported: '${cls.recordPath.join(".")}' in ${path}. Use .jsonValues() only on a module's root record/router.`,
3670
- filePath: path
3671
- });
3672
- patchHadError = true;
3673
- break;
4044
+ const expandedRes = expandJsonValuesRootOp(rawOp, currentRes.value);
4045
+ if (fp.result.isErr(expandedRes)) {
4046
+ errors.push({
4047
+ message: expandedRes.error.message,
4048
+ filePath: path
4049
+ });
4050
+ patchHadError = true;
4051
+ break;
4052
+ }
4053
+ ops = expandedRes.value;
3674
4054
  }
3675
- // The op targets a `.jsonValues()` entry.
3676
- if (cls.subPath.length === 0) {
3677
- // Structural / whole-entry op.
3678
- if (op.op === "add") {
3679
- const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
3680
- if (fp.result.isErr(newPathsRes)) {
3681
- errors.push(newPathsRes.error);
3682
- patchHadError = true;
3683
- break;
3684
- }
3685
- const {
3686
- jsonPath,
3687
- importPath
3688
- } = newPathsRes.value;
3689
- const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
3690
- if (fp.result.isErr(insRes)) {
3691
- collectPatchError(insRes.error, patchId, op);
3692
- patchHadError = true;
3693
- break;
3694
- }
3695
- tsSourceFile = insRes.value;
3696
- tsChanged = true;
3697
- jsonEntryContents.set(jsonPath, op.value);
3698
- jsonEntryContentsByKey.set(cls.entryKey, op.value);
3699
- entryKeyToJsonPath.set(cls.entryKey, jsonPath);
3700
- } else if (op.op === "remove") {
3701
- const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3702
- if (fp.result.isErr(jsonPathRes)) {
3703
- errors.push(jsonPathRes.error);
3704
- patchHadError = true;
3705
- break;
3706
- }
3707
- const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey);
3708
- if (fp.result.isErr(remRes)) {
3709
- collectPatchError(remRes.error, patchId, op);
4055
+ for (const op of ops) {
4056
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
4057
+ kind: "normal"
4058
+ };
4059
+ // `move` / `copy` also READ from a path: classify that too, so an op
4060
+ // that moves a value out of (or into) a jsonValues entry cannot slip
4061
+ // through as a plain `.val.ts` op.
4062
+ const fromCls = serializedSchema && (op.op === "move" || op.op === "copy") ? classifyJsonValuesOp(serializedSchema, op.from) : {
4063
+ kind: "normal"
4064
+ };
4065
+ if (cls.kind === "normal" && fromCls.kind === "normal") {
4066
+ const patchRes = patch.applyPatch(tsSourceFile, tsOps, [op]);
4067
+ if (fp.result.isErr(patchRes)) {
4068
+ collectPatchError(patchRes.error, patchId, op);
3710
4069
  patchHadError = true;
3711
4070
  break;
3712
4071
  }
3713
- tsSourceFile = remRes.value;
4072
+ tsSourceFile = patchRes.value;
3714
4073
  tsChanged = true;
3715
- jsonEntryContents.set(jsonPathRes.value, null);
3716
- jsonEntryContentsByKey.set(cls.entryKey, null);
3717
- } else if (op.op === "replace") {
3718
- const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3719
- if (fp.result.isErr(jsonPathRes)) {
3720
- errors.push(jsonPathRes.error);
4074
+ continue;
4075
+ }
4076
+ if (cls.kind === "normal") {
4077
+ errors.push({
4078
+ message: `Cannot '${op.op}' a value out of a jsonValues entry and into the module source`,
4079
+ filePath: path
4080
+ });
4081
+ patchHadError = true;
4082
+ break;
4083
+ }
4084
+ // Nested `.jsonValues()` records are not supported: only the read path
4085
+ // for a module's ROOT record/router is implemented end to end. This is
4086
+ // also rejected up front in `initSources`; this is defense in depth.
4087
+ if (cls.recordPath.length > 0 || fromCls.kind === "entry" && fromCls.recordPath.length > 0) {
4088
+ errors.push({
4089
+ message: `Nested .jsonValues() records are not supported: '${cls.recordPath.join(".")}' in ${path}. Use .jsonValues() only on a module's root record/router.`,
4090
+ filePath: path
4091
+ });
4092
+ patchHadError = true;
4093
+ break;
4094
+ }
4095
+ // The op targets a `.jsonValues()` entry.
4096
+ if (cls.subPath.length === 0) {
4097
+ // Structural / whole-entry op.
4098
+ if (op.op === "add") {
4099
+ const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
4100
+ if (fp.result.isErr(newPathsRes)) {
4101
+ errors.push(newPathsRes.error);
4102
+ patchHadError = true;
4103
+ break;
4104
+ }
4105
+ const {
4106
+ jsonPath,
4107
+ importPath
4108
+ } = newPathsRes.value;
4109
+ const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
4110
+ if (fp.result.isErr(insRes)) {
4111
+ collectPatchError(insRes.error, patchId, op);
4112
+ patchHadError = true;
4113
+ break;
4114
+ }
4115
+ tsSourceFile = insRes.value;
4116
+ tsChanged = true;
4117
+ jsonEntryContents.set(jsonPath, op.value);
4118
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
4119
+ entryKeyToJsonPath.set(cls.entryKey, jsonPath);
4120
+ } else if (op.op === "remove") {
4121
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
4122
+ if (fp.result.isErr(jsonPathRes)) {
4123
+ errors.push(jsonPathRes.error);
4124
+ patchHadError = true;
4125
+ break;
4126
+ }
4127
+ const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey);
4128
+ if (fp.result.isErr(remRes)) {
4129
+ collectPatchError(remRes.error, patchId, op);
4130
+ patchHadError = true;
4131
+ break;
4132
+ }
4133
+ tsSourceFile = remRes.value;
4134
+ tsChanged = true;
4135
+ jsonEntryContents.set(jsonPathRes.value, null);
4136
+ jsonEntryContentsByKey.set(cls.entryKey, null);
4137
+ } else if (op.op === "replace") {
4138
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
4139
+ if (fp.result.isErr(jsonPathRes)) {
4140
+ errors.push(jsonPathRes.error);
4141
+ patchHadError = true;
4142
+ break;
4143
+ }
4144
+ jsonEntryContents.set(jsonPathRes.value, op.value);
4145
+ jsonEntryContentsByKey.set(cls.entryKey, op.value);
4146
+ } else if (op.op === "move" || op.op === "copy") {
4147
+ // Rename (move) or duplicate (copy) a whole entry. The new entry
4148
+ // gets its own `*.val.json` written with the source entry's
4149
+ // content plus a `c.json(...)` thunk; a move additionally drops
4150
+ // the old thunk and deletes the old file.
4151
+ if (fromCls.kind !== "entry" || fromCls.subPath.length !== 0 || fromCls.recordPath.join("\0") !== cls.recordPath.join("\0")) {
4152
+ errors.push({
4153
+ message: `Cannot '${op.op}' a jsonValues entry across records or from a non-entry path (from '${op.from.join(".")}' to '${op.path.join(".")}')`,
4154
+ filePath: path
4155
+ });
4156
+ patchHadError = true;
4157
+ break;
4158
+ }
4159
+ const fromKey = fromCls.entryKey;
4160
+ const fromPathRes = resolveEntryJsonPath(fromKey);
4161
+ if (fp.result.isErr(fromPathRes)) {
4162
+ errors.push(fromPathRes.error);
4163
+ patchHadError = true;
4164
+ break;
4165
+ }
4166
+ // Load BEFORE marking anything deleted: `loadEntryContent` errors
4167
+ // on a path that has already been nulled in this commit.
4168
+ const contentRes = await loadEntryContent(fromPathRes.value);
4169
+ if (fp.result.isErr(contentRes)) {
4170
+ errors.push(contentRes.error);
4171
+ patchHadError = true;
4172
+ break;
4173
+ }
4174
+ const content = patch.deepClone(contentRes.value);
4175
+ if (op.op === "move") {
4176
+ const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, fromKey);
4177
+ if (fp.result.isErr(remRes)) {
4178
+ collectPatchError(remRes.error, patchId, op);
4179
+ patchHadError = true;
4180
+ break;
4181
+ }
4182
+ tsSourceFile = remRes.value;
4183
+ }
4184
+ // LOCKED convention: the destination always uses the generated
4185
+ // path, so renaming a hand-placed file relocates it.
4186
+ const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
4187
+ if (fp.result.isErr(newPathsRes)) {
4188
+ errors.push(newPathsRes.error);
4189
+ patchHadError = true;
4190
+ break;
4191
+ }
4192
+ const {
4193
+ jsonPath,
4194
+ importPath
4195
+ } = newPathsRes.value;
4196
+ const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
4197
+ if (fp.result.isErr(insRes)) {
4198
+ collectPatchError(insRes.error, patchId, op);
4199
+ patchHadError = true;
4200
+ break;
4201
+ }
4202
+ tsSourceFile = insRes.value;
4203
+ tsChanged = true;
4204
+ jsonEntryContents.set(jsonPath, content);
4205
+ jsonEntryContentsByKey.set(cls.entryKey, content);
4206
+ entryKeyToJsonPath.set(cls.entryKey, jsonPath);
4207
+ if (op.op === "move" && fromPathRes.value !== jsonPath) {
4208
+ jsonEntryContents.set(fromPathRes.value, null);
4209
+ jsonEntryContentsByKey.set(fromKey, null);
4210
+ }
4211
+ } else {
4212
+ errors.push({
4213
+ message: `Unsupported op '${op.op}' on jsonValues entry '${cls.entryKey}' (supported: add, remove, replace, move, copy)`,
4214
+ filePath: path
4215
+ });
3721
4216
  patchHadError = true;
3722
4217
  break;
3723
4218
  }
3724
- jsonEntryContents.set(jsonPathRes.value, op.value);
3725
- jsonEntryContentsByKey.set(cls.entryKey, op.value);
3726
- } else if (op.op === "move" || op.op === "copy") {
3727
- // Rename (move) or duplicate (copy) a whole entry. The new entry
3728
- // gets its own `*.val.json` written with the source entry's
3729
- // content plus a `c.json(...)` thunk; a move additionally drops
3730
- // the old thunk and deletes the old file.
3731
- if (fromCls.kind !== "entry" || fromCls.subPath.length !== 0 || fromCls.recordPath.join("\0") !== cls.recordPath.join("\0")) {
4219
+ } else {
4220
+ // Content sub-op: replay against the entry's `*.val.json`.
4221
+ // `rebaseContentOp` slices `from` by the same prefix as `path`, so a
4222
+ // cross-entry move/copy would silently corrupt the target entry.
4223
+ if ((op.op === "move" || op.op === "copy") && (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey)) {
3732
4224
  errors.push({
3733
- message: `Cannot '${op.op}' a jsonValues entry across records or from a non-entry path (from '${op.from.join(".")}' to '${op.path.join(".")}')`,
4225
+ message: `Cannot '${op.op}' between different jsonValues entries`,
3734
4226
  filePath: path
3735
4227
  });
3736
4228
  patchHadError = true;
3737
4229
  break;
3738
4230
  }
3739
- const fromKey = fromCls.entryKey;
3740
- const fromPathRes = resolveEntryJsonPath(fromKey);
3741
- if (fp.result.isErr(fromPathRes)) {
3742
- errors.push(fromPathRes.error);
4231
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
4232
+ if (fp.result.isErr(jsonPathRes)) {
4233
+ errors.push(jsonPathRes.error);
3743
4234
  patchHadError = true;
3744
4235
  break;
3745
4236
  }
3746
- // Load BEFORE marking anything deleted: `loadEntryContent` errors
3747
- // on a path that has already been nulled in this commit.
3748
- const contentRes = await loadEntryContent(fromPathRes.value);
4237
+ const jsonPath = jsonPathRes.value;
4238
+ const contentRes = await loadEntryContent(jsonPath);
3749
4239
  if (fp.result.isErr(contentRes)) {
3750
4240
  errors.push(contentRes.error);
3751
4241
  patchHadError = true;
3752
4242
  break;
3753
4243
  }
3754
- const content = patch.deepClone(contentRes.value);
3755
- if (op.op === "move") {
3756
- const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, fromKey);
3757
- if (fp.result.isErr(remRes)) {
3758
- collectPatchError(remRes.error, patchId, op);
3759
- patchHadError = true;
3760
- break;
3761
- }
3762
- tsSourceFile = remRes.value;
3763
- }
3764
- // LOCKED convention: the destination always uses the generated
3765
- // path, so renaming a hand-placed file relocates it.
3766
- const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
3767
- if (fp.result.isErr(newPathsRes)) {
3768
- errors.push(newPathsRes.error);
4244
+ const rebasedRes = rebaseContentOp(op, cls.recordPath.length + 1);
4245
+ if (fp.result.isErr(rebasedRes)) {
4246
+ errors.push({
4247
+ message: rebasedRes.error.message,
4248
+ filePath: jsonPath
4249
+ });
3769
4250
  patchHadError = true;
3770
4251
  break;
3771
4252
  }
3772
- const {
3773
- jsonPath,
3774
- importPath
3775
- } = newPathsRes.value;
3776
- const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
3777
- if (fp.result.isErr(insRes)) {
3778
- collectPatchError(insRes.error, patchId, op);
4253
+ const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
4254
+ if (fp.result.isErr(applied)) {
4255
+ collectPatchError(applied.error, patchId, op);
3779
4256
  patchHadError = true;
3780
4257
  break;
3781
4258
  }
3782
- tsSourceFile = insRes.value;
3783
- tsChanged = true;
3784
- jsonEntryContents.set(jsonPath, content);
3785
- jsonEntryContentsByKey.set(cls.entryKey, content);
3786
- entryKeyToJsonPath.set(cls.entryKey, jsonPath);
3787
- if (op.op === "move" && fromPathRes.value !== jsonPath) {
3788
- jsonEntryContents.set(fromPathRes.value, null);
3789
- jsonEntryContentsByKey.set(fromKey, null);
3790
- }
3791
- } else {
3792
- errors.push({
3793
- message: `Unsupported op '${op.op}' on jsonValues entry '${cls.entryKey}' (supported: add, remove, replace, move, copy)`,
3794
- filePath: path
3795
- });
3796
- patchHadError = true;
3797
- break;
3798
- }
3799
- } else {
3800
- // Content sub-op: replay against the entry's `*.val.json`.
3801
- // `rebaseContentOp` slices `from` by the same prefix as `path`, so a
3802
- // cross-entry move/copy would silently corrupt the target entry.
3803
- if ((op.op === "move" || op.op === "copy") && (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey)) {
3804
- errors.push({
3805
- message: `Cannot '${op.op}' between different jsonValues entries`,
3806
- filePath: path
3807
- });
3808
- patchHadError = true;
3809
- break;
3810
- }
3811
- const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3812
- if (fp.result.isErr(jsonPathRes)) {
3813
- errors.push(jsonPathRes.error);
3814
- patchHadError = true;
3815
- break;
4259
+ jsonEntryContents.set(jsonPath, applied.value);
4260
+ jsonEntryContentsByKey.set(cls.entryKey, applied.value);
3816
4261
  }
3817
- const jsonPath = jsonPathRes.value;
3818
- const contentRes = await loadEntryContent(jsonPath);
3819
- if (fp.result.isErr(contentRes)) {
3820
- errors.push(contentRes.error);
3821
- patchHadError = true;
3822
- break;
3823
- }
3824
- const rebasedRes = rebaseContentOp(op, cls.recordPath.length + 1);
3825
- if (fp.result.isErr(rebasedRes)) {
3826
- errors.push({
3827
- message: rebasedRes.error.message,
3828
- filePath: jsonPath
3829
- });
3830
- patchHadError = true;
3831
- break;
3832
- }
3833
- const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
3834
- if (fp.result.isErr(applied)) {
3835
- collectPatchError(applied.error, patchId, op);
3836
- patchHadError = true;
3837
- break;
3838
- }
3839
- jsonEntryContents.set(jsonPath, applied.value);
3840
- jsonEntryContentsByKey.set(cls.entryKey, applied.value);
4262
+ }
4263
+ if (patchHadError) {
4264
+ break;
3841
4265
  }
3842
4266
  }
3843
4267
  if (patchHadError) {
@@ -3923,7 +4347,25 @@ class ValOps {
3923
4347
  };
3924
4348
  };
3925
4349
  const patchedJsonEntries = {};
3926
- const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
4350
+ /*
4351
+ * Patching the FILE TEXT, for a store that has somewhere to put it.
4352
+ *
4353
+ * Where there is nowhere -- an http project with no repository -- the
4354
+ * patches are still all applied, because they are: `getSources(analysis)`
4355
+ * below applies them to Source, and that is what the commit records and
4356
+ * what every reader of this project sees. What is skipped is rendering
4357
+ * that data back out as `.val.ts` and `*.val.json`, which would be a
4358
+ * mirror with no repository to mirror into -- and which cannot even be
4359
+ * attempted, because producing it starts by READING the current text from
4360
+ * the repository that is not there.
4361
+ */
4362
+ const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => this.mirrorsSourceFiles ? applySourceFilePatches(path, patches) : Promise.resolve({
4363
+ path: path,
4364
+ appliedPatches: patches.map(p => p.patchId),
4365
+ result: null,
4366
+ extraFiles: {},
4367
+ jsonEntries: {}
4368
+ })));
3927
4369
  let hasErrors = false;
3928
4370
  const sourceFilePatchErrors = {};
3929
4371
  const appliedPatches = {};
@@ -4098,6 +4540,46 @@ class ValOps {
4098
4540
  });
4099
4541
  }
4100
4542
 
4543
+ /**
4544
+ * Why a publish cannot happen here, or `null` when one can.
4545
+ *
4546
+ * Named rather than thrown, and asked BEFORE the click: the Studio shows the
4547
+ * reason and disables the action, instead of letting someone write a commit
4548
+ * message and then meeting a failure from four layers down.
4549
+ *
4550
+ * `no-base` is the only code so far and means what it says: there is nowhere
4551
+ * for this publish's commit to be based. Note which way round that is --
4552
+ * a project whose content service is the store of record always HAS a base
4553
+ * (the service's own chain, which mints its own shas), so the refusal is not
4554
+ * about missing git. It is about a deployment that cannot do what its
4555
+ * project requires.
4556
+ */
4557
+ publishRefusal() {
4558
+ return null;
4559
+ }
4560
+
4561
+ /**
4562
+ * Whether a commit here produces `.val.ts` TEXT as well as data.
4563
+ *
4564
+ * True everywhere there is somewhere to put it: a working tree in `fs` mode,
4565
+ * a host holding its own source in memory mode, a git repository in `http`
4566
+ * mode. False for an `http` project whose content service is the store of
4567
+ * record and which has no repository attached -- see `git` on
4568
+ * {@link ValApiOptions}.
4569
+ *
4570
+ * WHAT IS NOT AFFECTED, and it is the part worth being sure of:
4571
+ * `moduleVersions` -- what each changed module IS after the commit, with its
4572
+ * schema -- comes from `getSources(analysis)`, which applies the ops to
4573
+ * Source in the stores. It does not go near the file text. So a commit with
4574
+ * no mirror still records everything history and a later `connect-github`
4575
+ * fold need; what it does not record is a rendering of that data as code.
4576
+ *
4577
+ * WHAT IS: the ops are no longer applied to the file text as well, so a
4578
+ * patch that would not fit the `.val.ts` is not reported here. That check
4579
+ * only ever existed for the text being produced, and there is none.
4580
+ */
4581
+ mirrorsSourceFiles = true;
4582
+
4101
4583
  /**
4102
4584
  * Take the `.val.ts` text a commit produced as the new committed source.
4103
4585
  *
@@ -4262,6 +4744,16 @@ function formatPatchSourceError(error) {
4262
4744
  return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
4263
4745
  }
4264
4746
  }
4747
+
4748
+ /**
4749
+ * Why a publish is refused, in a form a person can be shown.
4750
+ *
4751
+ * `code` is for the Studio to branch on and `message` is what it says. Both,
4752
+ * rather than a code and a lookup table on the client: the server knows what
4753
+ * is actually missing -- which branch, which commit -- and a client-side table
4754
+ * could only ever say the generic version.
4755
+ */
4756
+
4265
4757
  /**
4266
4758
  * The patches a json entry render should apply, out of the whole chain.
4267
4759
  *
@@ -7101,8 +7593,14 @@ const GetApplicablePatches = z.z.object({
7101
7593
  })),
7102
7594
  commits: z.z.array(z.z.object({
7103
7595
  commitSha: z.z.string(),
7104
- clientCommitSha: z.z.string(),
7105
- parentCommitSha: z.z.string(),
7596
+ /*
7597
+ * Nullable since the content service mints its own commit shas: a
7598
+ * root commit has no parent, and a publisher that did not say where
7599
+ * it was has no client sha. Parsed rather than trusted, so a service
7600
+ * that sends null gets null here instead of failing the whole poll.
7601
+ */
7602
+ clientCommitSha: z.z.string().nullable(),
7603
+ parentCommitSha: z.z.string().nullable(),
7106
7604
  commitMessage: z.z.string().nullable(),
7107
7605
  branch: z.z.string(),
7108
7606
  creator: z.z.string(),
@@ -7118,7 +7616,21 @@ const GetApplicablePatches = z.z.object({
7118
7616
  // `ValDeployment`: this is the only source for a deployment that Val
7119
7617
  // did not publish, and zod would otherwise strip it here.
7120
7618
  commitMessage: z.z.string().nullable().optional()
7121
- })).optional()
7619
+ })).optional(),
7620
+ /**
7621
+ * What the project EXPECTS of whoever publishes it.
7622
+ *
7623
+ * Optional because a content service that predates it sends nothing, and
7624
+ * absent means "not reported" -- never "managed". The difference decides
7625
+ * whether a publish is refused, so guessing either way would be worse than
7626
+ * not checking: guessing `managed` would let a deployment publish a
7627
+ * connected project it cannot mirror, and guessing `connected` would refuse
7628
+ * every publish against an older service.
7629
+ */
7630
+ project: z.z.object({
7631
+ sourceMode: z.z.union([z.z.literal("managed"), z.z.literal("connected")]),
7632
+ branch: z.z.string()
7633
+ }).optional()
7122
7634
  });
7123
7635
  const FilesResponse = z.z.object({
7124
7636
  files: z.z.array(z.z.union([z.z.object({
@@ -7170,6 +7682,18 @@ const SavePatchFileResponse = z.z.object({
7170
7682
  const CommitResponse = z.z.object({
7171
7683
  updatedFiles: z.z.array(z.z.string()),
7172
7684
  commit: CommitSha,
7685
+ /*
7686
+ * Optional because a content service that predates them sends neither, and
7687
+ * this client talks to whichever one the project is on. Absent means NOT
7688
+ * REPORTED: `parent: undefined` is not "a root commit", and a caller that
7689
+ * reads it as one would conclude the history starts here.
7690
+ *
7691
+ * Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
7692
+ * from a service that versions separately and are passed through as what it
7693
+ * said, not as something this end vouched for.
7694
+ */
7695
+ parent: z.z.string().optional(),
7696
+ tree: z.z.string().optional(),
7173
7697
  branch: z.z.string()
7174
7698
  });
7175
7699
  // #region history wire schemas
@@ -7179,8 +7703,10 @@ const CommitResponse = z.z.object({
7179
7703
  // commit changed nothing", which is indistinguishable from a real answer.
7180
7704
  const HistoricalCommitResponse = z.z.object({
7181
7705
  commitSha: z.z.string(),
7182
- parentCommitSha: z.z.string(),
7183
- clientCommitSha: z.z.string(),
7706
+ /** `null` for a root commit; see the note on the applicable-patches schema. */
7707
+ parentCommitSha: z.z.string().nullable(),
7708
+ /** `null` when the publisher did not say where it was. */
7709
+ clientCommitSha: z.z.string().nullable(),
7184
7710
  branch: z.z.string(),
7185
7711
  createdBranch: z.z.string().nullable(),
7186
7712
  creator: z.z.string().nullable(),
@@ -7198,8 +7724,8 @@ const CommitPatchesResponse = z.z.object({
7198
7724
  commitSha: z.z.string(),
7199
7725
  commit: z.z.object({
7200
7726
  commitSha: z.z.string(),
7201
- parentCommitSha: z.z.string(),
7202
- clientCommitSha: z.z.string(),
7727
+ parentCommitSha: z.z.string().nullable(),
7728
+ clientCommitSha: z.z.string().nullable(),
7203
7729
  branch: z.z.string(),
7204
7730
  createdBranch: z.z.string().nullable(),
7205
7731
  creator: z.z.string().nullable(),
@@ -7299,9 +7825,45 @@ class ValOpsHttp extends ValOps {
7299
7825
  patchesAreLocal = false;
7300
7826
  /** See {@link ValOps.requiresAuth}. */
7301
7827
  requiresAuth = true;
7302
- constructor(contentUrl, project, commitSha,
7303
- // TODO: CommitSha
7304
- branch,
7828
+ /**
7829
+ * A commit mirrors into `.val.ts` only when there is a repository.
7830
+ *
7831
+ * See {@link ValOps.mirrorsSourceFiles}. Set in the constructor rather than
7832
+ * as an initialiser because it depends on `git`, and a class field
7833
+ * initialiser runs before the constructor body has assigned it.
7834
+ */
7835
+ mirrorsSourceFiles;
7836
+ /**
7837
+ * What the content service last said this project expects of its publisher.
7838
+ *
7839
+ * `null` until something has asked it, which in practice is the first poll.
7840
+ * It is remembered rather than asked for on demand because the answer is
7841
+ * only wanted on the publish path, and that path already fetches the
7842
+ * patches it is publishing -- so a dedicated request would be a second round
7843
+ * trip for two fields that just arrived.
7844
+ *
7845
+ * It can be one poll out of date, and that is the right amount: the thing it
7846
+ * changes is whether this deployment can mirror commits into a repository,
7847
+ * which changes when a project CONNECTS one -- and a project that has just
7848
+ * connected is one whose builds are about to be replaced anyway.
7849
+ */
7850
+ projectExpectation = null;
7851
+ constructor(contentUrl, project,
7852
+ /**
7853
+ * The repository this project's commits are mirrored into, or `null`.
7854
+ *
7855
+ * `null` is a project whose content service is the store of record: it
7856
+ * mints its own commit shas, and it knows this project's branch from the
7857
+ * project itself. Every request below that would have carried a branch and
7858
+ * a commit omits them instead, and the service answers from the project's
7859
+ * own chain -- which is where those answers always came from.
7860
+ *
7861
+ * It is NOT a degraded mode. The one thing that genuinely needs a
7862
+ * repository is producing the `.val.ts` text a commit mirrors, and a
7863
+ * project with no repository has nothing to mirror into. See `git` on
7864
+ * {@link ValApiOptions}.
7865
+ */
7866
+ git,
7305
7867
  /**
7306
7868
  * An api key (how the app itself authenticates) or a personal access token
7307
7869
  * (how the CLI authenticates after `val login`). Same two shapes as
@@ -7311,14 +7873,49 @@ class ValOpsHttp extends ValOps {
7311
7873
  super(valModules, options);
7312
7874
  this.contentUrl = contentUrl;
7313
7875
  this.project = project;
7314
- this.commitSha = commitSha;
7315
- this.branch = branch;
7876
+ this.git = git;
7316
7877
  this.authHeaders = "pat" in auth ? {
7317
7878
  "x-val-pat": auth.pat
7318
7879
  } : {
7319
7880
  Authorization: `Bearer ${auth.apiKey}`
7320
7881
  };
7321
7882
  this.root = (options === null || options === void 0 ? void 0 : options.root) ?? "";
7883
+ this.mirrorsSourceFiles = git !== null;
7884
+ }
7885
+ /**
7886
+ * A deployment that cannot mirror a project which expects to be mirrored.
7887
+ *
7888
+ * This is the one shape of "no base" that exists, and it is not the one it
7889
+ * sounds like. A project with no repository is fine: the content service is
7890
+ * the store of record for it and mints its own commit shas, so there is
7891
+ * always somewhere for the commit to go. What is refused is the mismatch --
7892
+ * a project whose commits are mirrored into a repository, being published by
7893
+ * a build that was made before it had one and so has no commit to produce
7894
+ * that mirror against.
7895
+ *
7896
+ * It is a REAL state rather than a defensive check: it is exactly what a
7897
+ * deployment looks like between a project connecting a repository and its
7898
+ * next build going out. Left unchecked, such a publish writes the data and
7899
+ * silently fails to mirror it, and the repository quietly falls behind the
7900
+ * content nobody is told about.
7901
+ *
7902
+ * `null` when the service did not say (see `project` on the response
7903
+ * schema): an older content service is not evidence of anything, and
7904
+ * refusing every publish against one would be a worse failure than not
7905
+ * checking.
7906
+ */
7907
+ publishRefusal() {
7908
+ var _this$projectExpectat;
7909
+ if (this.git !== null) {
7910
+ return null;
7911
+ }
7912
+ if (((_this$projectExpectat = this.projectExpectation) === null || _this$projectExpectat === void 0 ? void 0 : _this$projectExpectat.sourceMode) !== "connected") {
7913
+ return null;
7914
+ }
7915
+ return {
7916
+ code: "no-base",
7917
+ message: `This project mirrors its content into a git repository (branch ` + `'${this.projectExpectation.branch}'), but this deployment was not ` + "built from one, so it does not know which commit to write that " + "mirror against. Publishing would save the content and silently " + "leave the repository behind. Deploy this project again from its " + "repository, and publishing will work from that build on."
7918
+ };
7322
7919
  }
7323
7920
  async onInit() {
7324
7921
  // TODO: unused for now. Implement or remove
@@ -7509,16 +8106,31 @@ class ValOpsHttp extends ValOps {
7509
8106
  * decided against.
7510
8107
  */
7511
8108
  headCommitSha: internal.newestCommitSha(allPatchData.commits) ?? undefined,
7512
- commitSha: this.commitSha
8109
+ /*
8110
+ * Spread: a project with no repository has no such commit, and saying
8111
+ * so by leaving the key out is different from sending an empty string
8112
+ * the Studio would try to show.
8113
+ */
8114
+ ...(this.git ? {
8115
+ commitSha: this.git.commit
8116
+ } : {})
7513
8117
  };
7514
8118
  }
7515
8119
  async getWebSocketNonce(profileId) {
7516
8120
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
7517
8121
  method: "POST",
7518
8122
  body: JSON.stringify({
7519
- branch: this.branch,
7520
8123
  profileId,
7521
- commitSha: this.commitSha
8124
+ /*
8125
+ * Omitted when there is no repository, like every other request here.
8126
+ * The content service knows this project's branch -- it is a column on
8127
+ * the project -- and a nonce is scoped to the project and the person,
8128
+ * not to a position in a chain.
8129
+ */
8130
+ ...(this.git ? {
8131
+ branch: this.git.branch,
8132
+ commitSha: this.git.commit
8133
+ } : {})
7522
8134
  }),
7523
8135
  headers: {
7524
8136
  ...this.authHeaders,
@@ -7666,8 +8278,20 @@ class ValOpsHttp extends ValOps {
7666
8278
  }
7667
8279
  async fetchPatchesInternal(filters) {
7668
8280
  const params = [];
7669
- params.push(["branch", this.branch]);
7670
- params.push(["commit", this.commitSha]);
8281
+ /*
8282
+ * A position in the chain, WHEN THIS BUILD HAS ONE.
8283
+ *
8284
+ * `commit` tells the content service where this deployment sits, so it can
8285
+ * answer with the commits at or after that point. A build with no
8286
+ * repository has no such commit baked into it, and asking the service to
8287
+ * resolve the position itself is strictly better than inventing one: it
8288
+ * holds the chain, and for a project it is the only publisher of, the
8289
+ * position IS the head.
8290
+ */
8291
+ if (this.git) {
8292
+ params.push(["branch", this.git.branch]);
8293
+ params.push(["commit", this.git.commit]);
8294
+ }
7671
8295
  if (filters.patchIds) {
7672
8296
  for (const patchId of filters.patchIds) {
7673
8297
  params.push(["patch_id", patchId]);
@@ -7688,6 +8312,17 @@ class ValOpsHttp extends ValOps {
7688
8312
  if (parsed.success) {
7689
8313
  const errors = [];
7690
8314
  const data = parsed.data;
8315
+ /*
8316
+ * What the project expects of its publisher, remembered.
8317
+ *
8318
+ * Recorded here rather than returned because every caller of this
8319
+ * already has what it needs and only the publish path asks the
8320
+ * question -- and that path calls this first. See
8321
+ * {@link publishRefusal}.
8322
+ */
8323
+ if (data.project) {
8324
+ this.projectExpectation = data.project;
8325
+ }
7691
8326
  for (const patchesRes of data.patches) {
7692
8327
  var _patchesRes$applied;
7693
8328
  patches.push({
@@ -7908,7 +8543,7 @@ class ValOpsHttp extends ValOps {
7908
8543
  * `saveSourceFilePatch`): groups are per branch, so a request without one
7909
8544
  * is not merely under-specified, it is rejected.
7910
8545
  */
7911
- const params = new URLSearchParams([["branch", this.branch]]);
8546
+ const params = new URLSearchParams(this.git ? [["branch", this.git.branch]] : []);
7912
8547
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
7913
8548
  headers: this.authHeaders
7914
8549
  });
@@ -8060,8 +8695,10 @@ class ValOpsHttp extends ValOps {
8060
8695
  patchId,
8061
8696
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
8062
8697
  baseSha,
8063
- commit: this.commitSha,
8064
- branch: this.branch,
8698
+ ...(this.git ? {
8699
+ commit: this.git.commit,
8700
+ branch: this.git.branch
8701
+ } : {}),
8065
8702
  coreVersion: core.Internal.VERSION.core,
8066
8703
  /*
8067
8704
  * Group membership in the SAME request as the patch.
@@ -8227,11 +8864,28 @@ class ValOpsHttp extends ValOps {
8227
8864
  });
8228
8865
  }
8229
8866
  async getSourceFile(path) {
8867
+ /*
8868
+ * There is no file to read without a repository to read it from.
8869
+ *
8870
+ * Reachable only through the CLI's debug snapshot now: the publish path
8871
+ * does not call this for such a project at all -- see
8872
+ * {@link ValOps.mirrorsSourceFiles} -- because there is nothing for it to
8873
+ * produce. It is an error rather than an empty string because an empty
8874
+ * `.val.ts` would be patched successfully and committed as a module that
8875
+ * had lost all its content.
8876
+ */
8877
+ if (!this.git) {
8878
+ return {
8879
+ error: {
8880
+ message: `Cannot read the source of ${path}: this project has no ` + "repository, so there is no `.val.ts` to read. Its content lives " + "in Val's content service, which is the store of record for it."
8881
+ }
8882
+ };
8883
+ }
8230
8884
  const filesRes = await this.getHttpFiles([{
8231
8885
  filePath: path,
8232
8886
  location: "repo",
8233
8887
  root: this.root,
8234
- commitSha: this.commitSha
8888
+ commitSha: this.git.commit
8235
8889
  }]);
8236
8890
  if (filesRes.error) {
8237
8891
  return filesRes;
@@ -8251,11 +8905,22 @@ class ValOpsHttp extends ValOps {
8251
8905
  async getBinaryFile(filePath) {
8252
8906
  // We could also just get this from public/ on the running server. Current approach feels more clean, but will be slower / puts more server load... We might want to change this
8253
8907
  const requestFiles = [];
8908
+ if (!this.git) {
8909
+ /*
8910
+ * A published binary lives in the repository, and there is none.
8911
+ *
8912
+ * `null` is this method's existing "not found", which is what a caller
8913
+ * already handles: the Studio falls back to the patch's own copy, which
8914
+ * is where a managed project's files stay. See the note on local files
8915
+ * in the content service's commit handler.
8916
+ */
8917
+ return null;
8918
+ }
8254
8919
  requestFiles.push({
8255
8920
  filePath: filePath,
8256
8921
  location: "repo",
8257
8922
  root: this.root,
8258
- commitSha: this.commitSha
8923
+ commitSha: this.git.commit
8259
8924
  });
8260
8925
  const filesRes = await this.getHttpFiles(requestFiles);
8261
8926
  if (filesRes.error) {
@@ -8434,7 +9099,6 @@ class ValOpsHttp extends ValOps {
8434
9099
  patchGroupId) {
8435
9100
  try {
8436
9101
  var _res$headers$get3;
8437
- const existingBranch = this.branch;
8438
9102
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/commit`, {
8439
9103
  method: "POST",
8440
9104
  headers: {
@@ -8478,13 +9142,29 @@ class ValOpsHttp extends ValOps {
8478
9142
  * moved on.
8479
9143
  */
8480
9144
  modules: prepared.moduleVersions,
8481
- commit: this.commitSha,
9145
+ /*
9146
+ * WHERE THIS BUILD THOUGHT IT WAS -- and only for a project with a
9147
+ * repository, which is the only thing that still reads it.
9148
+ *
9149
+ * The content service mints its own commit shas, so this is no
9150
+ * longer the parent, and it never was the concurrency check: that is
9151
+ * `expectedHeadCommitSha` against `newestCommitSha(patches.commits)`,
9152
+ * which `/save` runs before it gets here. What is left is the git
9153
+ * fast-forward check, which needs to know the commit this build read
9154
+ * the branch at to tell whether the branch has moved under it.
9155
+ *
9156
+ * The branch goes with it. The project's branch is a column on the
9157
+ * project, so a build asserting one could only ever disagree with
9158
+ * the project it is publishing to.
9159
+ */
9160
+ ...(this.git ? {
9161
+ commit: this.git.commit
9162
+ } : {}),
8482
9163
  root: this.root,
8483
9164
  filesDirectory,
8484
9165
  baseSha: await this.getBaseSha(),
8485
9166
  committer,
8486
9167
  message,
8487
- existingBranch,
8488
9168
  newBranch,
8489
9169
  ...(patchGroupId !== undefined ? {
8490
9170
  patchGroupId
@@ -8497,7 +9177,19 @@ class ValOpsHttp extends ValOps {
8497
9177
  return {
8498
9178
  updatedFiles: parsed.data.updatedFiles,
8499
9179
  commit: parsed.data.commit,
8500
- branch: parsed.data.branch
9180
+ branch: parsed.data.branch,
9181
+ /*
9182
+ * Spread rather than set, so a service that did not report them
9183
+ * leaves the keys ABSENT rather than present-and-undefined. A
9184
+ * caller doing `'parent' in result` then gets the truthful answer,
9185
+ * and JSON round-trips of this object do not grow null fields.
9186
+ */
9187
+ ...(parsed.data.parent !== undefined ? {
9188
+ parent: parsed.data.parent
9189
+ } : {}),
9190
+ ...(parsed.data.tree !== undefined ? {
9191
+ tree: parsed.data.tree
9192
+ } : {})
8501
9193
  };
8502
9194
  }
8503
9195
  return {
@@ -10221,7 +10913,7 @@ async function initHandlerOptions(route, opts, config) {
10221
10913
  valContentUrl
10222
10914
  });
10223
10915
  if (isProxyMode) {
10224
- var _opts$versions, _opts$versions2;
10916
+ var _opts$git, _opts$git2, _opts$versions, _opts$versions2;
10225
10917
  /*
10226
10918
  * Why this app is in http mode, in the message that says what is missing.
10227
10919
  *
@@ -10234,13 +10926,30 @@ async function initHandlerOptions(route, opts, config) {
10234
10926
  if (!maybeApiKey || !maybeValSecret) {
10235
10927
  throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode" + because);
10236
10928
  }
10237
- const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
10238
- if (!maybeGitCommit) {
10239
- throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode" + because);
10240
- }
10241
- const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
10242
- if (!maybeGitBranch) {
10243
- throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode" + because);
10929
+ /*
10930
+ * A COMMIT IS NOT WHAT PUTS AN APP IN HTTP MODE. Credentials are.
10931
+ *
10932
+ * Both of these used to be required here, and the requirement was a
10933
+ * repository disguised as a configuration check: a deployment with no
10934
+ * commit to name -- one whose content service owns its content, which is
10935
+ * now the normal case -- threw at boot, or, worse, never reached this
10936
+ * branch at all and fell through to `fs` mode, looking for a working tree
10937
+ * that was not there.
10938
+ *
10939
+ * Absent is a project with no repository to mirror commits into. The
10940
+ * content service mints its own commit shas and is the store of record for
10941
+ * content, so there is nothing missing: see `git` on {@link ValApiOptions}
10942
+ * for what a commit is still FOR where there is one.
10943
+ *
10944
+ * Taken together or not at all. A commit without a branch names a point
10945
+ * with no line of work to publish to, and a branch without a commit names
10946
+ * a line with no position in it; either alone would be a half-configured
10947
+ * repository that fails later, at a publish, rather than here.
10948
+ */
10949
+ const maybeGitCommit = ((_opts$git = opts.git) === null || _opts$git === void 0 ? void 0 : _opts$git.commit) || process.env.VAL_GIT_COMMIT;
10950
+ const maybeGitBranch = ((_opts$git2 = opts.git) === null || _opts$git2 === void 0 ? void 0 : _opts$git2.branch) || process.env.VAL_GIT_BRANCH;
10951
+ if (!!maybeGitCommit !== !!maybeGitBranch) {
10952
+ throw new Error(`Val is configured with a git ${maybeGitCommit ? "commit" : "branch"} ` + `but no ${maybeGitCommit ? "branch" : "commit"}. Set both (the ` + "`git` option, or VAL_GIT_COMMIT and VAL_GIT_BRANCH) for a project " + "whose content is mirrored into a repository, or neither for one " + "whose content service is the store of record." + because);
10244
10953
  }
10245
10954
  if (!maybeValProject) {
10246
10955
  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);
@@ -10258,8 +10967,19 @@ async function initHandlerOptions(route, opts, config) {
10258
10967
  route,
10259
10968
  apiKey: maybeApiKey,
10260
10969
  valSecret: maybeValSecret,
10261
- commit: maybeGitCommit,
10262
- branch: maybeGitBranch,
10970
+ /*
10971
+ * Spread, so a project with no repository has no `git` key at all rather
10972
+ * than one holding undefined. `ValOpsHttp` asks `git === null` to decide
10973
+ * whether to send a branch and a commit with every request, and a key
10974
+ * that is present-but-undefined is one more thing for that check to get
10975
+ * wrong.
10976
+ */
10977
+ ...(maybeGitCommit && maybeGitBranch ? {
10978
+ git: {
10979
+ commit: maybeGitCommit,
10980
+ branch: maybeGitBranch
10981
+ }
10982
+ } : {}),
10263
10983
  root: opts.root,
10264
10984
  project: maybeValProject,
10265
10985
  valEnableRedirectUrl,
@@ -10322,7 +11042,7 @@ function createValOps(valModules, options) {
10322
11042
  });
10323
11043
  }
10324
11044
  if (options.mode === "http") {
10325
- return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
11045
+ return new ValOpsHttp(options.valContentUrl, options.project, options.git ?? null, {
10326
11046
  apiKey: options.apiKey
10327
11047
  }, valModules, {
10328
11048
  formatter: options.formatter,
@@ -10528,6 +11248,7 @@ async function resolveRemoteFileAuth(options) {
10528
11248
  /** What a publish reports back, in either shape. */
10529
11249
 
10530
11250
  const ValServer = (valModules, options, callbacks) => {
11251
+ var _options$git;
10531
11252
  const AIContentBlock = z.z.union([z.z.object({
10532
11253
  type: z.z.literal("text"),
10533
11254
  text: z.z.string()
@@ -10559,7 +11280,7 @@ const ValServer = (valModules, options, callbacks) => {
10559
11280
  url.searchParams.set("state", token);
10560
11281
  return url.toString();
10561
11282
  };
10562
- const commit = options.mode === "http" ? options.commit : undefined;
11283
+ const commit = options.mode === "http" ? (_options$git = options.git) === null || _options$git === void 0 ? void 0 : _options$git.commit : undefined;
10563
11284
  const getAppErrorUrl = error => {
10564
11285
  if (!options.project) {
10565
11286
  throw new Error("Project is not set");
@@ -11156,12 +11877,24 @@ const ValServer = (valModules, options, callbacks) => {
11156
11877
  * start against a server that was working.
11157
11878
  */
11158
11879
  const mode = serverOps.patchesAreLocal ? "fs" : "http";
11880
+ /*
11881
+ * Why publishing is unavailable, so the Studio can say so.
11882
+ *
11883
+ * Spread rather than set to null, so a server that can publish sends
11884
+ * no such key at all: the Studio's "can I publish" is then the
11885
+ * presence of the field, and there is no null to mistake for a reason
11886
+ * it failed to compute.
11887
+ */
11888
+ const publishRefusal = serverOps.publishRefusal();
11159
11889
  return {
11160
11890
  status: 200,
11161
11891
  json: {
11162
11892
  ...currentStat,
11163
11893
  profileId: profileId ?? null,
11164
11894
  mode,
11895
+ ...(publishRefusal ? {
11896
+ publishRefusal
11897
+ } : {}),
11165
11898
  config: options.config
11166
11899
  }
11167
11900
  };
@@ -12350,6 +13083,29 @@ const ValServer = (valModules, options, callbacks) => {
12350
13083
  patchIds,
12351
13084
  excludePatchOps: false
12352
13085
  });
13086
+ /*
13087
+ * Can this deployment publish this project AT ALL?
13088
+ *
13089
+ * After the fetch above, deliberately: that call is what tells the
13090
+ * data layer what the project expects, and asking before it would be
13091
+ * asking a question nothing has answered yet. It is still before
13092
+ * anything is written, which is the part that matters.
13093
+ *
13094
+ * The Studio already knows -- `/stat` carries the same refusal, so the
13095
+ * action is disabled with the reason shown. This is the second half of
13096
+ * that: a stat can be stale by a poll, and nothing may reach `prepare`
13097
+ * on a project it cannot mirror.
13098
+ */
13099
+ const refusal = serverOps.publishRefusal();
13100
+ if (refusal) {
13101
+ return {
13102
+ status: 409,
13103
+ json: {
13104
+ message: refusal.message,
13105
+ publishRefusal: refusal
13106
+ }
13107
+ };
13108
+ }
12353
13109
  /*
12354
13110
  * Exactly the patches this request consumes, and the ONLY ones it may
12355
13111
  * delete afterwards.