@valbuild/server 0.99.0 → 0.100.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/dist/declarations/src/ValOps.d.ts +95 -2
- package/dist/declarations/src/ValOpsFS.d.ts +7 -0
- package/dist/declarations/src/extractJsonValuesEntry.d.ts +17 -0
- package/dist/declarations/src/index.d.ts +3 -1
- package/dist/declarations/src/jsonEntryLocation.d.ts +17 -0
- package/dist/declarations/src/modulePathMap.d.ts +10 -0
- package/dist/valbuild-server.cjs.dev.js +1775 -91
- package/dist/valbuild-server.cjs.prod.js +1775 -91
- package/dist/valbuild-server.esm.js +1776 -95
- package/package.json +4 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
-
import { result,
|
|
3
|
-
import { FILE_REF_PROP, FILE_REF_SUBTYPE_TAG, VAL_EXTENSION, derefPatch,
|
|
4
|
-
import {
|
|
2
|
+
import { pipe, result, array } from '@valbuild/core/fp';
|
|
3
|
+
import { FILE_REF_PROP, FILE_REF_SUBTYPE_TAG, VAL_EXTENSION, derefPatch, RecordSchema, Internal, extractValModules, ImageSchema, DEFAULT_CONTENT_HOST } from '@valbuild/core';
|
|
4
|
+
import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import path__default from 'path';
|
|
7
7
|
import fs, { promises } from 'fs';
|
|
@@ -345,6 +345,65 @@ function createValRemoteReference(value) {
|
|
|
345
345
|
}
|
|
346
346
|
return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("c"), ts.factory.createIdentifier("remote")), undefined, args);
|
|
347
347
|
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Builds the expression `c.json(() => import("<importPath>"))` used to reference
|
|
351
|
+
* a lazily-loaded `*.val.json` entry of a `.jsonValues()` record.
|
|
352
|
+
*/
|
|
353
|
+
function createValJsonReference(importPath) {
|
|
354
|
+
// () => import("<importPath>")
|
|
355
|
+
// NOTE: an `import` identifier prints as the dynamic-import keyword call,
|
|
356
|
+
// which avoids casting the ImportKeyword token (not typed as an Expression).
|
|
357
|
+
const importCall = ts.factory.createCallExpression(ts.factory.createIdentifier("import"), undefined, [ts.factory.createStringLiteral(importPath)]);
|
|
358
|
+
const thunk = ts.factory.createArrowFunction(undefined, undefined, [], undefined, ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), importCall);
|
|
359
|
+
// c.json(<thunk>)
|
|
360
|
+
return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier("c"), ts.factory.createIdentifier("json")), undefined, [thunk]);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Inserts a new `.jsonValues()` entry `"<key>": c.json(() => import("<importPath>"))`
|
|
365
|
+
* into the record's object literal in a `.val.ts` module. `recordPath` is the
|
|
366
|
+
* path from the module source to the record (empty for a root record/router).
|
|
367
|
+
* Fails if the entry key already exists.
|
|
368
|
+
*/
|
|
369
|
+
function insertValJsonEntry(document, recordPath, key, importPath) {
|
|
370
|
+
return pipe(analyzeValModule(document), result.flatMap(({
|
|
371
|
+
source
|
|
372
|
+
}) => getAtPath(source, recordPath)), result.flatMap(recordNode => {
|
|
373
|
+
if (!ts.isObjectLiteralExpression(recordNode)) {
|
|
374
|
+
return result.err(new PatchError("Cannot add jsonValues entry: record source is not an object literal"));
|
|
375
|
+
}
|
|
376
|
+
return pipe(findObjectPropertyAssignment(recordNode, key), result.flatMap(assignment => {
|
|
377
|
+
if (assignment) {
|
|
378
|
+
return result.err(new PatchError(`Cannot add jsonValues entry '${key}': it already exists`));
|
|
379
|
+
}
|
|
380
|
+
const property = ts.factory.createPropertyAssignment(isValidIdentifier(key) ? ts.factory.createIdentifier(key) : ts.factory.createStringLiteral(key), createValJsonReference(importPath));
|
|
381
|
+
return result.ok(insertAt(document, recordNode.properties, recordNode.properties.length, property));
|
|
382
|
+
}));
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Removes the `.jsonValues()` entry `"<key>"` from the record's object literal
|
|
388
|
+
* in a `.val.ts` module. `recordPath` is the path from the module source to the
|
|
389
|
+
* record (empty for a root record/router). Fails if the entry key is missing.
|
|
390
|
+
*/
|
|
391
|
+
function removeValJsonEntry(document, recordPath, key) {
|
|
392
|
+
return pipe(analyzeValModule(document), result.flatMap(({
|
|
393
|
+
source
|
|
394
|
+
}) => getAtPath(source, recordPath)), result.flatMap(recordNode => {
|
|
395
|
+
if (!ts.isObjectLiteralExpression(recordNode)) {
|
|
396
|
+
return result.err(new PatchError("Cannot remove jsonValues entry: record source is not an object literal"));
|
|
397
|
+
}
|
|
398
|
+
return pipe(findObjectPropertyAssignment(recordNode, key), result.flatMap(assignment => {
|
|
399
|
+
if (!assignment) {
|
|
400
|
+
return result.err(new PatchError(`Cannot remove jsonValues entry '${key}': it does not exist`));
|
|
401
|
+
}
|
|
402
|
+
const [doc] = removeAt(document, recordNode.properties, recordNode.properties.indexOf(assignment));
|
|
403
|
+
return result.ok(doc);
|
|
404
|
+
}));
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
348
407
|
function toExpression(value) {
|
|
349
408
|
if (typeof value === "string") {
|
|
350
409
|
// TODO: Use configuration/heuristics to determine use of single quote or double quote
|
|
@@ -979,7 +1038,7 @@ function findValModulesPath(projectRoot, host) {
|
|
|
979
1038
|
}
|
|
980
1039
|
return null;
|
|
981
1040
|
}
|
|
982
|
-
const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"];
|
|
1041
|
+
const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs", ".json"];
|
|
983
1042
|
|
|
984
1043
|
// Specifiers that user val files must not actually use. We stub them so that
|
|
985
1044
|
// importing is fine, but using a value throws a clear error. Real @valbuild
|
|
@@ -1019,6 +1078,27 @@ function loadModule(absPath, cache, compilerOptions, host) {
|
|
|
1019
1078
|
if (cached) {
|
|
1020
1079
|
return cached;
|
|
1021
1080
|
}
|
|
1081
|
+
// JSON modules (e.g. the `*.val.json` files backing `.jsonValues()` entries)
|
|
1082
|
+
// are loaded by parsing, mirroring Node's `require("./x.json")` which returns
|
|
1083
|
+
// the parsed object as `module.exports`. The importing `.val.ts` wraps this
|
|
1084
|
+
// with `__importStar` so `import("./x.val.json")` yields `{ default, ... }`.
|
|
1085
|
+
// These are only loaded when an entry thunk is invoked, never during
|
|
1086
|
+
// `extractValModules`, so this stays lazy.
|
|
1087
|
+
//
|
|
1088
|
+
// Read through `host` like every other project file: an editor integration
|
|
1089
|
+
// passes a host that overlays unsaved buffers, and an entry the user is
|
|
1090
|
+
// editing has to resolve to what they are looking at.
|
|
1091
|
+
if (absPath.endsWith(".json")) {
|
|
1092
|
+
const json = host.readFile(absPath);
|
|
1093
|
+
if (json === undefined) {
|
|
1094
|
+
throw Error(`Could not read Val module file: '${absPath}'`);
|
|
1095
|
+
}
|
|
1096
|
+
const jsonModule = {
|
|
1097
|
+
exports: JSON.parse(json)
|
|
1098
|
+
};
|
|
1099
|
+
cache[absPath] = jsonModule;
|
|
1100
|
+
return jsonModule;
|
|
1101
|
+
}
|
|
1022
1102
|
const code = host.readFile(absPath);
|
|
1023
1103
|
if (code === undefined) {
|
|
1024
1104
|
throw Error(`Could not read Val module file: '${absPath}'`);
|
|
@@ -1107,6 +1187,403 @@ function resolveRelative(dirName, spec, host) {
|
|
|
1107
1187
|
return null;
|
|
1108
1188
|
}
|
|
1109
1189
|
|
|
1190
|
+
const jsonOps$1 = new JSONOps();
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Classification of a single patch op against a module's serialized schema,
|
|
1194
|
+
* used by the commit flow to route ops for `.jsonValues()` records:
|
|
1195
|
+
*
|
|
1196
|
+
* - `normal`: the op does not descend into a `.jsonValues()` entry; apply it to
|
|
1197
|
+
* the `.val.ts` as usual.
|
|
1198
|
+
* - `entry`: the op targets a `.jsonValues()` entry. `recordPath` is the path to
|
|
1199
|
+
* the record within the module source (empty for a root record/router),
|
|
1200
|
+
* `entryKey` is the entry key, and `subPath` is the remaining path inside the
|
|
1201
|
+
* entry (empty when the op targets the entry value itself, e.g. add/remove of
|
|
1202
|
+
* the whole entry).
|
|
1203
|
+
*/
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Walks the serialized schema following the op path. When a `.jsonValues()`
|
|
1207
|
+
* record is encountered, the next path segment is the entry key and everything
|
|
1208
|
+
* after it lives inside the entry's `*.val.json` (so it does not touch the
|
|
1209
|
+
* `.val.ts`). Returns `{ kind: "normal" }` when the op never enters a
|
|
1210
|
+
* `.jsonValues()` record.
|
|
1211
|
+
*/
|
|
1212
|
+
function classifyJsonValuesOp(schema, opPath) {
|
|
1213
|
+
let current = schema;
|
|
1214
|
+
const recordPath = [];
|
|
1215
|
+
for (let i = 0; i < opPath.length; i++) {
|
|
1216
|
+
if (!current) {
|
|
1217
|
+
return {
|
|
1218
|
+
kind: "normal"
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
if (current.type === "record" && current.jsonValues) {
|
|
1222
|
+
return {
|
|
1223
|
+
kind: "entry",
|
|
1224
|
+
recordPath: recordPath.slice(),
|
|
1225
|
+
entryKey: opPath[i],
|
|
1226
|
+
subPath: opPath.slice(i + 1)
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
const seg = opPath[i];
|
|
1230
|
+
current = descend(current, seg);
|
|
1231
|
+
recordPath.push(seg);
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
kind: "normal"
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
function descend(schema, key) {
|
|
1238
|
+
switch (schema.type) {
|
|
1239
|
+
case "object":
|
|
1240
|
+
return schema.items[key];
|
|
1241
|
+
case "record":
|
|
1242
|
+
return schema.item;
|
|
1243
|
+
case "array":
|
|
1244
|
+
return schema.item;
|
|
1245
|
+
default:
|
|
1246
|
+
// Unions / primitives / leaf schemas: we cannot (or need not) descend
|
|
1247
|
+
// further to find a jsonValues record. Anything below is a normal
|
|
1248
|
+
// `.val.ts` edit.
|
|
1249
|
+
return undefined;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Finds every `.jsonValues()` record in a module's schema that is NOT the
|
|
1255
|
+
* module's root, returning the path to each within the module source.
|
|
1256
|
+
*
|
|
1257
|
+
* `.jsonValues()` is only supported on a module's ROOT record/router: the
|
|
1258
|
+
* `/json` endpoint keys entries by a single string, the Studio substitutes
|
|
1259
|
+
* loaded content at the top level of the module source, and
|
|
1260
|
+
* `validateJsonValuesEntries` only visits a root record. A nested one would
|
|
1261
|
+
* silently skip content validation and hang the Studio on a 404, so we reject
|
|
1262
|
+
* it up front instead (see {@link ValOps.initSources}).
|
|
1263
|
+
*/
|
|
1264
|
+
function findNestedJsonValuesRecords(schema, path = []) {
|
|
1265
|
+
const found = [];
|
|
1266
|
+
const rec = (current, currentPath) => {
|
|
1267
|
+
if (current.type === "record" && current.jsonValues && currentPath.length > 0) {
|
|
1268
|
+
found.push(currentPath);
|
|
1269
|
+
// Do not descend: everything below lives in the entry's `*.val.json`.
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
switch (current.type) {
|
|
1273
|
+
case "object":
|
|
1274
|
+
for (const key of Object.keys(current.items)) {
|
|
1275
|
+
rec(current.items[key], currentPath.concat(key));
|
|
1276
|
+
}
|
|
1277
|
+
return;
|
|
1278
|
+
case "record":
|
|
1279
|
+
rec(current.item, currentPath.concat("*"));
|
|
1280
|
+
return;
|
|
1281
|
+
case "array":
|
|
1282
|
+
rec(current.item, currentPath.concat("*"));
|
|
1283
|
+
return;
|
|
1284
|
+
case "union":
|
|
1285
|
+
for (let i = 0; i < current.items.length; i++) {
|
|
1286
|
+
rec(current.items[i], currentPath.concat(`union[${i}]`));
|
|
1287
|
+
}
|
|
1288
|
+
return;
|
|
1289
|
+
default:
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
rec(schema, path);
|
|
1294
|
+
return found;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* The `.val.ts` suffix a module file path ends with. Stripping it yields the
|
|
1299
|
+
* folder that a new entry's `*.val.json` files are nested under.
|
|
1300
|
+
*/
|
|
1301
|
+
const VAL_TS_SUFFIX = ".val.ts";
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Computes the `*.val.json` file path (relative to rootDir) and the `import(...)`
|
|
1305
|
+
* path (relative to the module's directory) for a NEW `.jsonValues()` entry,
|
|
1306
|
+
* following the locked filename convention: the file mirrors the entry key under
|
|
1307
|
+
* a folder named after the `.val.ts` (its `.val.ts` suffix becomes the folder).
|
|
1308
|
+
*
|
|
1309
|
+
* For module `/app/support/[slug]/page.val.ts` and key `/support/faq`:
|
|
1310
|
+
* - jsonPath: `/app/support/[slug]/page/support/faq.val.json`
|
|
1311
|
+
* - importPath: `./page/support/faq.val.json`
|
|
1312
|
+
*/
|
|
1313
|
+
function getNewJsonEntryPaths(moduleFilePath, entryKey) {
|
|
1314
|
+
const base = moduleFilePath.endsWith(VAL_TS_SUFFIX) ? moduleFilePath.slice(0, -VAL_TS_SUFFIX.length) : moduleFilePath;
|
|
1315
|
+
const keyRel = entryKey.replace(/^\//, "");
|
|
1316
|
+
const invalid = reason => result.err({
|
|
1317
|
+
message: `Invalid .jsonValues() entry key '${entryKey}' in ${moduleFilePath}: ${reason}`,
|
|
1318
|
+
filePath: moduleFilePath
|
|
1319
|
+
});
|
|
1320
|
+
// An entry key is CLIENT-SUPPLIED — it arrives as a record key in a patch op —
|
|
1321
|
+
// and this path is what the commit writes to disk. Left unchecked, a key with
|
|
1322
|
+
// `..` segments (or a backslash, which is a separator once the path is handed
|
|
1323
|
+
// to node's `path.join` on Windows) puts that write anywhere in the project or
|
|
1324
|
+
// outside it entirely, and a `move` additionally deletes the source path.
|
|
1325
|
+
if (keyRel === "") {
|
|
1326
|
+
return invalid("it is empty");
|
|
1327
|
+
}
|
|
1328
|
+
if (keyRel.includes("\\") || keyRel.includes("\0")) {
|
|
1329
|
+
return invalid("it contains a backslash or a NUL byte");
|
|
1330
|
+
}
|
|
1331
|
+
const jsonPath = path.posix.normalize(`${base}/${keyRel}.val.json`);
|
|
1332
|
+
if (!jsonPath.startsWith(`${base}/`)) {
|
|
1333
|
+
return invalid(`it resolves outside '${base}/'`);
|
|
1334
|
+
}
|
|
1335
|
+
const moduleDir = path.posix.dirname(moduleFilePath);
|
|
1336
|
+
let importPath = path.posix.relative(moduleDir, jsonPath);
|
|
1337
|
+
if (!importPath.startsWith(".")) {
|
|
1338
|
+
importPath = `./${importPath}`;
|
|
1339
|
+
}
|
|
1340
|
+
return result.ok({
|
|
1341
|
+
jsonPath,
|
|
1342
|
+
importPath
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Rebases a patch op that targets a `.jsonValues()` entry's content so its paths
|
|
1348
|
+
* are relative to the entry's `*.val.json` root (drops the record + entry-key
|
|
1349
|
+
* prefix). Used to replay the op against the backing JSON file.
|
|
1350
|
+
*/
|
|
1351
|
+
function rebaseContentOp(op, prefixLen) {
|
|
1352
|
+
const path = op.path.slice(prefixLen);
|
|
1353
|
+
switch (op.op) {
|
|
1354
|
+
case "add":
|
|
1355
|
+
case "replace":
|
|
1356
|
+
case "test":
|
|
1357
|
+
return result.ok({
|
|
1358
|
+
...op,
|
|
1359
|
+
path
|
|
1360
|
+
});
|
|
1361
|
+
case "remove":
|
|
1362
|
+
{
|
|
1363
|
+
if (!array.isNonEmpty(path)) {
|
|
1364
|
+
return result.err(new PatchError("Cannot remove the root of a jsonValues entry"));
|
|
1365
|
+
}
|
|
1366
|
+
return result.ok({
|
|
1367
|
+
...op,
|
|
1368
|
+
path
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
case "move":
|
|
1372
|
+
{
|
|
1373
|
+
const from = op.from.slice(prefixLen);
|
|
1374
|
+
if (!array.isNonEmpty(from)) {
|
|
1375
|
+
return result.err(new PatchError("Cannot move from the root of a jsonValues entry"));
|
|
1376
|
+
}
|
|
1377
|
+
return result.ok({
|
|
1378
|
+
...op,
|
|
1379
|
+
path,
|
|
1380
|
+
from
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
case "copy":
|
|
1384
|
+
return result.ok({
|
|
1385
|
+
...op,
|
|
1386
|
+
path,
|
|
1387
|
+
from: op.from.slice(prefixLen)
|
|
1388
|
+
});
|
|
1389
|
+
case "file":
|
|
1390
|
+
return result.err(new PatchError("Cannot apply a file op to a jsonValues entry"));
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** The outcome of replaying pending patches onto one `.jsonValues()` entry. */
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Replays the ops of `patches` that target ONE `.jsonValues()` entry onto its
|
|
1398
|
+
* committed content, yielding the entry's draft content.
|
|
1399
|
+
*
|
|
1400
|
+
* This is the read-side counterpart to the commit flow in `ValOps.prepare`:
|
|
1401
|
+
* both route ops with {@link classifyJsonValuesOp} and replay content sub-ops
|
|
1402
|
+
* with {@link rebaseContentOp}, but this one produces a value instead of files
|
|
1403
|
+
* and never touches the `.val.ts`.
|
|
1404
|
+
*
|
|
1405
|
+
* Root-only, like the rest of the `.jsonValues()` machinery: ops targeting a
|
|
1406
|
+
* nested record are ignored (nested `.jsonValues()` is rejected at startup).
|
|
1407
|
+
*/
|
|
1408
|
+
function applyJsonValuesEntryPatches(args) {
|
|
1409
|
+
const {
|
|
1410
|
+
serializedSchema,
|
|
1411
|
+
entryKey,
|
|
1412
|
+
baseContent,
|
|
1413
|
+
patches
|
|
1414
|
+
} = args;
|
|
1415
|
+
let content = baseContent;
|
|
1416
|
+
let deleted = false;
|
|
1417
|
+
const appliedPatchIds = [];
|
|
1418
|
+
for (const {
|
|
1419
|
+
patchId,
|
|
1420
|
+
patch
|
|
1421
|
+
} of patches) {
|
|
1422
|
+
let touched = false;
|
|
1423
|
+
for (const op of patch) {
|
|
1424
|
+
if (op.op === "file") {
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
|
|
1428
|
+
kind: "normal"
|
|
1429
|
+
};
|
|
1430
|
+
if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
touched = true;
|
|
1434
|
+
if (cls.subPath.length === 0) {
|
|
1435
|
+
if (op.op === "add" || op.op === "replace") {
|
|
1436
|
+
content = op.value;
|
|
1437
|
+
deleted = false;
|
|
1438
|
+
} else if (op.op === "remove") {
|
|
1439
|
+
content = undefined;
|
|
1440
|
+
deleted = true;
|
|
1441
|
+
} else if (op.op === "test") {
|
|
1442
|
+
// An assertion, not a mutation: the content is unchanged either way, and
|
|
1443
|
+
// the commit path is where a failing `test` is reported. Falling through
|
|
1444
|
+
// to the move/copy error below turned a no-op into a permanent load
|
|
1445
|
+
// failure for the whole entry.
|
|
1446
|
+
continue;
|
|
1447
|
+
} else {
|
|
1448
|
+
// move/copy INTO this key: the content comes from the source entry,
|
|
1449
|
+
// which the caller must resolve (it is a different `*.val.json`).
|
|
1450
|
+
return {
|
|
1451
|
+
kind: "error",
|
|
1452
|
+
message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
|
|
1453
|
+
patchId
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (content === undefined) {
|
|
1459
|
+
return {
|
|
1460
|
+
kind: "error",
|
|
1461
|
+
message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
|
|
1462
|
+
patchId
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
const rebased = rebaseContentOp(op, cls.recordPath.length + 1);
|
|
1466
|
+
if (result.isErr(rebased)) {
|
|
1467
|
+
return {
|
|
1468
|
+
kind: "error",
|
|
1469
|
+
message: rebased.error.message,
|
|
1470
|
+
patchId
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
const applied = applyPatch(deepClone(content), jsonOps$1, [rebased.value]);
|
|
1474
|
+
if (result.isErr(applied)) {
|
|
1475
|
+
return {
|
|
1476
|
+
kind: "error",
|
|
1477
|
+
message: applied.error.message,
|
|
1478
|
+
patchId
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
content = applied.value;
|
|
1482
|
+
}
|
|
1483
|
+
if (touched) {
|
|
1484
|
+
appliedPatchIds.push(patchId);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (deleted || content === undefined) {
|
|
1488
|
+
return {
|
|
1489
|
+
kind: "deleted",
|
|
1490
|
+
appliedPatchIds
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
return {
|
|
1494
|
+
kind: "content",
|
|
1495
|
+
content,
|
|
1496
|
+
appliedPatchIds
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Resolves an EXISTING entry's `*.val.json` path (relative to rootDir) from the
|
|
1502
|
+
* `import(...)` path recorded in the `.val.ts` thunk (from
|
|
1503
|
+
* {@link analyzeJsonValuesEntries}). Existing files may have been hand-placed,
|
|
1504
|
+
* so the import path is authoritative (hybrid authoring).
|
|
1505
|
+
*/
|
|
1506
|
+
function resolveExistingJsonPath(moduleFilePath, importPath) {
|
|
1507
|
+
const moduleDir = path.posix.dirname(moduleFilePath);
|
|
1508
|
+
return path.posix.join(moduleDir, importPath);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Validates the content of every `.jsonValues()` entry in a module by loading
|
|
1513
|
+
* each backing `*.val.json` (via its lazy import thunk) and checking it against
|
|
1514
|
+
* the record's item schema.
|
|
1515
|
+
*
|
|
1516
|
+
* The record-level `executeValidate` only asserts the marker shape — content
|
|
1517
|
+
* validation is deferred (the content isn't inlined). This performs that
|
|
1518
|
+
* deferred deep validation server-side. Every loadable entry is validated;
|
|
1519
|
+
* validation is allowed to be slower at scale, so a sha-keyed skip-cache is left
|
|
1520
|
+
* as a later optimization.
|
|
1521
|
+
*
|
|
1522
|
+
* Entries without a runtime import thunk (transport markers / draft entries
|
|
1523
|
+
* whose content lives in a patch) are skipped here — their content is validated
|
|
1524
|
+
* where it is loaded (the single-entry fetch path).
|
|
1525
|
+
*
|
|
1526
|
+
* ROOT-ONLY BY CONTRACT: only a module whose root schema is a `.jsonValues()`
|
|
1527
|
+
* record is visited. Nested `.jsonValues()` records are rejected up front as
|
|
1528
|
+
* module errors (see `findNestedJsonValuesRecords`), so they can never reach
|
|
1529
|
+
* here — if that guard is ever relaxed, this must become a recursive visitor or
|
|
1530
|
+
* nested entries silently get no content validation at all.
|
|
1531
|
+
*/
|
|
1532
|
+
async function validateJsonValuesEntries(schema, source, modulePath) {
|
|
1533
|
+
const out = {};
|
|
1534
|
+
if (!(schema instanceof RecordSchema)) {
|
|
1535
|
+
return out;
|
|
1536
|
+
}
|
|
1537
|
+
if (!schema["executeSerialize"]().jsonValues) {
|
|
1538
|
+
return out;
|
|
1539
|
+
}
|
|
1540
|
+
if (source === null || typeof source !== "object" || Array.isArray(source)) {
|
|
1541
|
+
return out;
|
|
1542
|
+
}
|
|
1543
|
+
for (const [key, marker] of Object.entries(source)) {
|
|
1544
|
+
const entryPath = Internal.createValPathOfItem(modulePath, key);
|
|
1545
|
+
if (!entryPath) {
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
if (!Internal.isJson(marker)) {
|
|
1549
|
+
// A value written INLINE in the `.val.ts` instead of `c.json(() => import(...))`.
|
|
1550
|
+
//
|
|
1551
|
+
// The record-level validation checks it against the item schema (so bad
|
|
1552
|
+
// content is still reported), but it cannot report the inlining itself:
|
|
1553
|
+
// the Studio substitutes loaded entry content in place of the marker
|
|
1554
|
+
// before validating, so from `executeValidate`'s perspective a loaded
|
|
1555
|
+
// entry and a hand-authored one look identical. Here the source is the
|
|
1556
|
+
// module as it is on disk, where a non-marker can only be inlined.
|
|
1557
|
+
out[entryPath] = [{
|
|
1558
|
+
message: `Entry '${key}' is written inline in ${modulePath}, but this record uses .jsonValues(): entry values must live in their own '*.val.json' file, referenced with c.json(() => import("./...")). Run 'val validate --fix' to move it.`,
|
|
1559
|
+
value: marker,
|
|
1560
|
+
fixes: ["jsonValues:extract-entry"]
|
|
1561
|
+
}];
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
const thunk = Internal.getJsonImport(marker);
|
|
1565
|
+
if (!thunk) {
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
let content;
|
|
1569
|
+
try {
|
|
1570
|
+
content = (await thunk()).default;
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
out[entryPath] = [{
|
|
1573
|
+
message: `Could not load JSON entry '${key}': ${err instanceof Error ? err.message : String(err)}`
|
|
1574
|
+
}];
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
const entryErrors = schema.validateJsonEntryContent(entryPath, content);
|
|
1578
|
+
if (entryErrors) {
|
|
1579
|
+
for (const [p, errs] of Object.entries(entryErrors)) {
|
|
1580
|
+
out[p] = errs;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return out;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1110
1587
|
async function createService(projectRoot, host = {
|
|
1111
1588
|
...ts.sys,
|
|
1112
1589
|
writeFile: (fileName, data, encoding) => {
|
|
@@ -1153,21 +1630,69 @@ class Service {
|
|
|
1153
1630
|
const schema = this.extracted.schemas[moduleFilePath];
|
|
1154
1631
|
const serializedSchema = this.extracted.serializedSchemas[moduleFilePath];
|
|
1155
1632
|
const moduleError = this.extracted.moduleErrors.find(e => e.path === moduleFilePath);
|
|
1633
|
+
// A module whose `def()` threw is recorded WITHOUT a path: the import never
|
|
1634
|
+
// got far enough to reveal one, so it cannot be matched above. Those errors
|
|
1635
|
+
// are precisely why a module can be missing here, so report them too -
|
|
1636
|
+
// "was not found in val.modules" alone sends the reader hunting for a
|
|
1637
|
+
// registration that is already there.
|
|
1638
|
+
const unattributedModuleErrors = this.extracted.moduleErrors.filter(e => e.path === undefined);
|
|
1156
1639
|
if (source === undefined || schema === undefined || serializedSchema === undefined) {
|
|
1157
1640
|
return {
|
|
1158
1641
|
path: moduleFilePath,
|
|
1159
1642
|
errors: {
|
|
1160
1643
|
invalidModulePath: moduleFilePath,
|
|
1161
|
-
fatal: [{
|
|
1162
|
-
message:
|
|
1163
|
-
}]
|
|
1644
|
+
fatal: moduleError ? [{
|
|
1645
|
+
message: moduleError.message
|
|
1646
|
+
}] : [{
|
|
1647
|
+
message: `Module '${moduleFilePath}' was not found in val.modules`
|
|
1648
|
+
}, ...unattributedModuleErrors.map(e => ({
|
|
1649
|
+
message: e.message
|
|
1650
|
+
}))]
|
|
1164
1651
|
}
|
|
1165
1652
|
};
|
|
1166
1653
|
}
|
|
1167
|
-
|
|
1654
|
+
let validation = opts.validate ? schema["executeValidate"](moduleFilePath, source) : false;
|
|
1655
|
+
|
|
1656
|
+
// `.jsonValues()` needs two checks that `executeValidate` structurally
|
|
1657
|
+
// cannot do, and this is the ONLY place the Service-based callers (the CLI's
|
|
1658
|
+
// `val validate`, chiefly) can get them. `ValOps` — the Studio's path — has
|
|
1659
|
+
// its own copies; without these, `val validate` reports a module with broken
|
|
1660
|
+
// entry content, or an unsupported nested `.jsonValues()`, as VALID, and CI
|
|
1661
|
+
// gates on that.
|
|
1662
|
+
let jsonValuesModuleError;
|
|
1663
|
+
if (opts.validate) {
|
|
1664
|
+
const nested = findNestedJsonValuesRecords(serializedSchema);
|
|
1665
|
+
if (nested.length > 0) {
|
|
1666
|
+
// Root-only is a hard contract (see findNestedJsonValuesRecords): a
|
|
1667
|
+
// nested one would silently get NO content validation, which is exactly
|
|
1668
|
+
// the failure this check exists to prevent.
|
|
1669
|
+
jsonValuesModuleError = `Nested .jsonValues() records are not supported: ${nested.map(nestedPath => `'${nestedPath.join(".")}'`).join(", ")} in ${moduleFilePath}. Use .jsonValues() only on a module's root record/router.`;
|
|
1670
|
+
} else {
|
|
1671
|
+
// Loads every entry's backing `*.val.json` through its thunk. That is the
|
|
1672
|
+
// accepted cost of having no revalidation token (locked decision #3):
|
|
1673
|
+
// validation is allowed to be slower at scale, but it is not allowed to
|
|
1674
|
+
// silently skip content.
|
|
1675
|
+
const entryErrors = await validateJsonValuesEntries(schema, source, moduleFilePath);
|
|
1676
|
+
if (Object.keys(entryErrors).length > 0) {
|
|
1677
|
+
// Concatenate per path, never overwrite: an entry written inline is
|
|
1678
|
+
// reported BOTH by the record-level validation (which checks the
|
|
1679
|
+
// inline value against the item schema) and here (which reports the
|
|
1680
|
+
// inlining itself). A spread would drop whichever came first, hiding
|
|
1681
|
+
// a real content error behind the inlining error or vice versa.
|
|
1682
|
+
const merged = {
|
|
1683
|
+
...(validation || {})
|
|
1684
|
+
};
|
|
1685
|
+
for (const [entryPathS, errs] of Object.entries(entryErrors)) {
|
|
1686
|
+
const entryPath = entryPathS;
|
|
1687
|
+
merged[entryPath] = (merged[entryPath] || []).concat(errs);
|
|
1688
|
+
}
|
|
1689
|
+
validation = merged;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1168
1693
|
const resolved = Internal.resolvePath(modulePath, source, serializedSchema);
|
|
1169
1694
|
const sourcePath = resolved.path ? [moduleFilePath, resolved.path].join(".") : moduleFilePath;
|
|
1170
|
-
if (!validation && !moduleError) {
|
|
1695
|
+
if (!validation && !moduleError && !jsonValuesModuleError) {
|
|
1171
1696
|
return {
|
|
1172
1697
|
path: sourcePath,
|
|
1173
1698
|
source: resolved.source,
|
|
@@ -1175,15 +1700,24 @@ class Service {
|
|
|
1175
1700
|
errors: false
|
|
1176
1701
|
};
|
|
1177
1702
|
}
|
|
1703
|
+
const fatal = [];
|
|
1704
|
+
if (moduleError) {
|
|
1705
|
+
fatal.push({
|
|
1706
|
+
message: moduleError.message
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
if (jsonValuesModuleError) {
|
|
1710
|
+
fatal.push({
|
|
1711
|
+
message: jsonValuesModuleError
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1178
1714
|
return {
|
|
1179
1715
|
path: sourcePath,
|
|
1180
1716
|
source: resolved.source,
|
|
1181
1717
|
schema: resolved.schema,
|
|
1182
1718
|
errors: {
|
|
1183
1719
|
validation: validation || undefined,
|
|
1184
|
-
fatal:
|
|
1185
|
-
message: moduleError.message
|
|
1186
|
-
}] : undefined
|
|
1720
|
+
fatal: fatal.length > 0 ? fatal : undefined
|
|
1187
1721
|
}
|
|
1188
1722
|
};
|
|
1189
1723
|
}
|
|
@@ -1253,6 +1787,92 @@ function encodeJwt(payload, sessionKey) {
|
|
|
1253
1787
|
return `${jwtHeaderBase64}.${payloadBase64}.${crypto$1.createHmac("sha256", sessionKey).update(`${jwtHeaderBase64}.${payloadBase64}`).digest("base64")}`;
|
|
1254
1788
|
}
|
|
1255
1789
|
|
|
1790
|
+
/**
|
|
1791
|
+
* Given the `source` object-literal expression of a `.jsonValues()` record /
|
|
1792
|
+
* router (the 3rd argument to `c.define`, via {@link analyzeValModule}),
|
|
1793
|
+
* extracts each entry's `c.json(() => import("..."))` import path, keyed by
|
|
1794
|
+
* entry key. Entries whose value is not a `c.json(...)` call are skipped.
|
|
1795
|
+
*/
|
|
1796
|
+
function analyzeJsonValuesEntries(source) {
|
|
1797
|
+
const entries = new Map();
|
|
1798
|
+
if (!ts.isObjectLiteralExpression(source)) {
|
|
1799
|
+
return entries;
|
|
1800
|
+
}
|
|
1801
|
+
for (const prop of source.properties) {
|
|
1802
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
const key = getPropertyKey(prop.name);
|
|
1806
|
+
if (key === null) {
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
const entry = analyzeCJsonCall(prop.initializer);
|
|
1810
|
+
if (entry) {
|
|
1811
|
+
entries.set(key, entry);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return entries;
|
|
1815
|
+
}
|
|
1816
|
+
function getPropertyKey(name) {
|
|
1817
|
+
if (ts.isStringLiteralLike(name)) {
|
|
1818
|
+
return name.text;
|
|
1819
|
+
}
|
|
1820
|
+
if (ts.isIdentifier(name)) {
|
|
1821
|
+
return name.text;
|
|
1822
|
+
}
|
|
1823
|
+
return null;
|
|
1824
|
+
}
|
|
1825
|
+
function analyzeCJsonCall(node) {
|
|
1826
|
+
// c.json(() => import("path"))
|
|
1827
|
+
if (!ts.isCallExpression(node) || !isCJson(node.expression)) {
|
|
1828
|
+
return null;
|
|
1829
|
+
}
|
|
1830
|
+
const [thunk] = node.arguments;
|
|
1831
|
+
if (!thunk) {
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
const importPath = getImportPathFromThunk(thunk);
|
|
1835
|
+
if (importPath === null) {
|
|
1836
|
+
return null;
|
|
1837
|
+
}
|
|
1838
|
+
return {
|
|
1839
|
+
importPath
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
function isCJson(expr) {
|
|
1843
|
+
// c.json
|
|
1844
|
+
return ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression) && expr.expression.text === "c" && ts.isIdentifier(expr.name) && expr.name.text === "json";
|
|
1845
|
+
}
|
|
1846
|
+
function getImportPathFromThunk(thunk) {
|
|
1847
|
+
// () => import("path") (concise body) OR () => { return import("path"); }
|
|
1848
|
+
if (!ts.isArrowFunction(thunk)) {
|
|
1849
|
+
return null;
|
|
1850
|
+
}
|
|
1851
|
+
let importCall;
|
|
1852
|
+
if (ts.isCallExpression(thunk.body)) {
|
|
1853
|
+
importCall = thunk.body;
|
|
1854
|
+
} else if (ts.isBlock(thunk.body)) {
|
|
1855
|
+
for (const stmt of thunk.body.statements) {
|
|
1856
|
+
if (ts.isReturnStatement(stmt) && stmt.expression && ts.isCallExpression(stmt.expression)) {
|
|
1857
|
+
importCall = stmt.expression;
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (!importCall || !ts.isCallExpression(importCall)) {
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
const isImport = importCall.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(importCall.expression) && importCall.expression.text === "import";
|
|
1866
|
+
if (!isImport) {
|
|
1867
|
+
return null;
|
|
1868
|
+
}
|
|
1869
|
+
const arg = importCall.arguments[0];
|
|
1870
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
1871
|
+
return arg.text;
|
|
1872
|
+
}
|
|
1873
|
+
return null;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1256
1876
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
1257
1877
|
const jsonOps = new JSONOps();
|
|
1258
1878
|
const tsOps = new TSOps(document => {
|
|
@@ -1260,6 +1880,38 @@ const tsOps = new TSOps(document => {
|
|
|
1260
1880
|
source
|
|
1261
1881
|
}) => source));
|
|
1262
1882
|
});
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* `.jsonValues()` is only supported on a module's ROOT record/router. A nested
|
|
1886
|
+
* one is broken end to end (the `/json` endpoint keys entries by a single
|
|
1887
|
+
* string, the Studio substitutes at the top level, and content validation
|
|
1888
|
+
* silently skips it), so reject it up front as a module error — `/sources/~`
|
|
1889
|
+
* then fails with "Val is not correctly setup" naming the module.
|
|
1890
|
+
*/
|
|
1891
|
+
function findNestedJsonValuesModuleErrors(schemas) {
|
|
1892
|
+
const errors = [];
|
|
1893
|
+
for (const moduleFilePathS of Object.keys(schemas)) {
|
|
1894
|
+
const moduleFilePath = moduleFilePathS;
|
|
1895
|
+
const schema = schemas[moduleFilePath];
|
|
1896
|
+
if (!schema) {
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
let serialized;
|
|
1900
|
+
try {
|
|
1901
|
+
serialized = schema["executeSerialize"]();
|
|
1902
|
+
} catch {
|
|
1903
|
+
// Serialization errors are reported elsewhere (e.g. by extractValModules).
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
for (const nestedPath of findNestedJsonValuesRecords(serialized)) {
|
|
1907
|
+
errors.push({
|
|
1908
|
+
path: moduleFilePath,
|
|
1909
|
+
message: `Nested .jsonValues() records are not supported: '${nestedPath.join(".")}' in ${moduleFilePath}. Use .jsonValues() only on a module's root record/router.`
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
return errors;
|
|
1914
|
+
}
|
|
1263
1915
|
// #region ValOps
|
|
1264
1916
|
class ValOps {
|
|
1265
1917
|
/** Sources from val modules, immutable (without patches or anything) */
|
|
@@ -1301,13 +1953,14 @@ class ValOps {
|
|
|
1301
1953
|
async initSources() {
|
|
1302
1954
|
if (this.baseSha === null || this.sourcesSha === null || this.configSha === null || this.schemaSha === null || this.sources === null || this.schemas === null || this.modulesErrors === null) {
|
|
1303
1955
|
const extracted = await extractValModules(this.valModules);
|
|
1956
|
+
const moduleErrors = extracted.moduleErrors.concat(findNestedJsonValuesModuleErrors(extracted.schemas));
|
|
1304
1957
|
this.sources = extracted.sources;
|
|
1305
1958
|
this.schemas = extracted.schemas;
|
|
1306
1959
|
this.baseSha = extracted.baseSha;
|
|
1307
1960
|
this.schemaSha = extracted.schemaSha;
|
|
1308
1961
|
this.sourcesSha = extracted.sourcesSha;
|
|
1309
1962
|
this.configSha = extracted.configSha;
|
|
1310
|
-
this.modulesErrors =
|
|
1963
|
+
this.modulesErrors = moduleErrors;
|
|
1311
1964
|
return {
|
|
1312
1965
|
baseSha: this.baseSha,
|
|
1313
1966
|
schemaSha: this.schemaSha,
|
|
@@ -1315,7 +1968,7 @@ class ValOps {
|
|
|
1315
1968
|
configSha: this.configSha,
|
|
1316
1969
|
sources: extracted.sources,
|
|
1317
1970
|
schemas: extracted.schemas,
|
|
1318
|
-
moduleErrors
|
|
1971
|
+
moduleErrors
|
|
1319
1972
|
};
|
|
1320
1973
|
}
|
|
1321
1974
|
return {
|
|
@@ -1338,6 +1991,221 @@ class ValOps {
|
|
|
1338
1991
|
async getBaseSources() {
|
|
1339
1992
|
return this.initSources().then(result => result.sources);
|
|
1340
1993
|
}
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* Resolves the content of ONE `.jsonValues()` entry.
|
|
1997
|
+
*
|
|
1998
|
+
* The committed content comes from the entry's import thunk on the base
|
|
1999
|
+
* source (so it works in both fs and http mode, with no extra I/O). With
|
|
2000
|
+
* `applyPatches` (the default) any pending patches for that entry are then
|
|
2001
|
+
* replayed on top, which is what makes draft edits visible to the runtime.
|
|
2002
|
+
*
|
|
2003
|
+
* Callers that apply patches themselves (the Studio, which owns
|
|
2004
|
+
* in-flight client patches the server has not seen) must pass
|
|
2005
|
+
* `applyPatches: false` or the same edits would be applied twice.
|
|
2006
|
+
*/
|
|
2007
|
+
async getJsonEntry(moduleFilePath, entryKey, opts) {
|
|
2008
|
+
const res = await this.getJsonEntries(moduleFilePath, {
|
|
2009
|
+
keys: [entryKey]
|
|
2010
|
+
}, opts);
|
|
2011
|
+
if (res.status !== "success") {
|
|
2012
|
+
return res;
|
|
2013
|
+
}
|
|
2014
|
+
const entry = res.entries[0];
|
|
2015
|
+
if (entry !== undefined) {
|
|
2016
|
+
return {
|
|
2017
|
+
status: "success",
|
|
2018
|
+
content: entry.content
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
const error = res.errors[0];
|
|
2022
|
+
if (error !== undefined) {
|
|
2023
|
+
return {
|
|
2024
|
+
status: "error",
|
|
2025
|
+
message: error.message
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
return {
|
|
2029
|
+
status: "not-found",
|
|
2030
|
+
message: `Entry not found: ${entryKey} in ${moduleFilePath}`
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Resolves the content of MANY `.jsonValues()` entries in one pass.
|
|
2036
|
+
*
|
|
2037
|
+
* This is the single implementation; {@link getJsonEntry} is a one-key wrapper.
|
|
2038
|
+
* Batching matters because the expensive parts — `initSources()` and
|
|
2039
|
+
* `fetchPatches()` — are hoisted OUT of the per-entry loop: resolving 500
|
|
2040
|
+
* entries one-by-one would otherwise mean 500 patch fetches.
|
|
2041
|
+
*
|
|
2042
|
+
* Per-entry problems stay per-entry (`missing` / `errors`) so one corrupt
|
|
2043
|
+
* `*.val.json` cannot fail a whole batch. Only a missing or non-record MODULE
|
|
2044
|
+
* is a whole-request `not-found`.
|
|
2045
|
+
*
|
|
2046
|
+
* `selector` is either explicit `keys` or an `offset`/`limit` window over every
|
|
2047
|
+
* key of the record, in module key order. The window form requires
|
|
2048
|
+
* `applyPatches: false`: enumerating from the base source would silently omit
|
|
2049
|
+
* draft-added keys, and a silently-short key list is exactly the class of bug
|
|
2050
|
+
* this endpoint exists to avoid.
|
|
2051
|
+
*/
|
|
2052
|
+
async getJsonEntries(moduleFilePath, selector, opts) {
|
|
2053
|
+
const applyPatches = (opts === null || opts === void 0 ? void 0 : opts.applyPatches) !== false;
|
|
2054
|
+
const isWindow = !("keys" in selector);
|
|
2055
|
+
if (isWindow && applyPatches) {
|
|
2056
|
+
return {
|
|
2057
|
+
status: "error",
|
|
2058
|
+
message: "Cannot enumerate json entries by offset/limit with apply_patches: the base key set would omit draft-added entries. Pass apply_patches=false, or request explicit keys."
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
const {
|
|
2062
|
+
sources,
|
|
2063
|
+
schemas
|
|
2064
|
+
} = await this.initSources();
|
|
2065
|
+
const moduleSource = sources[moduleFilePath];
|
|
2066
|
+
if (moduleSource === undefined || moduleSource === null) {
|
|
2067
|
+
return {
|
|
2068
|
+
status: "not-found",
|
|
2069
|
+
message: `Module not found: ${moduleFilePath}`
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
if (typeof moduleSource !== "object" || Array.isArray(moduleSource)) {
|
|
2073
|
+
return {
|
|
2074
|
+
status: "not-found",
|
|
2075
|
+
message: `Module is not a record: ${moduleFilePath}`
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
const record = moduleSource;
|
|
2079
|
+
const allKeys = Object.keys(record);
|
|
2080
|
+
const requestedKeys = isWindow ? allKeys.slice(selector.offset, selector.offset + selector.limit) : selector.keys;
|
|
2081
|
+
|
|
2082
|
+
// Fetched once for the whole batch, not per entry.
|
|
2083
|
+
let modulePatches = [];
|
|
2084
|
+
let serializedSchema = undefined;
|
|
2085
|
+
if (applyPatches) {
|
|
2086
|
+
const patchOps = await this.fetchPatches({
|
|
2087
|
+
excludePatchOps: false
|
|
2088
|
+
});
|
|
2089
|
+
if (patchOps.error) {
|
|
2090
|
+
return {
|
|
2091
|
+
status: "error",
|
|
2092
|
+
message: patchOps.error.message
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
if (patchOps.errors && Object.keys(patchOps.errors).length > 0) {
|
|
2096
|
+
return {
|
|
2097
|
+
status: "error",
|
|
2098
|
+
message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
modulePatches = patchOps.patches.filter(p => p.path === moduleFilePath && !p.appliedAt).map(p => ({
|
|
2102
|
+
patchId: p.patchId,
|
|
2103
|
+
patch: p.patch
|
|
2104
|
+
}));
|
|
2105
|
+
try {
|
|
2106
|
+
var _schemas$moduleFilePa;
|
|
2107
|
+
serializedSchema = (_schemas$moduleFilePa = schemas[moduleFilePath]) === null || _schemas$moduleFilePa === void 0 ? void 0 : _schemas$moduleFilePa["executeSerialize"]();
|
|
2108
|
+
} catch {
|
|
2109
|
+
// Serialization errors are reported elsewhere; treat as "no schema".
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
const entries = [];
|
|
2113
|
+
const missing = [];
|
|
2114
|
+
const errors = [];
|
|
2115
|
+
const resolved = await Promise.all(requestedKeys.map(async entryKey => {
|
|
2116
|
+
const marker = record[entryKey];
|
|
2117
|
+
if (marker !== undefined && !Internal.isJson(marker)) {
|
|
2118
|
+
// Not a jsonValues entry — return the inlined value as-is (defensive).
|
|
2119
|
+
// `inline` skips patch replay: entry patches are expressed against a
|
|
2120
|
+
// jsonValues entry, and this value is part of the module source proper.
|
|
2121
|
+
return {
|
|
2122
|
+
entryKey,
|
|
2123
|
+
baseContent: marker,
|
|
2124
|
+
inline: true
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
if (marker === undefined) {
|
|
2128
|
+
return {
|
|
2129
|
+
entryKey,
|
|
2130
|
+
baseContent: undefined
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
const thunk = Internal.getJsonImport(marker);
|
|
2134
|
+
if (!thunk) {
|
|
2135
|
+
return {
|
|
2136
|
+
entryKey,
|
|
2137
|
+
baseContent: null
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
try {
|
|
2141
|
+
return {
|
|
2142
|
+
entryKey,
|
|
2143
|
+
baseContent: (await thunk()).default ?? null
|
|
2144
|
+
};
|
|
2145
|
+
} catch (e) {
|
|
2146
|
+
return {
|
|
2147
|
+
entryKey,
|
|
2148
|
+
message: `Failed to load JSON entry '${entryKey}': ${e instanceof Error ? e.message : String(e)}`
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
}));
|
|
2152
|
+
for (const result of resolved) {
|
|
2153
|
+
const {
|
|
2154
|
+
entryKey
|
|
2155
|
+
} = result;
|
|
2156
|
+
if ("message" in result) {
|
|
2157
|
+
errors.push({
|
|
2158
|
+
key: entryKey,
|
|
2159
|
+
message: result.message
|
|
2160
|
+
});
|
|
2161
|
+
continue;
|
|
2162
|
+
}
|
|
2163
|
+
const {
|
|
2164
|
+
baseContent
|
|
2165
|
+
} = result;
|
|
2166
|
+
if (!applyPatches || "inline" in result) {
|
|
2167
|
+
if (baseContent === undefined) {
|
|
2168
|
+
missing.push(entryKey);
|
|
2169
|
+
} else {
|
|
2170
|
+
entries.push({
|
|
2171
|
+
key: entryKey,
|
|
2172
|
+
content: baseContent
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
const res = applyJsonValuesEntryPatches({
|
|
2178
|
+
serializedSchema,
|
|
2179
|
+
entryKey,
|
|
2180
|
+
baseContent,
|
|
2181
|
+
patches: modulePatches
|
|
2182
|
+
});
|
|
2183
|
+
if (res.kind === "error") {
|
|
2184
|
+
errors.push({
|
|
2185
|
+
key: entryKey,
|
|
2186
|
+
message: res.message
|
|
2187
|
+
});
|
|
2188
|
+
} else if (res.kind === "deleted") {
|
|
2189
|
+
missing.push(entryKey);
|
|
2190
|
+
} else {
|
|
2191
|
+
entries.push({
|
|
2192
|
+
key: entryKey,
|
|
2193
|
+
content: res.content
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
return {
|
|
2198
|
+
status: "success",
|
|
2199
|
+
entries,
|
|
2200
|
+
missing,
|
|
2201
|
+
errors,
|
|
2202
|
+
total: allKeys.length,
|
|
2203
|
+
...(isWindow ? {
|
|
2204
|
+
offset: selector.offset,
|
|
2205
|
+
limit: selector.limit
|
|
2206
|
+
} : {})
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
1341
2209
|
async getSchemas() {
|
|
1342
2210
|
return this.initSources().then(result => result.schemas);
|
|
1343
2211
|
}
|
|
@@ -1391,13 +2259,20 @@ class ValOps {
|
|
|
1391
2259
|
// ops used to be applied twice. Idempotent for "replace", destructive for
|
|
1392
2260
|
// array add/remove/move.
|
|
1393
2261
|
if (hasSourceFileOps) {
|
|
2262
|
+
var _patchesByModule$path;
|
|
1394
2263
|
const path = patch.path;
|
|
1395
2264
|
if (!patchesByModule[path]) {
|
|
1396
2265
|
patchesByModule[path] = [];
|
|
1397
2266
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
2267
|
+
// At most ONE entry per (module, patch): consumers treat each entry as
|
|
2268
|
+
// "apply this whole patch". Pushing per-op made a patch with N non-file
|
|
2269
|
+
// ops be applied N times — idempotent for `replace`, but it duplicates
|
|
2270
|
+
// `add`s and corrupts non-idempotent ops like `move`.
|
|
2271
|
+
if (((_patchesByModule$path = patchesByModule[path][patchesByModule[path].length - 1]) === null || _patchesByModule$path === void 0 ? void 0 : _patchesByModule$path.patchId) !== patch.patchId) {
|
|
2272
|
+
patchesByModule[path].push({
|
|
2273
|
+
patchId: patch.patchId
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
1401
2276
|
}
|
|
1402
2277
|
}
|
|
1403
2278
|
return {
|
|
@@ -1407,6 +2282,15 @@ class ValOps {
|
|
|
1407
2282
|
}
|
|
1408
2283
|
|
|
1409
2284
|
// #region getRenders
|
|
2285
|
+
/**
|
|
2286
|
+
* Reifies each module's render from its schema INSTANCE.
|
|
2287
|
+
*
|
|
2288
|
+
* Kept even though the Studio also computes renders client-side: `select` is a
|
|
2289
|
+
* user function that lives on the instance and is not part of the serialized
|
|
2290
|
+
* schema, so a host app that does not render `<ValModulesClient>` has no
|
|
2291
|
+
* instances in the browser and would otherwise get no renders at all. See
|
|
2292
|
+
* #470.
|
|
2293
|
+
*/
|
|
1410
2294
|
async getRenders(schemas, sources) {
|
|
1411
2295
|
const renders = {};
|
|
1412
2296
|
for (const [pathS, schema] of Object.entries(schemas)) {
|
|
@@ -1434,6 +2318,25 @@ class ValOps {
|
|
|
1434
2318
|
} = await this.initSources();
|
|
1435
2319
|
const patchedSources = {};
|
|
1436
2320
|
const errors = {};
|
|
2321
|
+
// Serialized schemas, resolved lazily and only for modules that actually
|
|
2322
|
+
// have patches, so the common (non-jsonValues) case stays free.
|
|
2323
|
+
const {
|
|
2324
|
+
schemas
|
|
2325
|
+
} = await this.initSources();
|
|
2326
|
+
const serializedSchemaCache = new Map();
|
|
2327
|
+
const jsonValuesSchemaFor = path => {
|
|
2328
|
+
if (!serializedSchemaCache.has(path)) {
|
|
2329
|
+
let serialized = undefined;
|
|
2330
|
+
try {
|
|
2331
|
+
var _schemas$path;
|
|
2332
|
+
serialized = (_schemas$path = schemas[path]) === null || _schemas$path === void 0 ? void 0 : _schemas$path["executeSerialize"]();
|
|
2333
|
+
} catch {
|
|
2334
|
+
// Serialization errors are reported elsewhere; treat as "no schema".
|
|
2335
|
+
}
|
|
2336
|
+
serializedSchemaCache.set(path, serialized);
|
|
2337
|
+
}
|
|
2338
|
+
return serializedSchemaCache.get(path);
|
|
2339
|
+
};
|
|
1437
2340
|
for (const patchData of analysis.patches) {
|
|
1438
2341
|
const path = patchData.path;
|
|
1439
2342
|
if (sources[path] === undefined) {
|
|
@@ -1462,6 +2365,13 @@ class ValOps {
|
|
|
1462
2365
|
} else {
|
|
1463
2366
|
const applicableOps = [];
|
|
1464
2367
|
const fileFixOps = {};
|
|
2368
|
+
// `.jsonValues()` entry values are opaque `{_type:"json"}` markers in
|
|
2369
|
+
// the module source — their content lives in the entry's `*.val.json`.
|
|
2370
|
+
// Ops that reach INTO an entry therefore cannot be applied here (and
|
|
2371
|
+
// would fail with "Cannot replace object element which does not exist",
|
|
2372
|
+
// poisoning the rest of this module's patch chain). See the per-op
|
|
2373
|
+
// routing below.
|
|
2374
|
+
const serializedSchema = jsonValuesSchemaFor(path);
|
|
1465
2375
|
for (const op of patchData.patch) {
|
|
1466
2376
|
if (op.op === "file") {
|
|
1467
2377
|
if (op.value !== null) {
|
|
@@ -1478,7 +2388,45 @@ class ValOps {
|
|
|
1478
2388
|
// null value = delete: no patch_id to inject; the "remove" op in
|
|
1479
2389
|
// the patch already removes the metadata entry from the source
|
|
1480
2390
|
} else {
|
|
1481
|
-
|
|
2391
|
+
const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
|
|
2392
|
+
kind: "normal"
|
|
2393
|
+
};
|
|
2394
|
+
if (cls.kind === "normal") {
|
|
2395
|
+
applicableOps.push(op);
|
|
2396
|
+
} else if (cls.subPath.length > 0) ; else if (op.op === "add" || op.op === "replace") {
|
|
2397
|
+
// Whole-entry add/replace: keep the record's KEY SET correct for
|
|
2398
|
+
// drafts by writing the marker rather than the content. Record
|
|
2399
|
+
// validation only asserts `isJson`, and
|
|
2400
|
+
// `validateJsonValuesEntries` skips thunkless markers by design.
|
|
2401
|
+
applicableOps.push({
|
|
2402
|
+
op: op.op,
|
|
2403
|
+
path: op.path,
|
|
2404
|
+
value: {
|
|
2405
|
+
[VAL_EXTENSION]: "json",
|
|
2406
|
+
patch_id: patchId
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
} else if (op.op === "remove") {
|
|
2410
|
+
applicableOps.push(op);
|
|
2411
|
+
} else {
|
|
2412
|
+
// move/copy of a whole entry: the destination key must appear, and
|
|
2413
|
+
// for a move the source key must disappear. Both are key-set
|
|
2414
|
+
// changes we can express with markers.
|
|
2415
|
+
applicableOps.push({
|
|
2416
|
+
op: "add",
|
|
2417
|
+
path: op.path,
|
|
2418
|
+
value: {
|
|
2419
|
+
[VAL_EXTENSION]: "json",
|
|
2420
|
+
patch_id: patchId
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
if (op.op === "move" && array.isNonEmpty(op.from)) {
|
|
2424
|
+
applicableOps.push({
|
|
2425
|
+
op: "remove",
|
|
2426
|
+
path: op.from
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
1482
2430
|
}
|
|
1483
2431
|
}
|
|
1484
2432
|
const patchRes = applyPatch(deepClone(patchedSources[path]),
|
|
@@ -1585,6 +2533,21 @@ class ValOps {
|
|
|
1585
2533
|
continue;
|
|
1586
2534
|
}
|
|
1587
2535
|
const res = schema["executeValidate"](path, source);
|
|
2536
|
+
// For `.jsonValues()` records, executeValidate only checks the entry
|
|
2537
|
+
// markers; load + validate each entry's backing `*.val.json` content here.
|
|
2538
|
+
const jsonValuesErrors = await validateJsonValuesEntries(schema, source, path);
|
|
2539
|
+
for (const [sourcePathS, entryErrors] of Object.entries(jsonValuesErrors)) {
|
|
2540
|
+
const sourcePath = sourcePathS;
|
|
2541
|
+
if (!errors[path]) {
|
|
2542
|
+
errors[path] = {
|
|
2543
|
+
validations: {}
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
if (!errors[path].validations[sourcePath]) {
|
|
2547
|
+
errors[path].validations[sourcePath] = [];
|
|
2548
|
+
}
|
|
2549
|
+
errors[path].validations[sourcePath].push(...entryErrors);
|
|
2550
|
+
}
|
|
1588
2551
|
if (res === false) {
|
|
1589
2552
|
continue;
|
|
1590
2553
|
}
|
|
@@ -1827,6 +2790,10 @@ class ValOps {
|
|
|
1827
2790
|
const previousSourceFiles = {};
|
|
1828
2791
|
const partiallyPatchedSourceFiles = {};
|
|
1829
2792
|
const unappliablePatches = {};
|
|
2793
|
+
|
|
2794
|
+
// Serialized schemas are needed to route ops that target `.jsonValues()`
|
|
2795
|
+
// entries (their content lives in `*.val.json`, not the `.val.ts`).
|
|
2796
|
+
const schemas = await this.getSchemas();
|
|
1830
2797
|
const applySourceFilePatches = async (path, patches) => {
|
|
1831
2798
|
const sourceFileRes = await this.getSourceFile(path);
|
|
1832
2799
|
const errors = [];
|
|
@@ -1837,13 +2804,99 @@ class ValOps {
|
|
|
1837
2804
|
});
|
|
1838
2805
|
return {
|
|
1839
2806
|
path,
|
|
1840
|
-
errors,
|
|
1841
|
-
skippedPatches: patches.map(p => p.patchId)
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1844
|
-
const sourceFile = sourceFileRes.data;
|
|
1845
|
-
previousSourceFiles[path] = sourceFile;
|
|
1846
|
-
|
|
2807
|
+
errors,
|
|
2808
|
+
skippedPatches: patches.map(p => p.patchId)
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
const sourceFile = sourceFileRes.data;
|
|
2812
|
+
previousSourceFiles[path] = sourceFile;
|
|
2813
|
+
const originalSourceFile = ts.createSourceFile("<val>", sourceFile, ts.ScriptTarget.ES2015);
|
|
2814
|
+
let tsSourceFile = originalSourceFile;
|
|
2815
|
+
let tsChanged = false;
|
|
2816
|
+
let serializedSchema = undefined;
|
|
2817
|
+
try {
|
|
2818
|
+
var _schemas$path2;
|
|
2819
|
+
serializedSchema = (_schemas$path2 = schemas[path]) === null || _schemas$path2 === void 0 ? void 0 : _schemas$path2["executeSerialize"]();
|
|
2820
|
+
} catch {
|
|
2821
|
+
// Serialization errors are reported elsewhere; treat as "no schema".
|
|
2822
|
+
// Without this guard one unserializable schema (e.g. a `keyOf` with an
|
|
2823
|
+
// empty selector) rejects the whole prepare(), so NO module's patches
|
|
2824
|
+
// can be saved — instead of this module's ops being routed as plain
|
|
2825
|
+
// `.val.ts` ops and any that cannot apply reported per-patch.
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// jsonValues entry content, keyed by `*.val.json` path. `null` = delete.
|
|
2829
|
+
const jsonEntryContents = new Map();
|
|
2830
|
+
// Entries added in this commit → their new `*.val.json` path, so later
|
|
2831
|
+
// content ops in the same commit resolve to the freshly-created file.
|
|
2832
|
+
const entryKeyToJsonPath = new Map();
|
|
2833
|
+
|
|
2834
|
+
// Lazily analyzed `c.json(() => import("..."))` entries of the ORIGINAL
|
|
2835
|
+
// `.val.ts` (import paths are authoritative for existing/hand-placed files).
|
|
2836
|
+
let analyzerEntries = null;
|
|
2837
|
+
const resolveEntryJsonPath = entryKey => {
|
|
2838
|
+
const added = entryKeyToJsonPath.get(entryKey);
|
|
2839
|
+
if (added !== undefined) {
|
|
2840
|
+
return result.ok(added);
|
|
2841
|
+
}
|
|
2842
|
+
if (analyzerEntries === null) {
|
|
2843
|
+
const analysis = analyzeValModule(originalSourceFile);
|
|
2844
|
+
if (result.isErr(analysis)) {
|
|
2845
|
+
return result.err(analysis.error);
|
|
2846
|
+
}
|
|
2847
|
+
analyzerEntries = analyzeJsonValuesEntries(analysis.value.source);
|
|
2848
|
+
}
|
|
2849
|
+
const entry = analyzerEntries.get(entryKey);
|
|
2850
|
+
if (!entry) {
|
|
2851
|
+
return result.err({
|
|
2852
|
+
message: `Could not find jsonValues entry '${entryKey}' in ${path}`,
|
|
2853
|
+
filePath: path
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
return result.ok(resolveExistingJsonPath(path, entry.importPath));
|
|
2857
|
+
};
|
|
2858
|
+
const loadEntryContent = async jsonPath => {
|
|
2859
|
+
const current = jsonEntryContents.get(jsonPath);
|
|
2860
|
+
if (current !== undefined) {
|
|
2861
|
+
if (current === null) {
|
|
2862
|
+
return result.err({
|
|
2863
|
+
message: `Cannot edit a removed jsonValues entry: ${jsonPath}`,
|
|
2864
|
+
filePath: jsonPath
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
return result.ok(current);
|
|
2868
|
+
}
|
|
2869
|
+
const res = await this.getSourceFile(jsonPath);
|
|
2870
|
+
if (res.error) {
|
|
2871
|
+
return result.err({
|
|
2872
|
+
message: res.error.message,
|
|
2873
|
+
filePath: jsonPath
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
try {
|
|
2877
|
+
const parsed = JSON.parse(res.data);
|
|
2878
|
+
jsonEntryContents.set(jsonPath, parsed);
|
|
2879
|
+
return result.ok(parsed);
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
return result.err({
|
|
2882
|
+
message: `Could not parse jsonValues entry ${jsonPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2883
|
+
filePath: jsonPath
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
const collectPatchError = (err, patchId, op) => {
|
|
2888
|
+
console.error("Could not patch", JSON.stringify({
|
|
2889
|
+
path,
|
|
2890
|
+
patchId,
|
|
2891
|
+
error: err,
|
|
2892
|
+
op
|
|
2893
|
+
}, null, 2));
|
|
2894
|
+
if (Array.isArray(err)) {
|
|
2895
|
+
errors.push(...err);
|
|
2896
|
+
} else {
|
|
2897
|
+
errors.push(err);
|
|
2898
|
+
}
|
|
2899
|
+
};
|
|
1847
2900
|
const appliedPatches = [];
|
|
1848
2901
|
const triedPatches = [];
|
|
1849
2902
|
for (const {
|
|
@@ -1867,43 +2920,232 @@ class ValOps {
|
|
|
1867
2920
|
}
|
|
1868
2921
|
const patch = patchData.patch;
|
|
1869
2922
|
const sourceFileOps = patch.filter(op => op.op !== "file"); // file is not a valid source file op
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2923
|
+
let patchHadError = false;
|
|
2924
|
+
// Where this patch's errors start, so the unappliable-patch report below
|
|
2925
|
+
// can name what went wrong rather than just that something did.
|
|
2926
|
+
const errorsBefore = errors.length;
|
|
2927
|
+
for (const op of sourceFileOps) {
|
|
2928
|
+
const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
|
|
2929
|
+
kind: "normal"
|
|
2930
|
+
};
|
|
2931
|
+
// `move` / `copy` also READ from a path: classify that too, so an op
|
|
2932
|
+
// that moves a value out of (or into) a jsonValues entry cannot slip
|
|
2933
|
+
// through as a plain `.val.ts` op.
|
|
2934
|
+
const fromCls = serializedSchema && (op.op === "move" || op.op === "copy") ? classifyJsonValuesOp(serializedSchema, op.from) : {
|
|
2935
|
+
kind: "normal"
|
|
2936
|
+
};
|
|
2937
|
+
if (cls.kind === "normal" && fromCls.kind === "normal") {
|
|
2938
|
+
const patchRes = applyPatch(tsSourceFile, tsOps, [op]);
|
|
2939
|
+
if (result.isErr(patchRes)) {
|
|
2940
|
+
collectPatchError(patchRes.error, patchId, op);
|
|
2941
|
+
patchHadError = true;
|
|
2942
|
+
break;
|
|
2943
|
+
}
|
|
2944
|
+
tsSourceFile = patchRes.value;
|
|
2945
|
+
tsChanged = true;
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
if (cls.kind === "normal") {
|
|
2949
|
+
errors.push({
|
|
2950
|
+
message: `Cannot '${op.op}' a value out of a jsonValues entry and into the module source`,
|
|
2951
|
+
filePath: path
|
|
2952
|
+
});
|
|
2953
|
+
patchHadError = true;
|
|
2954
|
+
break;
|
|
2955
|
+
}
|
|
2956
|
+
// Nested `.jsonValues()` records are not supported: only the read path
|
|
2957
|
+
// for a module's ROOT record/router is implemented end to end. This is
|
|
2958
|
+
// also rejected up front in `initSources`; this is defense in depth.
|
|
2959
|
+
if (cls.recordPath.length > 0 || fromCls.kind === "entry" && fromCls.recordPath.length > 0) {
|
|
2960
|
+
errors.push({
|
|
2961
|
+
message: `Nested .jsonValues() records are not supported: '${cls.recordPath.join(".")}' in ${path}. Use .jsonValues() only on a module's root record/router.`,
|
|
2962
|
+
filePath: path
|
|
2963
|
+
});
|
|
2964
|
+
patchHadError = true;
|
|
2965
|
+
break;
|
|
2966
|
+
}
|
|
2967
|
+
// The op targets a `.jsonValues()` entry.
|
|
2968
|
+
if (cls.subPath.length === 0) {
|
|
2969
|
+
// Structural / whole-entry op.
|
|
2970
|
+
if (op.op === "add") {
|
|
2971
|
+
const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
|
|
2972
|
+
if (result.isErr(newPathsRes)) {
|
|
2973
|
+
errors.push(newPathsRes.error);
|
|
2974
|
+
patchHadError = true;
|
|
2975
|
+
break;
|
|
2976
|
+
}
|
|
2977
|
+
const {
|
|
2978
|
+
jsonPath,
|
|
2979
|
+
importPath
|
|
2980
|
+
} = newPathsRes.value;
|
|
2981
|
+
const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
|
|
2982
|
+
if (result.isErr(insRes)) {
|
|
2983
|
+
collectPatchError(insRes.error, patchId, op);
|
|
2984
|
+
patchHadError = true;
|
|
2985
|
+
break;
|
|
2986
|
+
}
|
|
2987
|
+
tsSourceFile = insRes.value;
|
|
2988
|
+
tsChanged = true;
|
|
2989
|
+
jsonEntryContents.set(jsonPath, op.value);
|
|
2990
|
+
entryKeyToJsonPath.set(cls.entryKey, jsonPath);
|
|
2991
|
+
} else if (op.op === "remove") {
|
|
2992
|
+
const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
|
|
2993
|
+
if (result.isErr(jsonPathRes)) {
|
|
2994
|
+
errors.push(jsonPathRes.error);
|
|
2995
|
+
patchHadError = true;
|
|
2996
|
+
break;
|
|
2997
|
+
}
|
|
2998
|
+
const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey);
|
|
2999
|
+
if (result.isErr(remRes)) {
|
|
3000
|
+
collectPatchError(remRes.error, patchId, op);
|
|
3001
|
+
patchHadError = true;
|
|
3002
|
+
break;
|
|
3003
|
+
}
|
|
3004
|
+
tsSourceFile = remRes.value;
|
|
3005
|
+
tsChanged = true;
|
|
3006
|
+
jsonEntryContents.set(jsonPathRes.value, null);
|
|
3007
|
+
} else if (op.op === "replace") {
|
|
3008
|
+
const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
|
|
3009
|
+
if (result.isErr(jsonPathRes)) {
|
|
3010
|
+
errors.push(jsonPathRes.error);
|
|
3011
|
+
patchHadError = true;
|
|
3012
|
+
break;
|
|
3013
|
+
}
|
|
3014
|
+
jsonEntryContents.set(jsonPathRes.value, op.value);
|
|
3015
|
+
} else if (op.op === "move" || op.op === "copy") {
|
|
3016
|
+
// Rename (move) or duplicate (copy) a whole entry. The new entry
|
|
3017
|
+
// gets its own `*.val.json` written with the source entry's
|
|
3018
|
+
// content plus a `c.json(...)` thunk; a move additionally drops
|
|
3019
|
+
// the old thunk and deletes the old file.
|
|
3020
|
+
if (fromCls.kind !== "entry" || fromCls.subPath.length !== 0 || fromCls.recordPath.join("\0") !== cls.recordPath.join("\0")) {
|
|
3021
|
+
errors.push({
|
|
3022
|
+
message: `Cannot '${op.op}' a jsonValues entry across records or from a non-entry path (from '${op.from.join(".")}' to '${op.path.join(".")}')`,
|
|
3023
|
+
filePath: path
|
|
3024
|
+
});
|
|
3025
|
+
patchHadError = true;
|
|
3026
|
+
break;
|
|
3027
|
+
}
|
|
3028
|
+
const fromKey = fromCls.entryKey;
|
|
3029
|
+
const fromPathRes = resolveEntryJsonPath(fromKey);
|
|
3030
|
+
if (result.isErr(fromPathRes)) {
|
|
3031
|
+
errors.push(fromPathRes.error);
|
|
3032
|
+
patchHadError = true;
|
|
3033
|
+
break;
|
|
3034
|
+
}
|
|
3035
|
+
// Load BEFORE marking anything deleted: `loadEntryContent` errors
|
|
3036
|
+
// on a path that has already been nulled in this commit.
|
|
3037
|
+
const contentRes = await loadEntryContent(fromPathRes.value);
|
|
3038
|
+
if (result.isErr(contentRes)) {
|
|
3039
|
+
errors.push(contentRes.error);
|
|
3040
|
+
patchHadError = true;
|
|
3041
|
+
break;
|
|
3042
|
+
}
|
|
3043
|
+
const content = deepClone(contentRes.value);
|
|
3044
|
+
if (op.op === "move") {
|
|
3045
|
+
const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, fromKey);
|
|
3046
|
+
if (result.isErr(remRes)) {
|
|
3047
|
+
collectPatchError(remRes.error, patchId, op);
|
|
3048
|
+
patchHadError = true;
|
|
3049
|
+
break;
|
|
3050
|
+
}
|
|
3051
|
+
tsSourceFile = remRes.value;
|
|
3052
|
+
}
|
|
3053
|
+
// LOCKED convention: the destination always uses the generated
|
|
3054
|
+
// path, so renaming a hand-placed file relocates it.
|
|
3055
|
+
const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
|
|
3056
|
+
if (result.isErr(newPathsRes)) {
|
|
3057
|
+
errors.push(newPathsRes.error);
|
|
3058
|
+
patchHadError = true;
|
|
3059
|
+
break;
|
|
3060
|
+
}
|
|
3061
|
+
const {
|
|
3062
|
+
jsonPath,
|
|
3063
|
+
importPath
|
|
3064
|
+
} = newPathsRes.value;
|
|
3065
|
+
const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
|
|
3066
|
+
if (result.isErr(insRes)) {
|
|
3067
|
+
collectPatchError(insRes.error, patchId, op);
|
|
3068
|
+
patchHadError = true;
|
|
3069
|
+
break;
|
|
3070
|
+
}
|
|
3071
|
+
tsSourceFile = insRes.value;
|
|
3072
|
+
tsChanged = true;
|
|
3073
|
+
jsonEntryContents.set(jsonPath, content);
|
|
3074
|
+
entryKeyToJsonPath.set(cls.entryKey, jsonPath);
|
|
3075
|
+
if (op.op === "move" && fromPathRes.value !== jsonPath) {
|
|
3076
|
+
jsonEntryContents.set(fromPathRes.value, null);
|
|
3077
|
+
}
|
|
3078
|
+
} else {
|
|
3079
|
+
errors.push({
|
|
3080
|
+
message: `Unsupported op '${op.op}' on jsonValues entry '${cls.entryKey}' (supported: add, remove, replace, move, copy)`,
|
|
3081
|
+
filePath: path
|
|
3082
|
+
});
|
|
3083
|
+
patchHadError = true;
|
|
3084
|
+
break;
|
|
3085
|
+
}
|
|
1882
3086
|
} else {
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
3087
|
+
// Content sub-op: replay against the entry's `*.val.json`.
|
|
3088
|
+
// `rebaseContentOp` slices `from` by the same prefix as `path`, so a
|
|
3089
|
+
// cross-entry move/copy would silently corrupt the target entry.
|
|
3090
|
+
if ((op.op === "move" || op.op === "copy") && (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey)) {
|
|
3091
|
+
errors.push({
|
|
3092
|
+
message: `Cannot '${op.op}' between different jsonValues entries`,
|
|
3093
|
+
filePath: path
|
|
3094
|
+
});
|
|
3095
|
+
patchHadError = true;
|
|
3096
|
+
break;
|
|
3097
|
+
}
|
|
3098
|
+
const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
|
|
3099
|
+
if (result.isErr(jsonPathRes)) {
|
|
3100
|
+
errors.push(jsonPathRes.error);
|
|
3101
|
+
patchHadError = true;
|
|
3102
|
+
break;
|
|
3103
|
+
}
|
|
3104
|
+
const jsonPath = jsonPathRes.value;
|
|
3105
|
+
const contentRes = await loadEntryContent(jsonPath);
|
|
3106
|
+
if (result.isErr(contentRes)) {
|
|
3107
|
+
errors.push(contentRes.error);
|
|
3108
|
+
patchHadError = true;
|
|
3109
|
+
break;
|
|
3110
|
+
}
|
|
3111
|
+
const rebasedRes = rebaseContentOp(op, cls.recordPath.length + 1);
|
|
3112
|
+
if (result.isErr(rebasedRes)) {
|
|
3113
|
+
errors.push({
|
|
3114
|
+
message: rebasedRes.error.message,
|
|
3115
|
+
filePath: jsonPath
|
|
3116
|
+
});
|
|
3117
|
+
patchHadError = true;
|
|
3118
|
+
break;
|
|
3119
|
+
}
|
|
3120
|
+
const applied = applyPatch(deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
|
|
3121
|
+
if (result.isErr(applied)) {
|
|
3122
|
+
collectPatchError(applied.error, patchId, op);
|
|
3123
|
+
patchHadError = true;
|
|
3124
|
+
break;
|
|
3125
|
+
}
|
|
3126
|
+
jsonEntryContents.set(jsonPath, applied.value);
|
|
1890
3127
|
}
|
|
3128
|
+
}
|
|
3129
|
+
if (patchHadError) {
|
|
1891
3130
|
unappliablePatches[patchId] = {
|
|
1892
3131
|
moduleFilePath: path,
|
|
1893
|
-
|
|
3132
|
+
// The per-op loop reports through `errors`, so take what it added
|
|
3133
|
+
// for THIS patch — the same information a single applyPatch gives
|
|
3134
|
+
// via formatPatchSourceError.
|
|
3135
|
+
message: errors.slice(errorsBefore).map(formatPatchSourceError).join("\n") || `Could not apply patch: ${patchId}`
|
|
1894
3136
|
};
|
|
1895
3137
|
triedPatches.push(patchId);
|
|
1896
3138
|
if (continueOnError) {
|
|
1897
|
-
//
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
3139
|
+
// Carry on so a single run reports EVERY unappliable patch, not just
|
|
3140
|
+
// the first per module. Note that the ops of this patch BEFORE the
|
|
3141
|
+
// failing one have already been applied, so what follows builds on a
|
|
3142
|
+
// partially patched state — fine, because continueOnError is
|
|
3143
|
+
// diagnosis only and the commit is refused regardless.
|
|
1901
3144
|
continue;
|
|
1902
3145
|
}
|
|
1903
3146
|
break;
|
|
1904
3147
|
}
|
|
1905
3148
|
appliedPatches.push(patchId);
|
|
1906
|
-
tsSourceFile = patchRes.value;
|
|
1907
3149
|
}
|
|
1908
3150
|
if (errors.length > 0 && continueOnError) {
|
|
1909
3151
|
// Diagnosis: expose what the source file would look like with the
|
|
@@ -1912,33 +3154,58 @@ class ValOps {
|
|
|
1912
3154
|
partiallyPatchedSourceFiles[path] = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
|
|
1913
3155
|
}
|
|
1914
3156
|
if (errors.length === 0) {
|
|
1915
|
-
var _this$options;
|
|
1916
3157
|
// https://github.com/microsoft/TypeScript/issues/36174
|
|
1917
|
-
let sourceFileText =
|
|
1918
|
-
if (
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
})
|
|
3158
|
+
let sourceFileText = null;
|
|
3159
|
+
if (tsChanged) {
|
|
3160
|
+
var _this$options;
|
|
3161
|
+
sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
|
|
3162
|
+
if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.formatter) {
|
|
3163
|
+
try {
|
|
3164
|
+
sourceFileText = await this.options.formatter(sourceFileText, path);
|
|
3165
|
+
} catch (err) {
|
|
3166
|
+
errors.push({
|
|
3167
|
+
message: "Could not format source file: " + (err instanceof Error ? err.message : "Unknown error")
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
1925
3170
|
}
|
|
1926
3171
|
}
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
3172
|
+
const extraFiles = {};
|
|
3173
|
+
for (const [jsonPath, content] of Array.from(jsonEntryContents)) {
|
|
3174
|
+
var _this$options2;
|
|
3175
|
+
if (content === null) {
|
|
3176
|
+
extraFiles[jsonPath] = null;
|
|
3177
|
+
continue;
|
|
3178
|
+
}
|
|
3179
|
+
let jsonText = JSON.stringify(content, null, 2);
|
|
3180
|
+
if ((_this$options2 = this.options) !== null && _this$options2 !== void 0 && _this$options2.formatter) {
|
|
3181
|
+
try {
|
|
3182
|
+
jsonText = await this.options.formatter(jsonText, jsonPath);
|
|
3183
|
+
} catch (err) {
|
|
3184
|
+
errors.push({
|
|
3185
|
+
message: "Could not format jsonValues entry: " + (err instanceof Error ? err.message : "Unknown error"),
|
|
3186
|
+
filePath: jsonPath
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
extraFiles[jsonPath] = jsonText;
|
|
3191
|
+
}
|
|
3192
|
+
if (errors.length === 0) {
|
|
3193
|
+
return {
|
|
3194
|
+
path,
|
|
3195
|
+
appliedPatches,
|
|
3196
|
+
result: sourceFileText,
|
|
3197
|
+
extraFiles
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
1941
3200
|
}
|
|
3201
|
+
const skippedPatches = patches.slice(appliedPatches.length + triedPatches.length).map(p => p.patchId);
|
|
3202
|
+
return {
|
|
3203
|
+
path,
|
|
3204
|
+
appliedPatches,
|
|
3205
|
+
triedPatches,
|
|
3206
|
+
skippedPatches,
|
|
3207
|
+
errors
|
|
3208
|
+
};
|
|
1942
3209
|
};
|
|
1943
3210
|
const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
|
|
1944
3211
|
let hasErrors = false;
|
|
@@ -1957,7 +3224,14 @@ class ValOps {
|
|
|
1957
3224
|
triedPatches[res.path] = res.triedPatches ?? [];
|
|
1958
3225
|
skippedPatches[res.path] = res.skippedPatches ?? [];
|
|
1959
3226
|
} else {
|
|
1960
|
-
|
|
3227
|
+
// `result` is null when the `.val.ts` itself was not changed (pure
|
|
3228
|
+
// jsonValues content edits only write `*.val.json` extraFiles).
|
|
3229
|
+
if (res.result !== null) {
|
|
3230
|
+
patchedSourceFiles[res.path] = res.result;
|
|
3231
|
+
}
|
|
3232
|
+
for (const [extraPath, data] of Object.entries(res.extraFiles)) {
|
|
3233
|
+
patchedSourceFiles[extraPath] = data;
|
|
3234
|
+
}
|
|
1961
3235
|
appliedPatches[res.path] = res.appliedPatches ?? [];
|
|
1962
3236
|
}
|
|
1963
3237
|
for (const patchId of res.appliedPatches ?? []) {
|
|
@@ -2134,6 +3408,130 @@ function bufferFromDataUrl(dataUrl) {
|
|
|
2134
3408
|
}
|
|
2135
3409
|
}
|
|
2136
3410
|
|
|
3411
|
+
/**
|
|
3412
|
+
* The `*.val.json` files backing a `.jsonValues()` module's entries, as paths
|
|
3413
|
+
* relative to the project root.
|
|
3414
|
+
*
|
|
3415
|
+
* Read out of the `.val.ts` rather than off the loaded module: the entry's path
|
|
3416
|
+
* only exists as the string literal inside its `c.json(() => import("..."))`
|
|
3417
|
+
* thunk, and the loaded marker does not carry it.
|
|
3418
|
+
*/
|
|
3419
|
+
function findJsonEntryFilePathsInSource(moduleFilePath, valTsSourceFile) {
|
|
3420
|
+
let analysis;
|
|
3421
|
+
try {
|
|
3422
|
+
analysis = analyzeValModule(valTsSourceFile);
|
|
3423
|
+
} catch {
|
|
3424
|
+
return [];
|
|
3425
|
+
}
|
|
3426
|
+
if (result.isErr(analysis)) {
|
|
3427
|
+
return [];
|
|
3428
|
+
}
|
|
3429
|
+
const paths = [];
|
|
3430
|
+
analyzeJsonValuesEntries(analysis.value.source).forEach(entry => {
|
|
3431
|
+
paths.push(resolveExistingJsonPath(moduleFilePath, entry.importPath));
|
|
3432
|
+
});
|
|
3433
|
+
return paths;
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
/**
|
|
3437
|
+
* A fingerprint of every `.jsonValues()` entry file on disk, for change
|
|
3438
|
+
* detection.
|
|
3439
|
+
*
|
|
3440
|
+
* Uses each file's SIZE + MTIME, never its content: reading every entry on every
|
|
3441
|
+
* stat would undo the point of `.jsonValues()`, and the question here is only
|
|
3442
|
+
* "did any of them change", not "what do they say now".
|
|
3443
|
+
*
|
|
3444
|
+
* Why this needs to exist at all: `sourcesSha` and `baseSha` are computed from
|
|
3445
|
+
* `JSON.stringify(source)`, and a jsonValues module's source is just markers —
|
|
3446
|
+
* the content is behind a thunk, which `JSON.stringify` drops. So no existing sha
|
|
3447
|
+
* can see an entry edit, and the Studio has no way to learn that a hand-edited
|
|
3448
|
+
* `*.val.json` changed.
|
|
3449
|
+
*
|
|
3450
|
+
* Deliberately NOT folded into `sourcesSha`: that is computed by the client too
|
|
3451
|
+
* (from the ValModules registry, which has no entry content), so the two would
|
|
3452
|
+
* disagree and trip the schema-out-of-date machinery.
|
|
3453
|
+
*/
|
|
3454
|
+
class JsonEntryFilesFingerprint {
|
|
3455
|
+
/** Entry paths per module, memoized on the `.val.ts` mtime that produced them. */
|
|
3456
|
+
cache = new Map();
|
|
3457
|
+
constructor(rootDir) {
|
|
3458
|
+
this.rootDir = rootDir;
|
|
3459
|
+
}
|
|
3460
|
+
compute(schemas) {
|
|
3461
|
+
const parts = [];
|
|
3462
|
+
for (const entryFilePath of this.entryFilePaths(schemas)) {
|
|
3463
|
+
parts.push(`${entryFilePath}:${this.fileFingerprint(entryFilePath)}`);
|
|
3464
|
+
}
|
|
3465
|
+
return parts.join("|");
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* Every `.jsonValues()` entry file the given schemas reach, rootDir-relative and
|
|
3470
|
+
* in a stable order.
|
|
3471
|
+
*
|
|
3472
|
+
* Public because the fingerprint is only half the story: the fs polling fallback
|
|
3473
|
+
* (which exists because `fs.watch` is unreliable on some systems, notably WSL)
|
|
3474
|
+
* has to watch these files too, or on those systems a hand-edited entry waits
|
|
3475
|
+
* out the whole long-poll interval.
|
|
3476
|
+
*/
|
|
3477
|
+
entryFilePaths(schemas) {
|
|
3478
|
+
const paths = [];
|
|
3479
|
+
for (const moduleFilePathS of Object.keys(schemas).sort()) {
|
|
3480
|
+
const moduleFilePath = moduleFilePathS;
|
|
3481
|
+
const schema = schemas[moduleFilePath];
|
|
3482
|
+
// Root-only, per locked decision #7 — so a module whose root is not a
|
|
3483
|
+
// jsonValues record has no entry files to fingerprint.
|
|
3484
|
+
if (schema === undefined || schema.type !== "record" || schema.jsonValues !== true) {
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
paths.push(...this.entryFilePathsOf(moduleFilePath));
|
|
3488
|
+
}
|
|
3489
|
+
return paths;
|
|
3490
|
+
}
|
|
3491
|
+
entryFilePathsOf(moduleFilePath) {
|
|
3492
|
+
const absModulePath = path__default.join(this.rootDir, moduleFilePath);
|
|
3493
|
+
let moduleMtimeMs;
|
|
3494
|
+
try {
|
|
3495
|
+
moduleMtimeMs = fs.statSync(absModulePath).mtimeMs;
|
|
3496
|
+
} catch {
|
|
3497
|
+
return [];
|
|
3498
|
+
}
|
|
3499
|
+
const cached = this.cache.get(moduleFilePath);
|
|
3500
|
+
// The entry LIST only changes when the `.val.ts` does (that is where the
|
|
3501
|
+
// thunks live), so parsing it again on every stat would be wasted work.
|
|
3502
|
+
if (cached && cached.moduleMtimeMs === moduleMtimeMs) {
|
|
3503
|
+
return cached.entryFilePaths;
|
|
3504
|
+
}
|
|
3505
|
+
let entryFilePaths = [];
|
|
3506
|
+
try {
|
|
3507
|
+
const contents = fs.readFileSync(absModulePath, "utf-8");
|
|
3508
|
+
entryFilePaths = findJsonEntryFilePathsInSource(moduleFilePath, ts.createSourceFile(absModulePath, contents, ts.ScriptTarget.ES2015));
|
|
3509
|
+
} catch {
|
|
3510
|
+
entryFilePaths = [];
|
|
3511
|
+
}
|
|
3512
|
+
this.cache.set(moduleFilePath, {
|
|
3513
|
+
moduleMtimeMs,
|
|
3514
|
+
entryFilePaths
|
|
3515
|
+
});
|
|
3516
|
+
return entryFilePaths;
|
|
3517
|
+
}
|
|
3518
|
+
fileFingerprint(entryFilePath) {
|
|
3519
|
+
try {
|
|
3520
|
+
// NANOSECOND mtime, not `mtimeMs`: two writes inside the same millisecond
|
|
3521
|
+
// that happen to preserve the file size would otherwise be indistinguishable
|
|
3522
|
+
// from no change at all, and the edit would silently never reach the Studio.
|
|
3523
|
+
const stat = fs.statSync(path__default.join(this.rootDir, entryFilePath), {
|
|
3524
|
+
bigint: true
|
|
3525
|
+
});
|
|
3526
|
+
return `${stat.size}:${stat.mtimeNs}`;
|
|
3527
|
+
} catch {
|
|
3528
|
+
// Missing is a state too — going from present to absent must change the
|
|
3529
|
+
// fingerprint, not be indistinguishable from unchanged.
|
|
3530
|
+
return "missing";
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
|
|
2137
3535
|
/**
|
|
2138
3536
|
* Computes the changed patch parent references based on the current patches and the patch IDs to be deleted.
|
|
2139
3537
|
*
|
|
@@ -2225,6 +3623,14 @@ function getFileExt(filePath) {
|
|
|
2225
3623
|
return filePath.split(".").pop() || "";
|
|
2226
3624
|
}
|
|
2227
3625
|
|
|
3626
|
+
/** Serializes a schema, or gives up quietly — serialization errors are reported elsewhere. */
|
|
3627
|
+
function serializeSchemaSafely(schema) {
|
|
3628
|
+
try {
|
|
3629
|
+
return schema === null || schema === void 0 ? void 0 : schema["executeSerialize"]();
|
|
3630
|
+
} catch {
|
|
3631
|
+
return undefined;
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
2228
3634
|
class ValOpsFS extends ValOps {
|
|
2229
3635
|
static VAL_DIR = ".val";
|
|
2230
3636
|
constructor(contentUrl, rootDir, valModules, options) {
|
|
@@ -2232,7 +3638,14 @@ class ValOpsFS extends ValOps {
|
|
|
2232
3638
|
this.contentUrl = contentUrl;
|
|
2233
3639
|
this.rootDir = rootDir;
|
|
2234
3640
|
this.host = new FSOpsHost();
|
|
3641
|
+
this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
|
|
2235
3642
|
}
|
|
3643
|
+
|
|
3644
|
+
/**
|
|
3645
|
+
* Change detection for `.jsonValues()` entry files, which no sha can see (their
|
|
3646
|
+
* content lives behind a thunk that `JSON.stringify` drops).
|
|
3647
|
+
*/
|
|
3648
|
+
|
|
2236
3649
|
async onInit() {
|
|
2237
3650
|
// do nothing
|
|
2238
3651
|
}
|
|
@@ -2321,7 +3734,11 @@ class ValOpsFS extends ValOps {
|
|
|
2321
3734
|
const currentBaseSha = await this.getBaseSha();
|
|
2322
3735
|
const currentSchemaSha = await this.getSchemaSha();
|
|
2323
3736
|
const currentSourcesSha = await this.getSourcesSha();
|
|
2324
|
-
const
|
|
3737
|
+
const schemas = await this.getSchemas();
|
|
3738
|
+
const moduleFilePaths = Object.keys(schemas);
|
|
3739
|
+
const serializedSchemas = Object.fromEntries(Object.entries(schemas).map(([path, schema]) => [path, serializeSchemaSafely(schema)]));
|
|
3740
|
+
const currentJsonEntriesSha = this.jsonEntryFilesFingerprint.compute(serializedSchemas);
|
|
3741
|
+
const jsonEntryFilePaths = this.jsonEntryFilesFingerprint.entryFilePaths(serializedSchemas);
|
|
2325
3742
|
const patchData = await this.readPatches();
|
|
2326
3743
|
const patches = [];
|
|
2327
3744
|
// TODO: use proper patch sequences when available:
|
|
@@ -2331,7 +3748,10 @@ class ValOpsFS extends ValOps {
|
|
|
2331
3748
|
patches.push(patchId);
|
|
2332
3749
|
}
|
|
2333
3750
|
// something changed: return immediately
|
|
2334
|
-
const didChange = !params ||
|
|
3751
|
+
const didChange = !params ||
|
|
3752
|
+
// An entry file changed on disk: nothing else here can see that, since a
|
|
3753
|
+
// jsonValues module's source is markers.
|
|
3754
|
+
params.jsonEntriesSha !== undefined && currentJsonEntriesSha !== params.jsonEntriesSha || currentBaseSha !== params.baseSha ||
|
|
2335
3755
|
// base sha covers both sources sha and schema sha, so we could remove checks for schema sha and sources sha
|
|
2336
3756
|
currentSourcesSha !== params.sourcesSha || currentSchemaSha !== params.schemaSha || patches.length !== params.patches.length || patches.some((p, i) => p !== params.patches[i]);
|
|
2337
3757
|
if (didChange) {
|
|
@@ -2340,7 +3760,8 @@ class ValOpsFS extends ValOps {
|
|
|
2340
3760
|
baseSha: currentBaseSha,
|
|
2341
3761
|
schemaSha: currentSchemaSha,
|
|
2342
3762
|
sourcesSha: currentSourcesSha,
|
|
2343
|
-
patches
|
|
3763
|
+
patches,
|
|
3764
|
+
jsonEntriesSha: currentJsonEntriesSha
|
|
2344
3765
|
};
|
|
2345
3766
|
}
|
|
2346
3767
|
let fsWatcher = null;
|
|
@@ -2423,7 +3844,12 @@ class ValOpsFS extends ValOps {
|
|
|
2423
3844
|
patchesDirHandle = handle;
|
|
2424
3845
|
}),
|
|
2425
3846
|
// we poll the files that Val depends on for changes
|
|
2426
|
-
disableFilePolling ? new Promise(() => {}) : didFilesChangeUsingPolling([path__default.join(this.rootDir, "val.config.ts"), path__default.join(this.rootDir, "val.modules.ts"), path__default.join(this.rootDir, "val.config.js"), path__default.join(this.rootDir, "val.modules.js"), ...moduleFilePaths.map(p => path__default.join(this.rootDir, p))
|
|
3847
|
+
disableFilePolling ? new Promise(() => {}) : didFilesChangeUsingPolling([path__default.join(this.rootDir, "val.config.ts"), path__default.join(this.rootDir, "val.modules.ts"), path__default.join(this.rootDir, "val.config.js"), path__default.join(this.rootDir, "val.modules.js"), ...moduleFilePaths.map(p => path__default.join(this.rootDir, p)),
|
|
3848
|
+
// The `.jsonValues()` entry files too: their content is invisible
|
|
3849
|
+
// to every sha, and this polling fallback exists for the systems
|
|
3850
|
+
// where the `fs.watch` below does not fire (notably WSL) — without
|
|
3851
|
+
// them a hand-edited entry waits out the whole long-poll interval.
|
|
3852
|
+
...jsonEntryFilePaths.map(p => path__default.join(this.rootDir, p))], statFilePollingInterval, handle => {
|
|
2427
3853
|
valFilesIntervalHandle = handle;
|
|
2428
3854
|
}), new Promise(resolve => {
|
|
2429
3855
|
fsWatcher = fs.watch(this.rootDir, {
|
|
@@ -2432,7 +3858,11 @@ class ValOpsFS extends ValOps {
|
|
|
2432
3858
|
if (!filename) {
|
|
2433
3859
|
return;
|
|
2434
3860
|
}
|
|
2435
|
-
const isChange = filename.startsWith(this.getPatchesDir().slice(this.rootDir.length + 1)) || filename.endsWith(".val.ts") || filename.endsWith(".val.js") ||
|
|
3861
|
+
const isChange = filename.startsWith(this.getPatchesDir().slice(this.rootDir.length + 1)) || filename.endsWith(".val.ts") || filename.endsWith(".val.js") ||
|
|
3862
|
+
// A `.jsonValues()` entry file. Its content is invisible to every
|
|
3863
|
+
// sha (see JsonEntryFilesFingerprint), so without this a
|
|
3864
|
+
// hand-edited entry never reaches an open Studio.
|
|
3865
|
+
filename.endsWith(".val.json") || filename.endsWith("val.config.ts") || filename.endsWith("val.config.js") || filename.endsWith("val.modules.ts") || filename.endsWith("val.modules.js");
|
|
2436
3866
|
if (isChange) {
|
|
2437
3867
|
// a file that Val depends on just changed or a patch was created, break connection and request stat again to get the new values
|
|
2438
3868
|
resolve("request-again");
|
|
@@ -2454,7 +3884,8 @@ class ValOpsFS extends ValOps {
|
|
|
2454
3884
|
baseSha: currentBaseSha,
|
|
2455
3885
|
schemaSha: currentSchemaSha,
|
|
2456
3886
|
sourcesSha: currentSourcesSha,
|
|
2457
|
-
patches
|
|
3887
|
+
patches,
|
|
3888
|
+
jsonEntriesSha: currentJsonEntriesSha
|
|
2458
3889
|
};
|
|
2459
3890
|
} catch (err) {
|
|
2460
3891
|
if (err instanceof Error) {
|
|
@@ -5513,9 +6944,148 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
5513
6944
|
};
|
|
5514
6945
|
}
|
|
5515
6946
|
},
|
|
6947
|
+
// #region json
|
|
6948
|
+
// Loads the content of a single `.jsonValues()` entry by key, so the Studio
|
|
6949
|
+
// can lazily load just the entry being opened, and the runtime can read draft
|
|
6950
|
+
// edits. With apply_patches (default true) pending patches for the entry are
|
|
6951
|
+
// replayed server-side; the Studio passes false and overlays its own.
|
|
6952
|
+
"/json": {
|
|
6953
|
+
GET: async req => {
|
|
6954
|
+
const auth = getAuth(req.cookies);
|
|
6955
|
+
if (auth.error) {
|
|
6956
|
+
return {
|
|
6957
|
+
status: 401,
|
|
6958
|
+
json: {
|
|
6959
|
+
message: auth.error
|
|
6960
|
+
}
|
|
6961
|
+
};
|
|
6962
|
+
}
|
|
6963
|
+
if (serverOps instanceof ValOpsHttp && !("id" in auth)) {
|
|
6964
|
+
return {
|
|
6965
|
+
status: 401,
|
|
6966
|
+
json: {
|
|
6967
|
+
message: "Unauthorized"
|
|
6968
|
+
}
|
|
6969
|
+
};
|
|
6970
|
+
}
|
|
6971
|
+
const moduleFilePath = req.query.path;
|
|
6972
|
+
const {
|
|
6973
|
+
key,
|
|
6974
|
+
keys,
|
|
6975
|
+
offset,
|
|
6976
|
+
limit
|
|
6977
|
+
} = req.query;
|
|
6978
|
+
// Defaults to true, mirroring /sources/~. The Studio opts out.
|
|
6979
|
+
const applyPatches = req.query.apply_patches !== false;
|
|
6980
|
+
const isWindow = offset !== undefined || limit !== undefined;
|
|
6981
|
+
const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
|
|
6982
|
+
if (shapes !== 1) {
|
|
6983
|
+
return {
|
|
6984
|
+
status: 400,
|
|
6985
|
+
json: {
|
|
6986
|
+
message: "Exactly one of 'key', 'keys' or 'offset'+'limit' must be given"
|
|
6987
|
+
}
|
|
6988
|
+
};
|
|
6989
|
+
}
|
|
6990
|
+
if (isWindow && (offset === undefined || limit === undefined)) {
|
|
6991
|
+
return {
|
|
6992
|
+
status: 400,
|
|
6993
|
+
json: {
|
|
6994
|
+
message: "'offset' and 'limit' must be given together"
|
|
6995
|
+
}
|
|
6996
|
+
};
|
|
6997
|
+
}
|
|
6998
|
+
if (key !== undefined) {
|
|
6999
|
+
const res = await serverOps.getJsonEntry(moduleFilePath, key, {
|
|
7000
|
+
applyPatches
|
|
7001
|
+
});
|
|
7002
|
+
if (res.status === "unauthorized") {
|
|
7003
|
+
return {
|
|
7004
|
+
status: 401,
|
|
7005
|
+
json: {
|
|
7006
|
+
message: res.message
|
|
7007
|
+
}
|
|
7008
|
+
};
|
|
7009
|
+
}
|
|
7010
|
+
if (res.status === "not-found") {
|
|
7011
|
+
return {
|
|
7012
|
+
status: 404,
|
|
7013
|
+
json: {
|
|
7014
|
+
message: res.message
|
|
7015
|
+
}
|
|
7016
|
+
};
|
|
7017
|
+
}
|
|
7018
|
+
if (res.status === "error") {
|
|
7019
|
+
return {
|
|
7020
|
+
status: 500,
|
|
7021
|
+
json: {
|
|
7022
|
+
message: res.message
|
|
7023
|
+
}
|
|
7024
|
+
};
|
|
7025
|
+
}
|
|
7026
|
+
return {
|
|
7027
|
+
status: 200,
|
|
7028
|
+
json: {
|
|
7029
|
+
path: moduleFilePath,
|
|
7030
|
+
key,
|
|
7031
|
+
content: res.content
|
|
7032
|
+
}
|
|
7033
|
+
};
|
|
7034
|
+
}
|
|
7035
|
+
const res = await serverOps.getJsonEntries(moduleFilePath, keys !== undefined ? {
|
|
7036
|
+
keys
|
|
7037
|
+
} : {
|
|
7038
|
+
offset: offset,
|
|
7039
|
+
limit: limit
|
|
7040
|
+
}, {
|
|
7041
|
+
applyPatches
|
|
7042
|
+
});
|
|
7043
|
+
if (res.status === "unauthorized") {
|
|
7044
|
+
return {
|
|
7045
|
+
status: 401,
|
|
7046
|
+
json: {
|
|
7047
|
+
message: res.message
|
|
7048
|
+
}
|
|
7049
|
+
};
|
|
7050
|
+
}
|
|
7051
|
+
if (res.status === "not-found") {
|
|
7052
|
+
return {
|
|
7053
|
+
status: 404,
|
|
7054
|
+
json: {
|
|
7055
|
+
message: res.message
|
|
7056
|
+
}
|
|
7057
|
+
};
|
|
7058
|
+
}
|
|
7059
|
+
if (res.status === "error") {
|
|
7060
|
+
return {
|
|
7061
|
+
status: 500,
|
|
7062
|
+
json: {
|
|
7063
|
+
message: res.message
|
|
7064
|
+
}
|
|
7065
|
+
};
|
|
7066
|
+
}
|
|
7067
|
+
return {
|
|
7068
|
+
status: 200,
|
|
7069
|
+
json: {
|
|
7070
|
+
path: moduleFilePath,
|
|
7071
|
+
entries: res.entries,
|
|
7072
|
+
missing: res.missing,
|
|
7073
|
+
errors: res.errors,
|
|
7074
|
+
...(res.offset !== undefined ? {
|
|
7075
|
+
offset: res.offset
|
|
7076
|
+
} : {}),
|
|
7077
|
+
...(res.limit !== undefined ? {
|
|
7078
|
+
limit: res.limit
|
|
7079
|
+
} : {}),
|
|
7080
|
+
total: res.total
|
|
7081
|
+
}
|
|
7082
|
+
};
|
|
7083
|
+
}
|
|
7084
|
+
},
|
|
5516
7085
|
// #region sources
|
|
5517
7086
|
"/sources/~": {
|
|
5518
7087
|
PUT: async req => {
|
|
7088
|
+
var _patchOps$patches;
|
|
5519
7089
|
const query = req.query;
|
|
5520
7090
|
const cookies = req.cookies;
|
|
5521
7091
|
// TODO: filter results by moduleFilePath
|
|
@@ -5573,27 +7143,34 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
5573
7143
|
const unpatchedSources = sourcesRes.sources;
|
|
5574
7144
|
// Default to true to keep the legacy contract for older clients.
|
|
5575
7145
|
// The studio client always passes false: it owns patch application
|
|
5576
|
-
// and
|
|
7146
|
+
// and validation, and treats /sources/~ as a pure un-patched read.
|
|
5577
7147
|
const applyPatches = query.apply_patches !== false;
|
|
5578
|
-
|
|
7148
|
+
// NOTE: renders are computed here even when the client applies patches
|
|
7149
|
+
// itself: the render select functions live on the Schema instances and
|
|
7150
|
+
// are not part of the serialized schema, so the client cannot derive
|
|
7151
|
+
// them. They are computed on the patched sources, so that previews
|
|
7152
|
+
// (list titles / subtitles / images) reflect the patches that apply.
|
|
7153
|
+
let patchedSources = sourcesRes.sources;
|
|
7154
|
+
if ((((_patchOps$patches = patchOps.patches) === null || _patchOps$patches === void 0 ? void 0 : _patchOps$patches.length) ?? 0) > 0) {
|
|
5579
7155
|
const onlyPatchedTreeModules = await serverOps.getSources({
|
|
5580
7156
|
...patchAnalysis,
|
|
5581
7157
|
...patchOps
|
|
5582
7158
|
});
|
|
5583
|
-
|
|
5584
|
-
sources
|
|
5585
|
-
|
|
5586
|
-
...(onlyPatchedTreeModules.sources || {})
|
|
5587
|
-
},
|
|
5588
|
-
errors: {
|
|
5589
|
-
...sourcesRes.errors,
|
|
5590
|
-
...(onlyPatchedTreeModules.errors || {})
|
|
5591
|
-
}
|
|
7159
|
+
patchedSources = {
|
|
7160
|
+
...sourcesRes.sources,
|
|
7161
|
+
...(onlyPatchedTreeModules.sources || {})
|
|
5592
7162
|
};
|
|
7163
|
+
if (applyPatches) {
|
|
7164
|
+
sourcesRes = {
|
|
7165
|
+
sources: patchedSources,
|
|
7166
|
+
errors: {
|
|
7167
|
+
...sourcesRes.errors,
|
|
7168
|
+
...(onlyPatchedTreeModules.errors || {})
|
|
7169
|
+
}
|
|
7170
|
+
};
|
|
7171
|
+
}
|
|
5593
7172
|
}
|
|
5594
|
-
const renderRes =
|
|
5595
|
-
renders: {}
|
|
5596
|
-
};
|
|
7173
|
+
const renderRes = await serverOps.getRenders(schemasRes, patchedSources);
|
|
5597
7174
|
let sourcesValidation = {
|
|
5598
7175
|
errors: {},
|
|
5599
7176
|
files: {},
|
|
@@ -8686,6 +10263,23 @@ function createModulePathMap(sourceFile) {
|
|
|
8686
10263
|
}
|
|
8687
10264
|
}
|
|
8688
10265
|
}
|
|
10266
|
+
|
|
10267
|
+
/**
|
|
10268
|
+
* The {@link createModulePathMap} equivalent for a `.jsonValues()` entry's
|
|
10269
|
+
* backing `*.val.json`.
|
|
10270
|
+
*
|
|
10271
|
+
* The file IS the entry's value, so the map is rooted at the JSON document
|
|
10272
|
+
* rather than at a `c.define` argument — but everything below is the same shape,
|
|
10273
|
+
* which means a module path like `"title"` (the part after the entry key)
|
|
10274
|
+
* resolves against it exactly as it would inside a `.val.ts`.
|
|
10275
|
+
*/
|
|
10276
|
+
function createJsonEntryPathMap(jsonSourceFile) {
|
|
10277
|
+
const statement = jsonSourceFile.statements[0];
|
|
10278
|
+
if (!statement) {
|
|
10279
|
+
return undefined;
|
|
10280
|
+
}
|
|
10281
|
+
return traverse(statement.expression, jsonSourceFile);
|
|
10282
|
+
}
|
|
8689
10283
|
function traverse(node, sourceFile) {
|
|
8690
10284
|
if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
|
|
8691
10285
|
return {
|
|
@@ -8775,6 +10369,93 @@ function traverseObjectLiteral(node, sourceFile) {
|
|
|
8775
10369
|
}, {});
|
|
8776
10370
|
}
|
|
8777
10371
|
|
|
10372
|
+
/**
|
|
10373
|
+
* Which `*.val.json` holds a `.jsonValues()` entry's content, given the module's
|
|
10374
|
+
* `.val.ts`.
|
|
10375
|
+
*
|
|
10376
|
+
* Tooling that maps a validation `sourcePath` back to a place in a file needs
|
|
10377
|
+
* this: for a jsonValues module the offending value is NOT in the `.val.ts` at
|
|
10378
|
+
* all — that file only holds `c.json(() => import("./x.val.json"))` — so a
|
|
10379
|
+
* resolver that only ever looks at the `.val.ts` can report the error but not
|
|
10380
|
+
* where it lives, which for a record with hundreds of entries is not much of a
|
|
10381
|
+
* report.
|
|
10382
|
+
*
|
|
10383
|
+
* Returns a path relative to the project root (leading slash), or undefined when
|
|
10384
|
+
* the module has no such entry (not a jsonValues module, unparseable, or the key
|
|
10385
|
+
* is not backed by a `c.json` thunk).
|
|
10386
|
+
*/
|
|
10387
|
+
function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
|
|
10388
|
+
let analysis;
|
|
10389
|
+
try {
|
|
10390
|
+
analysis = analyzeValModule(valTsSourceFile);
|
|
10391
|
+
} catch {
|
|
10392
|
+
return undefined;
|
|
10393
|
+
}
|
|
10394
|
+
if (result.isErr(analysis)) {
|
|
10395
|
+
return undefined;
|
|
10396
|
+
}
|
|
10397
|
+
const entry = analyzeJsonValuesEntries(analysis.value.source).get(entryKey);
|
|
10398
|
+
if (!entry) {
|
|
10399
|
+
return undefined;
|
|
10400
|
+
}
|
|
10401
|
+
return resolveExistingJsonPath(moduleFilePath, entry.importPath);
|
|
10402
|
+
}
|
|
10403
|
+
|
|
10404
|
+
/**
|
|
10405
|
+
* Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
|
|
10406
|
+
* its own `*.val.json`, replacing the inline value with
|
|
10407
|
+
* `c.json(() => import("./<key>.val.json"))`.
|
|
10408
|
+
*
|
|
10409
|
+
* This is the fix for the `jsonValues:extract-entry` validation error. It is not
|
|
10410
|
+
* expressible as a patch (a patch edits one `.val.ts` and cannot create the
|
|
10411
|
+
* backing JSON file), so it writes both files directly — JSON first, so a
|
|
10412
|
+
* failure part-way through never leaves the module pointing at a file that does
|
|
10413
|
+
* not exist.
|
|
10414
|
+
*
|
|
10415
|
+
* Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
|
|
10416
|
+
* up in the module's root record/router object literal.
|
|
10417
|
+
*/
|
|
10418
|
+
function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
|
|
10419
|
+
const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
|
|
10420
|
+
const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
|
|
10421
|
+
if (!sourceFile) {
|
|
10422
|
+
throw Error(`Source file ${valTsPath} not found`);
|
|
10423
|
+
}
|
|
10424
|
+
const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
|
|
10425
|
+
if (result.isErr(pathsRes)) {
|
|
10426
|
+
throw Error(formatPatchSourceError(pathsRes.error));
|
|
10427
|
+
}
|
|
10428
|
+
const {
|
|
10429
|
+
jsonPath,
|
|
10430
|
+
importPath
|
|
10431
|
+
} = pathsRes.value;
|
|
10432
|
+
const absoluteJsonPath = path__default.join(rootDir, jsonPath);
|
|
10433
|
+
if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
|
|
10434
|
+
throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
|
|
10435
|
+
}
|
|
10436
|
+
|
|
10437
|
+
// Remove the inline property, then add the `c.json(...)` reference back. The
|
|
10438
|
+
// entry moves to the end of the record: entry ORDER in a jsonValues record is
|
|
10439
|
+
// not meaningful (the Studio and the runtime key entries by name), and
|
|
10440
|
+
// insert-in-place would mean reimplementing insertValJsonEntry.
|
|
10441
|
+
const removed = removeValJsonEntry(sourceFile, [], entryKey);
|
|
10442
|
+
if (result.isErr(removed)) {
|
|
10443
|
+
throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
|
|
10444
|
+
}
|
|
10445
|
+
const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
|
|
10446
|
+
if (result.isErr(inserted)) {
|
|
10447
|
+
throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
|
|
10448
|
+
}
|
|
10449
|
+
sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
|
|
10450
|
+
sourceFileHandler.writeSourceFile(inserted.value);
|
|
10451
|
+
}
|
|
10452
|
+
function formatOpsError(error, sourceFile) {
|
|
10453
|
+
if (error instanceof PatchError) {
|
|
10454
|
+
return error.message;
|
|
10455
|
+
}
|
|
10456
|
+
return flatMapErrors(error, e => formatSyntaxError(e, sourceFile)).join("\n");
|
|
10457
|
+
}
|
|
10458
|
+
|
|
8778
10459
|
/**
|
|
8779
10460
|
* Replays a `val debug` snapshot: applies its patches the way /save does and
|
|
8780
10461
|
* validates the result.
|
|
@@ -8874,4 +10555,4 @@ function readCapturedReport(snapshotDir) {
|
|
|
8874
10555
|
return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
8875
10556
|
}
|
|
8876
10557
|
|
|
8877
|
-
export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createFixPatch, createModulePathMap, createService, createValApiRouter, createValServer, decodeJwt, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, findAndEvalValConfigFile, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, hasRemoteFileSchema, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };
|
|
10558
|
+
export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, compareWithCapturedReport, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValServer, decodeJwt, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, hasRemoteFileSchema, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata };
|