@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.
- package/CHANGELOG.md +122 -0
- package/dist/declarations/src/ValOps.d.ts +50 -1
- package/dist/declarations/src/ValOpsHttp.d.ts +87 -6
- package/dist/declarations/src/ValRouter.d.ts +23 -10
- package/dist/declarations/src/ValServer.d.ts +42 -2
- package/dist/declarations/src/history/types.d.ts +4 -2
- package/dist/declarations/src/patch/jsonValuesPatch.d.ts +69 -1
- package/dist/valbuild-server.cjs.dev.js +1106 -350
- package/dist/valbuild-server.cjs.prod.js +1106 -350
- package/dist/valbuild-server.esm.js +1107 -351
- package/package.json +3 -3
|
@@ -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,
|
|
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
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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:
|
|
1525
|
+
message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
|
|
1301
1526
|
patchId
|
|
1302
1527
|
};
|
|
1303
1528
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
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:
|
|
1533
|
+
message: rebased.error.message,
|
|
1326
1534
|
patchId
|
|
1327
1535
|
};
|
|
1328
1536
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
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
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
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
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
op
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
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:
|
|
3090
|
-
path: op.
|
|
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
|
|
3509
|
-
//
|
|
3510
|
-
|
|
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
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
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 =
|
|
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
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
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
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
}
|
|
3627
|
-
|
|
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
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
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 =
|
|
4038
|
+
tsSourceFile = patchRes.value;
|
|
3680
4039
|
tsChanged = true;
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
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
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
//
|
|
3694
|
-
|
|
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}'
|
|
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
|
|
3706
|
-
|
|
3707
|
-
|
|
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
|
-
|
|
3713
|
-
|
|
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
|
|
3721
|
-
if (
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
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
|
-
|
|
3740
|
-
|
|
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
|
-
|
|
3749
|
-
|
|
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;
|
|
4225
|
+
jsonEntryContents.set(jsonPath, applied.value);
|
|
4226
|
+
jsonEntryContentsByKey.set(cls.entryKey, applied.value);
|
|
3764
4227
|
}
|
|
3765
|
-
}
|
|
3766
|
-
|
|
3767
|
-
|
|
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;
|
|
3789
|
-
}
|
|
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) {
|
|
@@ -3889,7 +4313,25 @@ class ValOps {
|
|
|
3889
4313
|
};
|
|
3890
4314
|
};
|
|
3891
4315
|
const patchedJsonEntries = {};
|
|
3892
|
-
|
|
4316
|
+
/*
|
|
4317
|
+
* Patching the FILE TEXT, for a store that has somewhere to put it.
|
|
4318
|
+
*
|
|
4319
|
+
* Where there is nowhere -- an http project with no repository -- the
|
|
4320
|
+
* patches are still all applied, because they are: `getSources(analysis)`
|
|
4321
|
+
* below applies them to Source, and that is what the commit records and
|
|
4322
|
+
* what every reader of this project sees. What is skipped is rendering
|
|
4323
|
+
* that data back out as `.val.ts` and `*.val.json`, which would be a
|
|
4324
|
+
* mirror with no repository to mirror into -- and which cannot even be
|
|
4325
|
+
* attempted, because producing it starts by READING the current text from
|
|
4326
|
+
* the repository that is not there.
|
|
4327
|
+
*/
|
|
4328
|
+
const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => this.mirrorsSourceFiles ? applySourceFilePatches(path, patches) : Promise.resolve({
|
|
4329
|
+
path: path,
|
|
4330
|
+
appliedPatches: patches.map(p => p.patchId),
|
|
4331
|
+
result: null,
|
|
4332
|
+
extraFiles: {},
|
|
4333
|
+
jsonEntries: {}
|
|
4334
|
+
})));
|
|
3893
4335
|
let hasErrors = false;
|
|
3894
4336
|
const sourceFilePatchErrors = {};
|
|
3895
4337
|
const appliedPatches = {};
|
|
@@ -4064,6 +4506,46 @@ class ValOps {
|
|
|
4064
4506
|
});
|
|
4065
4507
|
}
|
|
4066
4508
|
|
|
4509
|
+
/**
|
|
4510
|
+
* Why a publish cannot happen here, or `null` when one can.
|
|
4511
|
+
*
|
|
4512
|
+
* Named rather than thrown, and asked BEFORE the click: the Studio shows the
|
|
4513
|
+
* reason and disables the action, instead of letting someone write a commit
|
|
4514
|
+
* message and then meeting a failure from four layers down.
|
|
4515
|
+
*
|
|
4516
|
+
* `no-base` is the only code so far and means what it says: there is nowhere
|
|
4517
|
+
* for this publish's commit to be based. Note which way round that is --
|
|
4518
|
+
* a project whose content service is the store of record always HAS a base
|
|
4519
|
+
* (the service's own chain, which mints its own shas), so the refusal is not
|
|
4520
|
+
* about missing git. It is about a deployment that cannot do what its
|
|
4521
|
+
* project requires.
|
|
4522
|
+
*/
|
|
4523
|
+
publishRefusal() {
|
|
4524
|
+
return null;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
/**
|
|
4528
|
+
* Whether a commit here produces `.val.ts` TEXT as well as data.
|
|
4529
|
+
*
|
|
4530
|
+
* True everywhere there is somewhere to put it: a working tree in `fs` mode,
|
|
4531
|
+
* a host holding its own source in memory mode, a git repository in `http`
|
|
4532
|
+
* mode. False for an `http` project whose content service is the store of
|
|
4533
|
+
* record and which has no repository attached -- see `git` on
|
|
4534
|
+
* {@link ValApiOptions}.
|
|
4535
|
+
*
|
|
4536
|
+
* WHAT IS NOT AFFECTED, and it is the part worth being sure of:
|
|
4537
|
+
* `moduleVersions` -- what each changed module IS after the commit, with its
|
|
4538
|
+
* schema -- comes from `getSources(analysis)`, which applies the ops to
|
|
4539
|
+
* Source in the stores. It does not go near the file text. So a commit with
|
|
4540
|
+
* no mirror still records everything history and a later `connect-github`
|
|
4541
|
+
* fold need; what it does not record is a rendering of that data as code.
|
|
4542
|
+
*
|
|
4543
|
+
* WHAT IS: the ops are no longer applied to the file text as well, so a
|
|
4544
|
+
* patch that would not fit the `.val.ts` is not reported here. That check
|
|
4545
|
+
* only ever existed for the text being produced, and there is none.
|
|
4546
|
+
*/
|
|
4547
|
+
mirrorsSourceFiles = true;
|
|
4548
|
+
|
|
4067
4549
|
/**
|
|
4068
4550
|
* Take the `.val.ts` text a commit produced as the new committed source.
|
|
4069
4551
|
*
|
|
@@ -4228,6 +4710,16 @@ function formatPatchSourceError(error) {
|
|
|
4228
4710
|
return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
|
|
4229
4711
|
}
|
|
4230
4712
|
}
|
|
4713
|
+
|
|
4714
|
+
/**
|
|
4715
|
+
* Why a publish is refused, in a form a person can be shown.
|
|
4716
|
+
*
|
|
4717
|
+
* `code` is for the Studio to branch on and `message` is what it says. Both,
|
|
4718
|
+
* rather than a code and a lookup table on the client: the server knows what
|
|
4719
|
+
* is actually missing -- which branch, which commit -- and a client-side table
|
|
4720
|
+
* could only ever say the generic version.
|
|
4721
|
+
*/
|
|
4722
|
+
|
|
4231
4723
|
/**
|
|
4232
4724
|
* The patches a json entry render should apply, out of the whole chain.
|
|
4233
4725
|
*
|
|
@@ -7067,8 +7559,14 @@ const GetApplicablePatches = z.object({
|
|
|
7067
7559
|
})),
|
|
7068
7560
|
commits: z.array(z.object({
|
|
7069
7561
|
commitSha: z.string(),
|
|
7070
|
-
|
|
7071
|
-
|
|
7562
|
+
/*
|
|
7563
|
+
* Nullable since the content service mints its own commit shas: a
|
|
7564
|
+
* root commit has no parent, and a publisher that did not say where
|
|
7565
|
+
* it was has no client sha. Parsed rather than trusted, so a service
|
|
7566
|
+
* that sends null gets null here instead of failing the whole poll.
|
|
7567
|
+
*/
|
|
7568
|
+
clientCommitSha: z.string().nullable(),
|
|
7569
|
+
parentCommitSha: z.string().nullable(),
|
|
7072
7570
|
commitMessage: z.string().nullable(),
|
|
7073
7571
|
branch: z.string(),
|
|
7074
7572
|
creator: z.string(),
|
|
@@ -7084,7 +7582,21 @@ const GetApplicablePatches = z.object({
|
|
|
7084
7582
|
// `ValDeployment`: this is the only source for a deployment that Val
|
|
7085
7583
|
// did not publish, and zod would otherwise strip it here.
|
|
7086
7584
|
commitMessage: z.string().nullable().optional()
|
|
7087
|
-
})).optional()
|
|
7585
|
+
})).optional(),
|
|
7586
|
+
/**
|
|
7587
|
+
* What the project EXPECTS of whoever publishes it.
|
|
7588
|
+
*
|
|
7589
|
+
* Optional because a content service that predates it sends nothing, and
|
|
7590
|
+
* absent means "not reported" -- never "managed". The difference decides
|
|
7591
|
+
* whether a publish is refused, so guessing either way would be worse than
|
|
7592
|
+
* not checking: guessing `managed` would let a deployment publish a
|
|
7593
|
+
* connected project it cannot mirror, and guessing `connected` would refuse
|
|
7594
|
+
* every publish against an older service.
|
|
7595
|
+
*/
|
|
7596
|
+
project: z.object({
|
|
7597
|
+
sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
|
|
7598
|
+
branch: z.string()
|
|
7599
|
+
}).optional()
|
|
7088
7600
|
});
|
|
7089
7601
|
const FilesResponse = z.object({
|
|
7090
7602
|
files: z.array(z.union([z.object({
|
|
@@ -7136,6 +7648,18 @@ const SavePatchFileResponse = z.object({
|
|
|
7136
7648
|
const CommitResponse = z.object({
|
|
7137
7649
|
updatedFiles: z.array(z.string()),
|
|
7138
7650
|
commit: CommitSha,
|
|
7651
|
+
/*
|
|
7652
|
+
* Optional because a content service that predates them sends neither, and
|
|
7653
|
+
* this client talks to whichever one the project is on. Absent means NOT
|
|
7654
|
+
* REPORTED: `parent: undefined` is not "a root commit", and a caller that
|
|
7655
|
+
* reads it as one would conclude the history starts here.
|
|
7656
|
+
*
|
|
7657
|
+
* Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
|
|
7658
|
+
* from a service that versions separately and are passed through as what it
|
|
7659
|
+
* said, not as something this end vouched for.
|
|
7660
|
+
*/
|
|
7661
|
+
parent: z.string().optional(),
|
|
7662
|
+
tree: z.string().optional(),
|
|
7139
7663
|
branch: z.string()
|
|
7140
7664
|
});
|
|
7141
7665
|
// #region history wire schemas
|
|
@@ -7145,8 +7669,10 @@ const CommitResponse = z.object({
|
|
|
7145
7669
|
// commit changed nothing", which is indistinguishable from a real answer.
|
|
7146
7670
|
const HistoricalCommitResponse = z.object({
|
|
7147
7671
|
commitSha: z.string(),
|
|
7148
|
-
|
|
7149
|
-
|
|
7672
|
+
/** `null` for a root commit; see the note on the applicable-patches schema. */
|
|
7673
|
+
parentCommitSha: z.string().nullable(),
|
|
7674
|
+
/** `null` when the publisher did not say where it was. */
|
|
7675
|
+
clientCommitSha: z.string().nullable(),
|
|
7150
7676
|
branch: z.string(),
|
|
7151
7677
|
createdBranch: z.string().nullable(),
|
|
7152
7678
|
creator: z.string().nullable(),
|
|
@@ -7164,8 +7690,8 @@ const CommitPatchesResponse = z.object({
|
|
|
7164
7690
|
commitSha: z.string(),
|
|
7165
7691
|
commit: z.object({
|
|
7166
7692
|
commitSha: z.string(),
|
|
7167
|
-
parentCommitSha: z.string(),
|
|
7168
|
-
clientCommitSha: z.string(),
|
|
7693
|
+
parentCommitSha: z.string().nullable(),
|
|
7694
|
+
clientCommitSha: z.string().nullable(),
|
|
7169
7695
|
branch: z.string(),
|
|
7170
7696
|
createdBranch: z.string().nullable(),
|
|
7171
7697
|
creator: z.string().nullable(),
|
|
@@ -7265,9 +7791,45 @@ class ValOpsHttp extends ValOps {
|
|
|
7265
7791
|
patchesAreLocal = false;
|
|
7266
7792
|
/** See {@link ValOps.requiresAuth}. */
|
|
7267
7793
|
requiresAuth = true;
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7794
|
+
/**
|
|
7795
|
+
* A commit mirrors into `.val.ts` only when there is a repository.
|
|
7796
|
+
*
|
|
7797
|
+
* See {@link ValOps.mirrorsSourceFiles}. Set in the constructor rather than
|
|
7798
|
+
* as an initialiser because it depends on `git`, and a class field
|
|
7799
|
+
* initialiser runs before the constructor body has assigned it.
|
|
7800
|
+
*/
|
|
7801
|
+
mirrorsSourceFiles;
|
|
7802
|
+
/**
|
|
7803
|
+
* What the content service last said this project expects of its publisher.
|
|
7804
|
+
*
|
|
7805
|
+
* `null` until something has asked it, which in practice is the first poll.
|
|
7806
|
+
* It is remembered rather than asked for on demand because the answer is
|
|
7807
|
+
* only wanted on the publish path, and that path already fetches the
|
|
7808
|
+
* patches it is publishing -- so a dedicated request would be a second round
|
|
7809
|
+
* trip for two fields that just arrived.
|
|
7810
|
+
*
|
|
7811
|
+
* It can be one poll out of date, and that is the right amount: the thing it
|
|
7812
|
+
* changes is whether this deployment can mirror commits into a repository,
|
|
7813
|
+
* which changes when a project CONNECTS one -- and a project that has just
|
|
7814
|
+
* connected is one whose builds are about to be replaced anyway.
|
|
7815
|
+
*/
|
|
7816
|
+
projectExpectation = null;
|
|
7817
|
+
constructor(contentUrl, project,
|
|
7818
|
+
/**
|
|
7819
|
+
* The repository this project's commits are mirrored into, or `null`.
|
|
7820
|
+
*
|
|
7821
|
+
* `null` is a project whose content service is the store of record: it
|
|
7822
|
+
* mints its own commit shas, and it knows this project's branch from the
|
|
7823
|
+
* project itself. Every request below that would have carried a branch and
|
|
7824
|
+
* a commit omits them instead, and the service answers from the project's
|
|
7825
|
+
* own chain -- which is where those answers always came from.
|
|
7826
|
+
*
|
|
7827
|
+
* It is NOT a degraded mode. The one thing that genuinely needs a
|
|
7828
|
+
* repository is producing the `.val.ts` text a commit mirrors, and a
|
|
7829
|
+
* project with no repository has nothing to mirror into. See `git` on
|
|
7830
|
+
* {@link ValApiOptions}.
|
|
7831
|
+
*/
|
|
7832
|
+
git,
|
|
7271
7833
|
/**
|
|
7272
7834
|
* An api key (how the app itself authenticates) or a personal access token
|
|
7273
7835
|
* (how the CLI authenticates after `val login`). Same two shapes as
|
|
@@ -7277,14 +7839,49 @@ class ValOpsHttp extends ValOps {
|
|
|
7277
7839
|
super(valModules, options);
|
|
7278
7840
|
this.contentUrl = contentUrl;
|
|
7279
7841
|
this.project = project;
|
|
7280
|
-
this.
|
|
7281
|
-
this.branch = branch;
|
|
7842
|
+
this.git = git;
|
|
7282
7843
|
this.authHeaders = "pat" in auth ? {
|
|
7283
7844
|
"x-val-pat": auth.pat
|
|
7284
7845
|
} : {
|
|
7285
7846
|
Authorization: `Bearer ${auth.apiKey}`
|
|
7286
7847
|
};
|
|
7287
7848
|
this.root = (options === null || options === void 0 ? void 0 : options.root) ?? "";
|
|
7849
|
+
this.mirrorsSourceFiles = git !== null;
|
|
7850
|
+
}
|
|
7851
|
+
/**
|
|
7852
|
+
* A deployment that cannot mirror a project which expects to be mirrored.
|
|
7853
|
+
*
|
|
7854
|
+
* This is the one shape of "no base" that exists, and it is not the one it
|
|
7855
|
+
* sounds like. A project with no repository is fine: the content service is
|
|
7856
|
+
* the store of record for it and mints its own commit shas, so there is
|
|
7857
|
+
* always somewhere for the commit to go. What is refused is the mismatch --
|
|
7858
|
+
* a project whose commits are mirrored into a repository, being published by
|
|
7859
|
+
* a build that was made before it had one and so has no commit to produce
|
|
7860
|
+
* that mirror against.
|
|
7861
|
+
*
|
|
7862
|
+
* It is a REAL state rather than a defensive check: it is exactly what a
|
|
7863
|
+
* deployment looks like between a project connecting a repository and its
|
|
7864
|
+
* next build going out. Left unchecked, such a publish writes the data and
|
|
7865
|
+
* silently fails to mirror it, and the repository quietly falls behind the
|
|
7866
|
+
* content nobody is told about.
|
|
7867
|
+
*
|
|
7868
|
+
* `null` when the service did not say (see `project` on the response
|
|
7869
|
+
* schema): an older content service is not evidence of anything, and
|
|
7870
|
+
* refusing every publish against one would be a worse failure than not
|
|
7871
|
+
* checking.
|
|
7872
|
+
*/
|
|
7873
|
+
publishRefusal() {
|
|
7874
|
+
var _this$projectExpectat;
|
|
7875
|
+
if (this.git !== null) {
|
|
7876
|
+
return null;
|
|
7877
|
+
}
|
|
7878
|
+
if (((_this$projectExpectat = this.projectExpectation) === null || _this$projectExpectat === void 0 ? void 0 : _this$projectExpectat.sourceMode) !== "connected") {
|
|
7879
|
+
return null;
|
|
7880
|
+
}
|
|
7881
|
+
return {
|
|
7882
|
+
code: "no-base",
|
|
7883
|
+
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."
|
|
7884
|
+
};
|
|
7288
7885
|
}
|
|
7289
7886
|
async onInit() {
|
|
7290
7887
|
// TODO: unused for now. Implement or remove
|
|
@@ -7475,16 +8072,31 @@ class ValOpsHttp extends ValOps {
|
|
|
7475
8072
|
* decided against.
|
|
7476
8073
|
*/
|
|
7477
8074
|
headCommitSha: newestCommitSha(allPatchData.commits) ?? undefined,
|
|
7478
|
-
|
|
8075
|
+
/*
|
|
8076
|
+
* Spread: a project with no repository has no such commit, and saying
|
|
8077
|
+
* so by leaving the key out is different from sending an empty string
|
|
8078
|
+
* the Studio would try to show.
|
|
8079
|
+
*/
|
|
8080
|
+
...(this.git ? {
|
|
8081
|
+
commitSha: this.git.commit
|
|
8082
|
+
} : {})
|
|
7479
8083
|
};
|
|
7480
8084
|
}
|
|
7481
8085
|
async getWebSocketNonce(profileId) {
|
|
7482
8086
|
return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
|
|
7483
8087
|
method: "POST",
|
|
7484
8088
|
body: JSON.stringify({
|
|
7485
|
-
branch: this.branch,
|
|
7486
8089
|
profileId,
|
|
7487
|
-
|
|
8090
|
+
/*
|
|
8091
|
+
* Omitted when there is no repository, like every other request here.
|
|
8092
|
+
* The content service knows this project's branch -- it is a column on
|
|
8093
|
+
* the project -- and a nonce is scoped to the project and the person,
|
|
8094
|
+
* not to a position in a chain.
|
|
8095
|
+
*/
|
|
8096
|
+
...(this.git ? {
|
|
8097
|
+
branch: this.git.branch,
|
|
8098
|
+
commitSha: this.git.commit
|
|
8099
|
+
} : {})
|
|
7488
8100
|
}),
|
|
7489
8101
|
headers: {
|
|
7490
8102
|
...this.authHeaders,
|
|
@@ -7632,8 +8244,20 @@ class ValOpsHttp extends ValOps {
|
|
|
7632
8244
|
}
|
|
7633
8245
|
async fetchPatchesInternal(filters) {
|
|
7634
8246
|
const params = [];
|
|
7635
|
-
|
|
7636
|
-
|
|
8247
|
+
/*
|
|
8248
|
+
* A position in the chain, WHEN THIS BUILD HAS ONE.
|
|
8249
|
+
*
|
|
8250
|
+
* `commit` tells the content service where this deployment sits, so it can
|
|
8251
|
+
* answer with the commits at or after that point. A build with no
|
|
8252
|
+
* repository has no such commit baked into it, and asking the service to
|
|
8253
|
+
* resolve the position itself is strictly better than inventing one: it
|
|
8254
|
+
* holds the chain, and for a project it is the only publisher of, the
|
|
8255
|
+
* position IS the head.
|
|
8256
|
+
*/
|
|
8257
|
+
if (this.git) {
|
|
8258
|
+
params.push(["branch", this.git.branch]);
|
|
8259
|
+
params.push(["commit", this.git.commit]);
|
|
8260
|
+
}
|
|
7637
8261
|
if (filters.patchIds) {
|
|
7638
8262
|
for (const patchId of filters.patchIds) {
|
|
7639
8263
|
params.push(["patch_id", patchId]);
|
|
@@ -7654,6 +8278,17 @@ class ValOpsHttp extends ValOps {
|
|
|
7654
8278
|
if (parsed.success) {
|
|
7655
8279
|
const errors = [];
|
|
7656
8280
|
const data = parsed.data;
|
|
8281
|
+
/*
|
|
8282
|
+
* What the project expects of its publisher, remembered.
|
|
8283
|
+
*
|
|
8284
|
+
* Recorded here rather than returned because every caller of this
|
|
8285
|
+
* already has what it needs and only the publish path asks the
|
|
8286
|
+
* question -- and that path calls this first. See
|
|
8287
|
+
* {@link publishRefusal}.
|
|
8288
|
+
*/
|
|
8289
|
+
if (data.project) {
|
|
8290
|
+
this.projectExpectation = data.project;
|
|
8291
|
+
}
|
|
7657
8292
|
for (const patchesRes of data.patches) {
|
|
7658
8293
|
var _patchesRes$applied;
|
|
7659
8294
|
patches.push({
|
|
@@ -7874,7 +8509,7 @@ class ValOpsHttp extends ValOps {
|
|
|
7874
8509
|
* `saveSourceFilePatch`): groups are per branch, so a request without one
|
|
7875
8510
|
* is not merely under-specified, it is rejected.
|
|
7876
8511
|
*/
|
|
7877
|
-
const params = new URLSearchParams([["branch", this.branch]]);
|
|
8512
|
+
const params = new URLSearchParams(this.git ? [["branch", this.git.branch]] : []);
|
|
7878
8513
|
const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
|
|
7879
8514
|
headers: this.authHeaders
|
|
7880
8515
|
});
|
|
@@ -8026,8 +8661,10 @@ class ValOpsHttp extends ValOps {
|
|
|
8026
8661
|
patchId,
|
|
8027
8662
|
parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
|
|
8028
8663
|
baseSha,
|
|
8029
|
-
|
|
8030
|
-
|
|
8664
|
+
...(this.git ? {
|
|
8665
|
+
commit: this.git.commit,
|
|
8666
|
+
branch: this.git.branch
|
|
8667
|
+
} : {}),
|
|
8031
8668
|
coreVersion: Internal.VERSION.core,
|
|
8032
8669
|
/*
|
|
8033
8670
|
* Group membership in the SAME request as the patch.
|
|
@@ -8193,11 +8830,28 @@ class ValOpsHttp extends ValOps {
|
|
|
8193
8830
|
});
|
|
8194
8831
|
}
|
|
8195
8832
|
async getSourceFile(path) {
|
|
8833
|
+
/*
|
|
8834
|
+
* There is no file to read without a repository to read it from.
|
|
8835
|
+
*
|
|
8836
|
+
* Reachable only through the CLI's debug snapshot now: the publish path
|
|
8837
|
+
* does not call this for such a project at all -- see
|
|
8838
|
+
* {@link ValOps.mirrorsSourceFiles} -- because there is nothing for it to
|
|
8839
|
+
* produce. It is an error rather than an empty string because an empty
|
|
8840
|
+
* `.val.ts` would be patched successfully and committed as a module that
|
|
8841
|
+
* had lost all its content.
|
|
8842
|
+
*/
|
|
8843
|
+
if (!this.git) {
|
|
8844
|
+
return {
|
|
8845
|
+
error: {
|
|
8846
|
+
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."
|
|
8847
|
+
}
|
|
8848
|
+
};
|
|
8849
|
+
}
|
|
8196
8850
|
const filesRes = await this.getHttpFiles([{
|
|
8197
8851
|
filePath: path,
|
|
8198
8852
|
location: "repo",
|
|
8199
8853
|
root: this.root,
|
|
8200
|
-
commitSha: this.
|
|
8854
|
+
commitSha: this.git.commit
|
|
8201
8855
|
}]);
|
|
8202
8856
|
if (filesRes.error) {
|
|
8203
8857
|
return filesRes;
|
|
@@ -8217,11 +8871,22 @@ class ValOpsHttp extends ValOps {
|
|
|
8217
8871
|
async getBinaryFile(filePath) {
|
|
8218
8872
|
// 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
|
|
8219
8873
|
const requestFiles = [];
|
|
8874
|
+
if (!this.git) {
|
|
8875
|
+
/*
|
|
8876
|
+
* A published binary lives in the repository, and there is none.
|
|
8877
|
+
*
|
|
8878
|
+
* `null` is this method's existing "not found", which is what a caller
|
|
8879
|
+
* already handles: the Studio falls back to the patch's own copy, which
|
|
8880
|
+
* is where a managed project's files stay. See the note on local files
|
|
8881
|
+
* in the content service's commit handler.
|
|
8882
|
+
*/
|
|
8883
|
+
return null;
|
|
8884
|
+
}
|
|
8220
8885
|
requestFiles.push({
|
|
8221
8886
|
filePath: filePath,
|
|
8222
8887
|
location: "repo",
|
|
8223
8888
|
root: this.root,
|
|
8224
|
-
commitSha: this.
|
|
8889
|
+
commitSha: this.git.commit
|
|
8225
8890
|
});
|
|
8226
8891
|
const filesRes = await this.getHttpFiles(requestFiles);
|
|
8227
8892
|
if (filesRes.error) {
|
|
@@ -8400,7 +9065,6 @@ class ValOpsHttp extends ValOps {
|
|
|
8400
9065
|
patchGroupId) {
|
|
8401
9066
|
try {
|
|
8402
9067
|
var _res$headers$get3;
|
|
8403
|
-
const existingBranch = this.branch;
|
|
8404
9068
|
const res = await fetch(`${this.contentUrl}/v1/${this.project}/commit`, {
|
|
8405
9069
|
method: "POST",
|
|
8406
9070
|
headers: {
|
|
@@ -8444,13 +9108,29 @@ class ValOpsHttp extends ValOps {
|
|
|
8444
9108
|
* moved on.
|
|
8445
9109
|
*/
|
|
8446
9110
|
modules: prepared.moduleVersions,
|
|
8447
|
-
|
|
9111
|
+
/*
|
|
9112
|
+
* WHERE THIS BUILD THOUGHT IT WAS -- and only for a project with a
|
|
9113
|
+
* repository, which is the only thing that still reads it.
|
|
9114
|
+
*
|
|
9115
|
+
* The content service mints its own commit shas, so this is no
|
|
9116
|
+
* longer the parent, and it never was the concurrency check: that is
|
|
9117
|
+
* `expectedHeadCommitSha` against `newestCommitSha(patches.commits)`,
|
|
9118
|
+
* which `/save` runs before it gets here. What is left is the git
|
|
9119
|
+
* fast-forward check, which needs to know the commit this build read
|
|
9120
|
+
* the branch at to tell whether the branch has moved under it.
|
|
9121
|
+
*
|
|
9122
|
+
* The branch goes with it. The project's branch is a column on the
|
|
9123
|
+
* project, so a build asserting one could only ever disagree with
|
|
9124
|
+
* the project it is publishing to.
|
|
9125
|
+
*/
|
|
9126
|
+
...(this.git ? {
|
|
9127
|
+
commit: this.git.commit
|
|
9128
|
+
} : {}),
|
|
8448
9129
|
root: this.root,
|
|
8449
9130
|
filesDirectory,
|
|
8450
9131
|
baseSha: await this.getBaseSha(),
|
|
8451
9132
|
committer,
|
|
8452
9133
|
message,
|
|
8453
|
-
existingBranch,
|
|
8454
9134
|
newBranch,
|
|
8455
9135
|
...(patchGroupId !== undefined ? {
|
|
8456
9136
|
patchGroupId
|
|
@@ -8463,7 +9143,19 @@ class ValOpsHttp extends ValOps {
|
|
|
8463
9143
|
return {
|
|
8464
9144
|
updatedFiles: parsed.data.updatedFiles,
|
|
8465
9145
|
commit: parsed.data.commit,
|
|
8466
|
-
branch: parsed.data.branch
|
|
9146
|
+
branch: parsed.data.branch,
|
|
9147
|
+
/*
|
|
9148
|
+
* Spread rather than set, so a service that did not report them
|
|
9149
|
+
* leaves the keys ABSENT rather than present-and-undefined. A
|
|
9150
|
+
* caller doing `'parent' in result` then gets the truthful answer,
|
|
9151
|
+
* and JSON round-trips of this object do not grow null fields.
|
|
9152
|
+
*/
|
|
9153
|
+
...(parsed.data.parent !== undefined ? {
|
|
9154
|
+
parent: parsed.data.parent
|
|
9155
|
+
} : {}),
|
|
9156
|
+
...(parsed.data.tree !== undefined ? {
|
|
9157
|
+
tree: parsed.data.tree
|
|
9158
|
+
} : {})
|
|
8467
9159
|
};
|
|
8468
9160
|
}
|
|
8469
9161
|
return {
|
|
@@ -10187,7 +10879,7 @@ async function initHandlerOptions(route, opts, config) {
|
|
|
10187
10879
|
valContentUrl
|
|
10188
10880
|
});
|
|
10189
10881
|
if (isProxyMode) {
|
|
10190
|
-
var _opts$versions, _opts$versions2;
|
|
10882
|
+
var _opts$git, _opts$git2, _opts$versions, _opts$versions2;
|
|
10191
10883
|
/*
|
|
10192
10884
|
* Why this app is in http mode, in the message that says what is missing.
|
|
10193
10885
|
*
|
|
@@ -10200,13 +10892,30 @@ async function initHandlerOptions(route, opts, config) {
|
|
|
10200
10892
|
if (!maybeApiKey || !maybeValSecret) {
|
|
10201
10893
|
throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode" + because);
|
|
10202
10894
|
}
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
10895
|
+
/*
|
|
10896
|
+
* A COMMIT IS NOT WHAT PUTS AN APP IN HTTP MODE. Credentials are.
|
|
10897
|
+
*
|
|
10898
|
+
* Both of these used to be required here, and the requirement was a
|
|
10899
|
+
* repository disguised as a configuration check: a deployment with no
|
|
10900
|
+
* commit to name -- one whose content service owns its content, which is
|
|
10901
|
+
* now the normal case -- threw at boot, or, worse, never reached this
|
|
10902
|
+
* branch at all and fell through to `fs` mode, looking for a working tree
|
|
10903
|
+
* that was not there.
|
|
10904
|
+
*
|
|
10905
|
+
* Absent is a project with no repository to mirror commits into. The
|
|
10906
|
+
* content service mints its own commit shas and is the store of record for
|
|
10907
|
+
* content, so there is nothing missing: see `git` on {@link ValApiOptions}
|
|
10908
|
+
* for what a commit is still FOR where there is one.
|
|
10909
|
+
*
|
|
10910
|
+
* Taken together or not at all. A commit without a branch names a point
|
|
10911
|
+
* with no line of work to publish to, and a branch without a commit names
|
|
10912
|
+
* a line with no position in it; either alone would be a half-configured
|
|
10913
|
+
* repository that fails later, at a publish, rather than here.
|
|
10914
|
+
*/
|
|
10915
|
+
const maybeGitCommit = ((_opts$git = opts.git) === null || _opts$git === void 0 ? void 0 : _opts$git.commit) || process.env.VAL_GIT_COMMIT;
|
|
10916
|
+
const maybeGitBranch = ((_opts$git2 = opts.git) === null || _opts$git2 === void 0 ? void 0 : _opts$git2.branch) || process.env.VAL_GIT_BRANCH;
|
|
10917
|
+
if (!!maybeGitCommit !== !!maybeGitBranch) {
|
|
10918
|
+
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);
|
|
10210
10919
|
}
|
|
10211
10920
|
if (!maybeValProject) {
|
|
10212
10921
|
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);
|
|
@@ -10224,8 +10933,19 @@ async function initHandlerOptions(route, opts, config) {
|
|
|
10224
10933
|
route,
|
|
10225
10934
|
apiKey: maybeApiKey,
|
|
10226
10935
|
valSecret: maybeValSecret,
|
|
10227
|
-
|
|
10228
|
-
|
|
10936
|
+
/*
|
|
10937
|
+
* Spread, so a project with no repository has no `git` key at all rather
|
|
10938
|
+
* than one holding undefined. `ValOpsHttp` asks `git === null` to decide
|
|
10939
|
+
* whether to send a branch and a commit with every request, and a key
|
|
10940
|
+
* that is present-but-undefined is one more thing for that check to get
|
|
10941
|
+
* wrong.
|
|
10942
|
+
*/
|
|
10943
|
+
...(maybeGitCommit && maybeGitBranch ? {
|
|
10944
|
+
git: {
|
|
10945
|
+
commit: maybeGitCommit,
|
|
10946
|
+
branch: maybeGitBranch
|
|
10947
|
+
}
|
|
10948
|
+
} : {}),
|
|
10229
10949
|
root: opts.root,
|
|
10230
10950
|
project: maybeValProject,
|
|
10231
10951
|
valEnableRedirectUrl,
|
|
@@ -10288,7 +11008,7 @@ function createValOps(valModules, options) {
|
|
|
10288
11008
|
});
|
|
10289
11009
|
}
|
|
10290
11010
|
if (options.mode === "http") {
|
|
10291
|
-
return new ValOpsHttp(options.valContentUrl, options.project, options.
|
|
11011
|
+
return new ValOpsHttp(options.valContentUrl, options.project, options.git ?? null, {
|
|
10292
11012
|
apiKey: options.apiKey
|
|
10293
11013
|
}, valModules, {
|
|
10294
11014
|
formatter: options.formatter,
|
|
@@ -10494,6 +11214,7 @@ async function resolveRemoteFileAuth(options) {
|
|
|
10494
11214
|
/** What a publish reports back, in either shape. */
|
|
10495
11215
|
|
|
10496
11216
|
const ValServer = (valModules, options, callbacks) => {
|
|
11217
|
+
var _options$git;
|
|
10497
11218
|
const AIContentBlock = z.union([z.object({
|
|
10498
11219
|
type: z.literal("text"),
|
|
10499
11220
|
text: z.string()
|
|
@@ -10525,7 +11246,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
10525
11246
|
url.searchParams.set("state", token);
|
|
10526
11247
|
return url.toString();
|
|
10527
11248
|
};
|
|
10528
|
-
const commit = options.mode === "http" ? options.commit : undefined;
|
|
11249
|
+
const commit = options.mode === "http" ? (_options$git = options.git) === null || _options$git === void 0 ? void 0 : _options$git.commit : undefined;
|
|
10529
11250
|
const getAppErrorUrl = error => {
|
|
10530
11251
|
if (!options.project) {
|
|
10531
11252
|
throw new Error("Project is not set");
|
|
@@ -11122,12 +11843,24 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
11122
11843
|
* start against a server that was working.
|
|
11123
11844
|
*/
|
|
11124
11845
|
const mode = serverOps.patchesAreLocal ? "fs" : "http";
|
|
11846
|
+
/*
|
|
11847
|
+
* Why publishing is unavailable, so the Studio can say so.
|
|
11848
|
+
*
|
|
11849
|
+
* Spread rather than set to null, so a server that can publish sends
|
|
11850
|
+
* no such key at all: the Studio's "can I publish" is then the
|
|
11851
|
+
* presence of the field, and there is no null to mistake for a reason
|
|
11852
|
+
* it failed to compute.
|
|
11853
|
+
*/
|
|
11854
|
+
const publishRefusal = serverOps.publishRefusal();
|
|
11125
11855
|
return {
|
|
11126
11856
|
status: 200,
|
|
11127
11857
|
json: {
|
|
11128
11858
|
...currentStat,
|
|
11129
11859
|
profileId: profileId ?? null,
|
|
11130
11860
|
mode,
|
|
11861
|
+
...(publishRefusal ? {
|
|
11862
|
+
publishRefusal
|
|
11863
|
+
} : {}),
|
|
11131
11864
|
config: options.config
|
|
11132
11865
|
}
|
|
11133
11866
|
};
|
|
@@ -12316,6 +13049,29 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12316
13049
|
patchIds,
|
|
12317
13050
|
excludePatchOps: false
|
|
12318
13051
|
});
|
|
13052
|
+
/*
|
|
13053
|
+
* Can this deployment publish this project AT ALL?
|
|
13054
|
+
*
|
|
13055
|
+
* After the fetch above, deliberately: that call is what tells the
|
|
13056
|
+
* data layer what the project expects, and asking before it would be
|
|
13057
|
+
* asking a question nothing has answered yet. It is still before
|
|
13058
|
+
* anything is written, which is the part that matters.
|
|
13059
|
+
*
|
|
13060
|
+
* The Studio already knows -- `/stat` carries the same refusal, so the
|
|
13061
|
+
* action is disabled with the reason shown. This is the second half of
|
|
13062
|
+
* that: a stat can be stale by a poll, and nothing may reach `prepare`
|
|
13063
|
+
* on a project it cannot mirror.
|
|
13064
|
+
*/
|
|
13065
|
+
const refusal = serverOps.publishRefusal();
|
|
13066
|
+
if (refusal) {
|
|
13067
|
+
return {
|
|
13068
|
+
status: 409,
|
|
13069
|
+
json: {
|
|
13070
|
+
message: refusal.message,
|
|
13071
|
+
publishRefusal: refusal
|
|
13072
|
+
}
|
|
13073
|
+
};
|
|
13074
|
+
}
|
|
12319
13075
|
/*
|
|
12320
13076
|
* Exactly the patches this request consumes, and the ONLY ones it may
|
|
12321
13077
|
* delete afterwards.
|