@valbuild/server 0.132.0 → 0.132.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +4 -0
- package/dist/declarations/src/ValServer.d.ts +31 -0
- package/dist/declarations/src/patch/jsonValuesPatch.d.ts +69 -1
- package/dist/valbuild-server.cjs.dev.js +760 -312
- package/dist/valbuild-server.cjs.prod.js +760 -312
- package/dist/valbuild-server.esm.js +761 -313
- package/package.json +4 -4
|
@@ -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
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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:
|
|
1559
|
+
message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
|
|
1335
1560
|
patchId
|
|
1336
1561
|
};
|
|
1337
1562
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
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:
|
|
1567
|
+
message: rebased.error.message,
|
|
1360
1568
|
patchId
|
|
1361
1569
|
};
|
|
1362
1570
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
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
|
-
|
|
3108
|
-
|
|
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
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
op
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
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:
|
|
3124
|
-
path: op.
|
|
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
|
|
3543
|
-
//
|
|
3544
|
-
|
|
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
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
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 =
|
|
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
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
}
|
|
3661
|
-
|
|
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
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
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 =
|
|
4072
|
+
tsSourceFile = patchRes.value;
|
|
3714
4073
|
tsChanged = true;
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
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
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
//
|
|
3728
|
-
|
|
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}'
|
|
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
|
|
3740
|
-
|
|
3741
|
-
|
|
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
|
-
|
|
3747
|
-
|
|
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
|
|
3755
|
-
if (
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
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
|
-
|
|
3774
|
-
|
|
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
|
-
|
|
3783
|
-
|
|
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;
|
|
3816
|
-
}
|
|
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;
|
|
4259
|
+
jsonEntryContents.set(jsonPath, applied.value);
|
|
4260
|
+
jsonEntryContentsByKey.set(cls.entryKey, applied.value);
|
|
3838
4261
|
}
|
|
3839
|
-
|
|
3840
|
-
|
|
4262
|
+
}
|
|
4263
|
+
if (patchHadError) {
|
|
4264
|
+
break;
|
|
3841
4265
|
}
|
|
3842
4266
|
}
|
|
3843
4267
|
if (patchHadError) {
|
|
@@ -7170,6 +7594,18 @@ const SavePatchFileResponse = z.z.object({
|
|
|
7170
7594
|
const CommitResponse = z.z.object({
|
|
7171
7595
|
updatedFiles: z.z.array(z.z.string()),
|
|
7172
7596
|
commit: CommitSha,
|
|
7597
|
+
/*
|
|
7598
|
+
* Optional because a content service that predates them sends neither, and
|
|
7599
|
+
* this client talks to whichever one the project is on. Absent means NOT
|
|
7600
|
+
* REPORTED: `parent: undefined` is not "a root commit", and a caller that
|
|
7601
|
+
* reads it as one would conclude the history starts here.
|
|
7602
|
+
*
|
|
7603
|
+
* Unbranded strings deliberately -- see `CommitResult.parent`. They arrive
|
|
7604
|
+
* from a service that versions separately and are passed through as what it
|
|
7605
|
+
* said, not as something this end vouched for.
|
|
7606
|
+
*/
|
|
7607
|
+
parent: z.z.string().optional(),
|
|
7608
|
+
tree: z.z.string().optional(),
|
|
7173
7609
|
branch: z.z.string()
|
|
7174
7610
|
});
|
|
7175
7611
|
// #region history wire schemas
|
|
@@ -8497,7 +8933,19 @@ class ValOpsHttp extends ValOps {
|
|
|
8497
8933
|
return {
|
|
8498
8934
|
updatedFiles: parsed.data.updatedFiles,
|
|
8499
8935
|
commit: parsed.data.commit,
|
|
8500
|
-
branch: parsed.data.branch
|
|
8936
|
+
branch: parsed.data.branch,
|
|
8937
|
+
/*
|
|
8938
|
+
* Spread rather than set, so a service that did not report them
|
|
8939
|
+
* leaves the keys ABSENT rather than present-and-undefined. A
|
|
8940
|
+
* caller doing `'parent' in result` then gets the truthful answer,
|
|
8941
|
+
* and JSON round-trips of this object do not grow null fields.
|
|
8942
|
+
*/
|
|
8943
|
+
...(parsed.data.parent !== undefined ? {
|
|
8944
|
+
parent: parsed.data.parent
|
|
8945
|
+
} : {}),
|
|
8946
|
+
...(parsed.data.tree !== undefined ? {
|
|
8947
|
+
tree: parsed.data.tree
|
|
8948
|
+
} : {})
|
|
8501
8949
|
};
|
|
8502
8950
|
}
|
|
8503
8951
|
return {
|