@valbuild/server 0.99.1 → 0.101.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.
@@ -1,7 +1,7 @@
1
1
  import ts from 'typescript';
2
- import { result, pipe } from '@valbuild/core/fp';
3
- import { FILE_REF_PROP, FILE_REF_SUBTYPE_TAG, VAL_EXTENSION, derefPatch, extractValModules, Internal, ImageSchema, DEFAULT_CONTENT_HOST } from '@valbuild/core';
4
- import { deepEqual, isNotRoot, PatchError, parseAndValidateArrayIndex, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
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) => {
@@ -1174,10 +1651,48 @@ class Service {
1174
1651
  }
1175
1652
  };
1176
1653
  }
1177
- const validation = opts.validate ? schema["executeValidate"](moduleFilePath, source) : false;
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
+ }
1178
1693
  const resolved = Internal.resolvePath(modulePath, source, serializedSchema);
1179
1694
  const sourcePath = resolved.path ? [moduleFilePath, resolved.path].join(".") : moduleFilePath;
1180
- if (!validation && !moduleError) {
1695
+ if (!validation && !moduleError && !jsonValuesModuleError) {
1181
1696
  return {
1182
1697
  path: sourcePath,
1183
1698
  source: resolved.source,
@@ -1185,15 +1700,24 @@ class Service {
1185
1700
  errors: false
1186
1701
  };
1187
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
+ }
1188
1714
  return {
1189
1715
  path: sourcePath,
1190
1716
  source: resolved.source,
1191
1717
  schema: resolved.schema,
1192
1718
  errors: {
1193
1719
  validation: validation || undefined,
1194
- fatal: moduleError ? [{
1195
- message: moduleError.message
1196
- }] : undefined
1720
+ fatal: fatal.length > 0 ? fatal : undefined
1197
1721
  }
1198
1722
  };
1199
1723
  }
@@ -1263,6 +1787,92 @@ function encodeJwt(payload, sessionKey) {
1263
1787
  return `${jwtHeaderBase64}.${payloadBase64}.${crypto$1.createHmac("sha256", sessionKey).update(`${jwtHeaderBase64}.${payloadBase64}`).digest("base64")}`;
1264
1788
  }
1265
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
+
1266
1876
  /* eslint-disable @typescript-eslint/no-unused-vars */
1267
1877
  const jsonOps = new JSONOps();
1268
1878
  const tsOps = new TSOps(document => {
@@ -1270,6 +1880,38 @@ const tsOps = new TSOps(document => {
1270
1880
  source
1271
1881
  }) => source));
1272
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
+ }
1273
1915
  // #region ValOps
1274
1916
  class ValOps {
1275
1917
  /** Sources from val modules, immutable (without patches or anything) */
@@ -1311,13 +1953,14 @@ class ValOps {
1311
1953
  async initSources() {
1312
1954
  if (this.baseSha === null || this.sourcesSha === null || this.configSha === null || this.schemaSha === null || this.sources === null || this.schemas === null || this.modulesErrors === null) {
1313
1955
  const extracted = await extractValModules(this.valModules);
1956
+ const moduleErrors = extracted.moduleErrors.concat(findNestedJsonValuesModuleErrors(extracted.schemas));
1314
1957
  this.sources = extracted.sources;
1315
1958
  this.schemas = extracted.schemas;
1316
1959
  this.baseSha = extracted.baseSha;
1317
1960
  this.schemaSha = extracted.schemaSha;
1318
1961
  this.sourcesSha = extracted.sourcesSha;
1319
1962
  this.configSha = extracted.configSha;
1320
- this.modulesErrors = extracted.moduleErrors;
1963
+ this.modulesErrors = moduleErrors;
1321
1964
  return {
1322
1965
  baseSha: this.baseSha,
1323
1966
  schemaSha: this.schemaSha,
@@ -1325,7 +1968,7 @@ class ValOps {
1325
1968
  configSha: this.configSha,
1326
1969
  sources: extracted.sources,
1327
1970
  schemas: extracted.schemas,
1328
- moduleErrors: extracted.moduleErrors
1971
+ moduleErrors
1329
1972
  };
1330
1973
  }
1331
1974
  return {
@@ -1348,6 +1991,221 @@ class ValOps {
1348
1991
  async getBaseSources() {
1349
1992
  return this.initSources().then(result => result.sources);
1350
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
+ }
1351
2209
  async getSchemas() {
1352
2210
  return this.initSources().then(result => result.schemas);
1353
2211
  }
@@ -1401,13 +2259,20 @@ class ValOps {
1401
2259
  // ops used to be applied twice. Idempotent for "replace", destructive for
1402
2260
  // array add/remove/move.
1403
2261
  if (hasSourceFileOps) {
2262
+ var _patchesByModule$path;
1404
2263
  const path = patch.path;
1405
2264
  if (!patchesByModule[path]) {
1406
2265
  patchesByModule[path] = [];
1407
2266
  }
1408
- patchesByModule[path].push({
1409
- patchId: patch.patchId
1410
- });
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
+ }
1411
2276
  }
1412
2277
  }
1413
2278
  return {
@@ -1417,6 +2282,15 @@ class ValOps {
1417
2282
  }
1418
2283
 
1419
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
+ */
1420
2294
  async getRenders(schemas, sources) {
1421
2295
  const renders = {};
1422
2296
  for (const [pathS, schema] of Object.entries(schemas)) {
@@ -1444,6 +2318,25 @@ class ValOps {
1444
2318
  } = await this.initSources();
1445
2319
  const patchedSources = {};
1446
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
+ };
1447
2340
  for (const patchData of analysis.patches) {
1448
2341
  const path = patchData.path;
1449
2342
  if (sources[path] === undefined) {
@@ -1472,6 +2365,13 @@ class ValOps {
1472
2365
  } else {
1473
2366
  const applicableOps = [];
1474
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);
1475
2375
  for (const op of patchData.patch) {
1476
2376
  if (op.op === "file") {
1477
2377
  if (op.value !== null) {
@@ -1488,7 +2388,45 @@ class ValOps {
1488
2388
  // null value = delete: no patch_id to inject; the "remove" op in
1489
2389
  // the patch already removes the metadata entry from the source
1490
2390
  } else {
1491
- applicableOps.push(op);
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
+ }
1492
2430
  }
1493
2431
  }
1494
2432
  const patchRes = applyPatch(deepClone(patchedSources[path]),
@@ -1595,6 +2533,21 @@ class ValOps {
1595
2533
  continue;
1596
2534
  }
1597
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
+ }
1598
2551
  if (res === false) {
1599
2552
  continue;
1600
2553
  }
@@ -1837,6 +2790,10 @@ class ValOps {
1837
2790
  const previousSourceFiles = {};
1838
2791
  const partiallyPatchedSourceFiles = {};
1839
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();
1840
2797
  const applySourceFilePatches = async (path, patches) => {
1841
2798
  const sourceFileRes = await this.getSourceFile(path);
1842
2799
  const errors = [];
@@ -1847,13 +2804,99 @@ class ValOps {
1847
2804
  });
1848
2805
  return {
1849
2806
  path,
1850
- errors,
1851
- skippedPatches: patches.map(p => p.patchId)
1852
- };
1853
- }
1854
- const sourceFile = sourceFileRes.data;
1855
- previousSourceFiles[path] = sourceFile;
1856
- let tsSourceFile = ts.createSourceFile("<val>", sourceFile, ts.ScriptTarget.ES2015);
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
+ };
1857
2900
  const appliedPatches = [];
1858
2901
  const triedPatches = [];
1859
2902
  for (const {
@@ -1877,43 +2920,232 @@ class ValOps {
1877
2920
  }
1878
2921
  const patch = patchData.patch;
1879
2922
  const sourceFileOps = patch.filter(op => op.op !== "file"); // file is not a valid source file op
1880
- const patchRes = applyPatch(tsSourceFile, tsOps, sourceFileOps);
1881
- if (result.isErr(patchRes)) {
1882
- if (Array.isArray(patchRes.error)) {
1883
- for (const error of patchRes.error) {
1884
- console.error("Could not patch", JSON.stringify({
1885
- path,
1886
- patchId,
1887
- error,
1888
- sourceFileOps
1889
- }, null, 2));
1890
- }
1891
- errors.push(...patchRes.error);
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
+ }
1892
3086
  } else {
1893
- console.error("Could not patch", JSON.stringify({
1894
- path,
1895
- patchId,
1896
- error: patchRes.error,
1897
- sourceFileOps
1898
- }, null, 2));
1899
- errors.push(patchRes.error);
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);
1900
3127
  }
3128
+ }
3129
+ if (patchHadError) {
1901
3130
  unappliablePatches[patchId] = {
1902
3131
  moduleFilePath: path,
1903
- message: formatPatchSourceError(patchRes.error)
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}`
1904
3136
  };
1905
3137
  triedPatches.push(patchId);
1906
3138
  if (continueOnError) {
1907
- // Continue from the unchanged source file: the failing patch made
1908
- // no change, so the rest of the chain applies on top of the state
1909
- // it had before it. This mirrors what the client already does in
1910
- // ValSyncEngine.getPatchedSource.
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.
1911
3144
  continue;
1912
3145
  }
1913
3146
  break;
1914
3147
  }
1915
3148
  appliedPatches.push(patchId);
1916
- tsSourceFile = patchRes.value;
1917
3149
  }
1918
3150
  if (errors.length > 0 && continueOnError) {
1919
3151
  // Diagnosis: expose what the source file would look like with the
@@ -1922,33 +3154,58 @@ class ValOps {
1922
3154
  partiallyPatchedSourceFiles[path] = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
1923
3155
  }
1924
3156
  if (errors.length === 0) {
1925
- var _this$options;
1926
3157
  // https://github.com/microsoft/TypeScript/issues/36174
1927
- let sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
1928
- if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.formatter) {
1929
- try {
1930
- sourceFileText = await this.options.formatter(sourceFileText, path);
1931
- } catch (err) {
1932
- errors.push({
1933
- message: "Could not format source file: " + (err instanceof Error ? err.message : "Unknown error")
1934
- });
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
+ }
1935
3170
  }
1936
3171
  }
1937
- return {
1938
- path,
1939
- appliedPatches,
1940
- result: sourceFileText
1941
- };
1942
- } else {
1943
- const skippedPatches = patches.slice(appliedPatches.length + triedPatches.length).map(p => p.patchId);
1944
- return {
1945
- path,
1946
- appliedPatches,
1947
- triedPatches,
1948
- skippedPatches,
1949
- errors
1950
- };
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
+ }
1951
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
+ };
1952
3209
  };
1953
3210
  const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
1954
3211
  let hasErrors = false;
@@ -1967,7 +3224,14 @@ class ValOps {
1967
3224
  triedPatches[res.path] = res.triedPatches ?? [];
1968
3225
  skippedPatches[res.path] = res.skippedPatches ?? [];
1969
3226
  } else {
1970
- patchedSourceFiles[res.path] = res.result;
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
+ }
1971
3235
  appliedPatches[res.path] = res.appliedPatches ?? [];
1972
3236
  }
1973
3237
  for (const patchId of res.appliedPatches ?? []) {
@@ -2144,6 +3408,130 @@ function bufferFromDataUrl(dataUrl) {
2144
3408
  }
2145
3409
  }
2146
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
+
2147
3535
  /**
2148
3536
  * Computes the changed patch parent references based on the current patches and the patch IDs to be deleted.
2149
3537
  *
@@ -2235,6 +3623,14 @@ function getFileExt(filePath) {
2235
3623
  return filePath.split(".").pop() || "";
2236
3624
  }
2237
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
+ }
2238
3634
  class ValOpsFS extends ValOps {
2239
3635
  static VAL_DIR = ".val";
2240
3636
  constructor(contentUrl, rootDir, valModules, options) {
@@ -2242,7 +3638,14 @@ class ValOpsFS extends ValOps {
2242
3638
  this.contentUrl = contentUrl;
2243
3639
  this.rootDir = rootDir;
2244
3640
  this.host = new FSOpsHost();
3641
+ this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
2245
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
+
2246
3649
  async onInit() {
2247
3650
  // do nothing
2248
3651
  }
@@ -2331,7 +3734,11 @@ class ValOpsFS extends ValOps {
2331
3734
  const currentBaseSha = await this.getBaseSha();
2332
3735
  const currentSchemaSha = await this.getSchemaSha();
2333
3736
  const currentSourcesSha = await this.getSourcesSha();
2334
- const moduleFilePaths = Object.keys(await this.getSchemas());
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);
2335
3742
  const patchData = await this.readPatches();
2336
3743
  const patches = [];
2337
3744
  // TODO: use proper patch sequences when available:
@@ -2341,7 +3748,10 @@ class ValOpsFS extends ValOps {
2341
3748
  patches.push(patchId);
2342
3749
  }
2343
3750
  // something changed: return immediately
2344
- const didChange = !params || currentBaseSha !== params.baseSha ||
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 ||
2345
3755
  // base sha covers both sources sha and schema sha, so we could remove checks for schema sha and sources sha
2346
3756
  currentSourcesSha !== params.sourcesSha || currentSchemaSha !== params.schemaSha || patches.length !== params.patches.length || patches.some((p, i) => p !== params.patches[i]);
2347
3757
  if (didChange) {
@@ -2350,7 +3760,8 @@ class ValOpsFS extends ValOps {
2350
3760
  baseSha: currentBaseSha,
2351
3761
  schemaSha: currentSchemaSha,
2352
3762
  sourcesSha: currentSourcesSha,
2353
- patches
3763
+ patches,
3764
+ jsonEntriesSha: currentJsonEntriesSha
2354
3765
  };
2355
3766
  }
2356
3767
  let fsWatcher = null;
@@ -2433,7 +3844,12 @@ class ValOpsFS extends ValOps {
2433
3844
  patchesDirHandle = handle;
2434
3845
  }),
2435
3846
  // we poll the files that Val depends on for changes
2436
- 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))], statFilePollingInterval, handle => {
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 => {
2437
3853
  valFilesIntervalHandle = handle;
2438
3854
  }), new Promise(resolve => {
2439
3855
  fsWatcher = fs.watch(this.rootDir, {
@@ -2442,7 +3858,11 @@ class ValOpsFS extends ValOps {
2442
3858
  if (!filename) {
2443
3859
  return;
2444
3860
  }
2445
- const isChange = filename.startsWith(this.getPatchesDir().slice(this.rootDir.length + 1)) || filename.endsWith(".val.ts") || filename.endsWith(".val.js") || filename.endsWith("val.config.ts") || filename.endsWith("val.config.js") || filename.endsWith("val.modules.ts") || filename.endsWith("val.modules.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");
2446
3866
  if (isChange) {
2447
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
2448
3868
  resolve("request-again");
@@ -2464,7 +3884,8 @@ class ValOpsFS extends ValOps {
2464
3884
  baseSha: currentBaseSha,
2465
3885
  schemaSha: currentSchemaSha,
2466
3886
  sourcesSha: currentSourcesSha,
2467
- patches
3887
+ patches,
3888
+ jsonEntriesSha: currentJsonEntriesSha
2468
3889
  };
2469
3890
  } catch (err) {
2470
3891
  if (err instanceof Error) {
@@ -4405,7 +5826,7 @@ function hasRemoteFileSchema(schema) {
4405
5826
  }
4406
5827
  }
4407
5828
  return false;
4408
- } else if (schema.type === "boolean" || schema.type === "number" || schema.type === "string" || schema.type === "literal" || schema.type === "date" || schema.type === "dateTime" || schema.type === "keyOf" || schema.type === "route") {
5829
+ } else if (schema.type === "boolean" || schema.type === "number" || schema.type === "string" || schema.type === "literal" || schema.type === "date" || schema.type === "dateTime" || schema.type === "color" || schema.type === "keyOf" || schema.type === "route") {
4409
5830
  return false;
4410
5831
  } else {
4411
5832
  const exhaustiveCheck = schema;
@@ -5523,6 +6944,144 @@ const ValServer = (valModules, options, callbacks) => {
5523
6944
  };
5524
6945
  }
5525
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
+ },
5526
7085
  // #region sources
5527
7086
  "/sources/~": {
5528
7087
  PUT: async req => {
@@ -8704,6 +10263,23 @@ function createModulePathMap(sourceFile) {
8704
10263
  }
8705
10264
  }
8706
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
+ }
8707
10283
  function traverse(node, sourceFile) {
8708
10284
  if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
8709
10285
  return {
@@ -8793,6 +10369,93 @@ function traverseObjectLiteral(node, sourceFile) {
8793
10369
  }, {});
8794
10370
  }
8795
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
+
8796
10459
  /**
8797
10460
  * Replays a `val debug` snapshot: applies its patches the way /save does and
8798
10461
  * validates the result.
@@ -8892,4 +10555,4 @@ function readCapturedReport(snapshotDir) {
8892
10555
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
8893
10556
  }
8894
10557
 
8895
- 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 };