@valbuild/server 0.99.1 → 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.
@@ -379,6 +379,65 @@ function createValRemoteReference(value) {
379
379
  }
380
380
  return ts__default["default"].factory.createCallExpression(ts__default["default"].factory.createPropertyAccessExpression(ts__default["default"].factory.createIdentifier("c"), ts__default["default"].factory.createIdentifier("remote")), undefined, args);
381
381
  }
382
+
383
+ /**
384
+ * Builds the expression `c.json(() => import("<importPath>"))` used to reference
385
+ * a lazily-loaded `*.val.json` entry of a `.jsonValues()` record.
386
+ */
387
+ function createValJsonReference(importPath) {
388
+ // () => import("<importPath>")
389
+ // NOTE: an `import` identifier prints as the dynamic-import keyword call,
390
+ // which avoids casting the ImportKeyword token (not typed as an Expression).
391
+ const importCall = ts__default["default"].factory.createCallExpression(ts__default["default"].factory.createIdentifier("import"), undefined, [ts__default["default"].factory.createStringLiteral(importPath)]);
392
+ const thunk = ts__default["default"].factory.createArrowFunction(undefined, undefined, [], undefined, ts__default["default"].factory.createToken(ts__default["default"].SyntaxKind.EqualsGreaterThanToken), importCall);
393
+ // c.json(<thunk>)
394
+ return ts__default["default"].factory.createCallExpression(ts__default["default"].factory.createPropertyAccessExpression(ts__default["default"].factory.createIdentifier("c"), ts__default["default"].factory.createIdentifier("json")), undefined, [thunk]);
395
+ }
396
+
397
+ /**
398
+ * Inserts a new `.jsonValues()` entry `"<key>": c.json(() => import("<importPath>"))`
399
+ * into the record's object literal in a `.val.ts` module. `recordPath` is the
400
+ * path from the module source to the record (empty for a root record/router).
401
+ * Fails if the entry key already exists.
402
+ */
403
+ function insertValJsonEntry(document, recordPath, key, importPath) {
404
+ return fp.pipe(analyzeValModule(document), fp.result.flatMap(({
405
+ source
406
+ }) => getAtPath(source, recordPath)), fp.result.flatMap(recordNode => {
407
+ if (!ts__default["default"].isObjectLiteralExpression(recordNode)) {
408
+ return fp.result.err(new patch.PatchError("Cannot add jsonValues entry: record source is not an object literal"));
409
+ }
410
+ return fp.pipe(findObjectPropertyAssignment(recordNode, key), fp.result.flatMap(assignment => {
411
+ if (assignment) {
412
+ return fp.result.err(new patch.PatchError(`Cannot add jsonValues entry '${key}': it already exists`));
413
+ }
414
+ const property = ts__default["default"].factory.createPropertyAssignment(isValidIdentifier(key) ? ts__default["default"].factory.createIdentifier(key) : ts__default["default"].factory.createStringLiteral(key), createValJsonReference(importPath));
415
+ return fp.result.ok(insertAt(document, recordNode.properties, recordNode.properties.length, property));
416
+ }));
417
+ }));
418
+ }
419
+
420
+ /**
421
+ * Removes the `.jsonValues()` entry `"<key>"` from the record's object literal
422
+ * in a `.val.ts` module. `recordPath` is the path from the module source to the
423
+ * record (empty for a root record/router). Fails if the entry key is missing.
424
+ */
425
+ function removeValJsonEntry(document, recordPath, key) {
426
+ return fp.pipe(analyzeValModule(document), fp.result.flatMap(({
427
+ source
428
+ }) => getAtPath(source, recordPath)), fp.result.flatMap(recordNode => {
429
+ if (!ts__default["default"].isObjectLiteralExpression(recordNode)) {
430
+ return fp.result.err(new patch.PatchError("Cannot remove jsonValues entry: record source is not an object literal"));
431
+ }
432
+ return fp.pipe(findObjectPropertyAssignment(recordNode, key), fp.result.flatMap(assignment => {
433
+ if (!assignment) {
434
+ return fp.result.err(new patch.PatchError(`Cannot remove jsonValues entry '${key}': it does not exist`));
435
+ }
436
+ const [doc] = removeAt(document, recordNode.properties, recordNode.properties.indexOf(assignment));
437
+ return fp.result.ok(doc);
438
+ }));
439
+ }));
440
+ }
382
441
  function toExpression(value) {
383
442
  if (typeof value === "string") {
384
443
  // TODO: Use configuration/heuristics to determine use of single quote or double quote
@@ -1013,7 +1072,7 @@ function findValModulesPath(projectRoot, host) {
1013
1072
  }
1014
1073
  return null;
1015
1074
  }
1016
- const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"];
1075
+ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs", ".json"];
1017
1076
 
1018
1077
  // Specifiers that user val files must not actually use. We stub them so that
1019
1078
  // importing is fine, but using a value throws a clear error. Real @valbuild
@@ -1053,6 +1112,27 @@ function loadModule(absPath, cache, compilerOptions, host) {
1053
1112
  if (cached) {
1054
1113
  return cached;
1055
1114
  }
1115
+ // JSON modules (e.g. the `*.val.json` files backing `.jsonValues()` entries)
1116
+ // are loaded by parsing, mirroring Node's `require("./x.json")` which returns
1117
+ // the parsed object as `module.exports`. The importing `.val.ts` wraps this
1118
+ // with `__importStar` so `import("./x.val.json")` yields `{ default, ... }`.
1119
+ // These are only loaded when an entry thunk is invoked, never during
1120
+ // `extractValModules`, so this stays lazy.
1121
+ //
1122
+ // Read through `host` like every other project file: an editor integration
1123
+ // passes a host that overlays unsaved buffers, and an entry the user is
1124
+ // editing has to resolve to what they are looking at.
1125
+ if (absPath.endsWith(".json")) {
1126
+ const json = host.readFile(absPath);
1127
+ if (json === undefined) {
1128
+ throw Error(`Could not read Val module file: '${absPath}'`);
1129
+ }
1130
+ const jsonModule = {
1131
+ exports: JSON.parse(json)
1132
+ };
1133
+ cache[absPath] = jsonModule;
1134
+ return jsonModule;
1135
+ }
1056
1136
  const code = host.readFile(absPath);
1057
1137
  if (code === undefined) {
1058
1138
  throw Error(`Could not read Val module file: '${absPath}'`);
@@ -1141,6 +1221,403 @@ function resolveRelative(dirName, spec, host) {
1141
1221
  return null;
1142
1222
  }
1143
1223
 
1224
+ const jsonOps$1 = new patch.JSONOps();
1225
+
1226
+ /**
1227
+ * Classification of a single patch op against a module's serialized schema,
1228
+ * used by the commit flow to route ops for `.jsonValues()` records:
1229
+ *
1230
+ * - `normal`: the op does not descend into a `.jsonValues()` entry; apply it to
1231
+ * the `.val.ts` as usual.
1232
+ * - `entry`: the op targets a `.jsonValues()` entry. `recordPath` is the path to
1233
+ * the record within the module source (empty for a root record/router),
1234
+ * `entryKey` is the entry key, and `subPath` is the remaining path inside the
1235
+ * entry (empty when the op targets the entry value itself, e.g. add/remove of
1236
+ * the whole entry).
1237
+ */
1238
+
1239
+ /**
1240
+ * Walks the serialized schema following the op path. When a `.jsonValues()`
1241
+ * record is encountered, the next path segment is the entry key and everything
1242
+ * after it lives inside the entry's `*.val.json` (so it does not touch the
1243
+ * `.val.ts`). Returns `{ kind: "normal" }` when the op never enters a
1244
+ * `.jsonValues()` record.
1245
+ */
1246
+ function classifyJsonValuesOp(schema, opPath) {
1247
+ let current = schema;
1248
+ const recordPath = [];
1249
+ for (let i = 0; i < opPath.length; i++) {
1250
+ if (!current) {
1251
+ return {
1252
+ kind: "normal"
1253
+ };
1254
+ }
1255
+ if (current.type === "record" && current.jsonValues) {
1256
+ return {
1257
+ kind: "entry",
1258
+ recordPath: recordPath.slice(),
1259
+ entryKey: opPath[i],
1260
+ subPath: opPath.slice(i + 1)
1261
+ };
1262
+ }
1263
+ const seg = opPath[i];
1264
+ current = descend(current, seg);
1265
+ recordPath.push(seg);
1266
+ }
1267
+ return {
1268
+ kind: "normal"
1269
+ };
1270
+ }
1271
+ function descend(schema, key) {
1272
+ switch (schema.type) {
1273
+ case "object":
1274
+ return schema.items[key];
1275
+ case "record":
1276
+ return schema.item;
1277
+ case "array":
1278
+ return schema.item;
1279
+ default:
1280
+ // Unions / primitives / leaf schemas: we cannot (or need not) descend
1281
+ // further to find a jsonValues record. Anything below is a normal
1282
+ // `.val.ts` edit.
1283
+ return undefined;
1284
+ }
1285
+ }
1286
+
1287
+ /**
1288
+ * Finds every `.jsonValues()` record in a module's schema that is NOT the
1289
+ * module's root, returning the path to each within the module source.
1290
+ *
1291
+ * `.jsonValues()` is only supported on a module's ROOT record/router: the
1292
+ * `/json` endpoint keys entries by a single string, the Studio substitutes
1293
+ * loaded content at the top level of the module source, and
1294
+ * `validateJsonValuesEntries` only visits a root record. A nested one would
1295
+ * silently skip content validation and hang the Studio on a 404, so we reject
1296
+ * it up front instead (see {@link ValOps.initSources}).
1297
+ */
1298
+ function findNestedJsonValuesRecords(schema, path = []) {
1299
+ const found = [];
1300
+ const rec = (current, currentPath) => {
1301
+ if (current.type === "record" && current.jsonValues && currentPath.length > 0) {
1302
+ found.push(currentPath);
1303
+ // Do not descend: everything below lives in the entry's `*.val.json`.
1304
+ return;
1305
+ }
1306
+ switch (current.type) {
1307
+ case "object":
1308
+ for (const key of Object.keys(current.items)) {
1309
+ rec(current.items[key], currentPath.concat(key));
1310
+ }
1311
+ return;
1312
+ case "record":
1313
+ rec(current.item, currentPath.concat("*"));
1314
+ return;
1315
+ case "array":
1316
+ rec(current.item, currentPath.concat("*"));
1317
+ return;
1318
+ case "union":
1319
+ for (let i = 0; i < current.items.length; i++) {
1320
+ rec(current.items[i], currentPath.concat(`union[${i}]`));
1321
+ }
1322
+ return;
1323
+ default:
1324
+ return;
1325
+ }
1326
+ };
1327
+ rec(schema, path);
1328
+ return found;
1329
+ }
1330
+
1331
+ /**
1332
+ * The `.val.ts` suffix a module file path ends with. Stripping it yields the
1333
+ * folder that a new entry's `*.val.json` files are nested under.
1334
+ */
1335
+ const VAL_TS_SUFFIX = ".val.ts";
1336
+
1337
+ /**
1338
+ * Computes the `*.val.json` file path (relative to rootDir) and the `import(...)`
1339
+ * path (relative to the module's directory) for a NEW `.jsonValues()` entry,
1340
+ * following the locked filename convention: the file mirrors the entry key under
1341
+ * a folder named after the `.val.ts` (its `.val.ts` suffix becomes the folder).
1342
+ *
1343
+ * For module `/app/support/[slug]/page.val.ts` and key `/support/faq`:
1344
+ * - jsonPath: `/app/support/[slug]/page/support/faq.val.json`
1345
+ * - importPath: `./page/support/faq.val.json`
1346
+ */
1347
+ function getNewJsonEntryPaths(moduleFilePath, entryKey) {
1348
+ const base = moduleFilePath.endsWith(VAL_TS_SUFFIX) ? moduleFilePath.slice(0, -VAL_TS_SUFFIX.length) : moduleFilePath;
1349
+ const keyRel = entryKey.replace(/^\//, "");
1350
+ const invalid = reason => fp.result.err({
1351
+ message: `Invalid .jsonValues() entry key '${entryKey}' in ${moduleFilePath}: ${reason}`,
1352
+ filePath: moduleFilePath
1353
+ });
1354
+ // An entry key is CLIENT-SUPPLIED — it arrives as a record key in a patch op —
1355
+ // and this path is what the commit writes to disk. Left unchecked, a key with
1356
+ // `..` segments (or a backslash, which is a separator once the path is handed
1357
+ // to node's `path.join` on Windows) puts that write anywhere in the project or
1358
+ // outside it entirely, and a `move` additionally deletes the source path.
1359
+ if (keyRel === "") {
1360
+ return invalid("it is empty");
1361
+ }
1362
+ if (keyRel.includes("\\") || keyRel.includes("\0")) {
1363
+ return invalid("it contains a backslash or a NUL byte");
1364
+ }
1365
+ const jsonPath = path__namespace.posix.normalize(`${base}/${keyRel}.val.json`);
1366
+ if (!jsonPath.startsWith(`${base}/`)) {
1367
+ return invalid(`it resolves outside '${base}/'`);
1368
+ }
1369
+ const moduleDir = path__namespace.posix.dirname(moduleFilePath);
1370
+ let importPath = path__namespace.posix.relative(moduleDir, jsonPath);
1371
+ if (!importPath.startsWith(".")) {
1372
+ importPath = `./${importPath}`;
1373
+ }
1374
+ return fp.result.ok({
1375
+ jsonPath,
1376
+ importPath
1377
+ });
1378
+ }
1379
+
1380
+ /**
1381
+ * Rebases a patch op that targets a `.jsonValues()` entry's content so its paths
1382
+ * are relative to the entry's `*.val.json` root (drops the record + entry-key
1383
+ * prefix). Used to replay the op against the backing JSON file.
1384
+ */
1385
+ function rebaseContentOp(op, prefixLen) {
1386
+ const path = op.path.slice(prefixLen);
1387
+ switch (op.op) {
1388
+ case "add":
1389
+ case "replace":
1390
+ case "test":
1391
+ return fp.result.ok({
1392
+ ...op,
1393
+ path
1394
+ });
1395
+ case "remove":
1396
+ {
1397
+ if (!fp.array.isNonEmpty(path)) {
1398
+ return fp.result.err(new patch.PatchError("Cannot remove the root of a jsonValues entry"));
1399
+ }
1400
+ return fp.result.ok({
1401
+ ...op,
1402
+ path
1403
+ });
1404
+ }
1405
+ case "move":
1406
+ {
1407
+ const from = op.from.slice(prefixLen);
1408
+ if (!fp.array.isNonEmpty(from)) {
1409
+ return fp.result.err(new patch.PatchError("Cannot move from the root of a jsonValues entry"));
1410
+ }
1411
+ return fp.result.ok({
1412
+ ...op,
1413
+ path,
1414
+ from
1415
+ });
1416
+ }
1417
+ case "copy":
1418
+ return fp.result.ok({
1419
+ ...op,
1420
+ path,
1421
+ from: op.from.slice(prefixLen)
1422
+ });
1423
+ case "file":
1424
+ return fp.result.err(new patch.PatchError("Cannot apply a file op to a jsonValues entry"));
1425
+ }
1426
+ }
1427
+
1428
+ /** The outcome of replaying pending patches onto one `.jsonValues()` entry. */
1429
+
1430
+ /**
1431
+ * Replays the ops of `patches` that target ONE `.jsonValues()` entry onto its
1432
+ * committed content, yielding the entry's draft content.
1433
+ *
1434
+ * This is the read-side counterpart to the commit flow in `ValOps.prepare`:
1435
+ * both route ops with {@link classifyJsonValuesOp} and replay content sub-ops
1436
+ * with {@link rebaseContentOp}, but this one produces a value instead of files
1437
+ * and never touches the `.val.ts`.
1438
+ *
1439
+ * Root-only, like the rest of the `.jsonValues()` machinery: ops targeting a
1440
+ * nested record are ignored (nested `.jsonValues()` is rejected at startup).
1441
+ */
1442
+ function applyJsonValuesEntryPatches(args) {
1443
+ const {
1444
+ serializedSchema,
1445
+ entryKey,
1446
+ baseContent,
1447
+ patches
1448
+ } = args;
1449
+ let content = baseContent;
1450
+ let deleted = false;
1451
+ const appliedPatchIds = [];
1452
+ for (const {
1453
+ patchId,
1454
+ patch: patch$1
1455
+ } of patches) {
1456
+ let touched = false;
1457
+ for (const op of patch$1) {
1458
+ if (op.op === "file") {
1459
+ continue;
1460
+ }
1461
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1462
+ kind: "normal"
1463
+ };
1464
+ if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1465
+ continue;
1466
+ }
1467
+ touched = true;
1468
+ if (cls.subPath.length === 0) {
1469
+ if (op.op === "add" || op.op === "replace") {
1470
+ content = op.value;
1471
+ deleted = false;
1472
+ } else if (op.op === "remove") {
1473
+ content = undefined;
1474
+ deleted = true;
1475
+ } else if (op.op === "test") {
1476
+ // An assertion, not a mutation: the content is unchanged either way, and
1477
+ // the commit path is where a failing `test` is reported. Falling through
1478
+ // to the move/copy error below turned a no-op into a permanent load
1479
+ // failure for the whole entry.
1480
+ continue;
1481
+ } else {
1482
+ // move/copy INTO this key: the content comes from the source entry,
1483
+ // which the caller must resolve (it is a different `*.val.json`).
1484
+ return {
1485
+ kind: "error",
1486
+ message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
1487
+ patchId
1488
+ };
1489
+ }
1490
+ continue;
1491
+ }
1492
+ if (content === undefined) {
1493
+ return {
1494
+ kind: "error",
1495
+ message: `Cannot edit jsonValues entry '${entryKey}': it does not exist`,
1496
+ patchId
1497
+ };
1498
+ }
1499
+ const rebased = rebaseContentOp(op, cls.recordPath.length + 1);
1500
+ if (fp.result.isErr(rebased)) {
1501
+ return {
1502
+ kind: "error",
1503
+ message: rebased.error.message,
1504
+ patchId
1505
+ };
1506
+ }
1507
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$1, [rebased.value]);
1508
+ if (fp.result.isErr(applied)) {
1509
+ return {
1510
+ kind: "error",
1511
+ message: applied.error.message,
1512
+ patchId
1513
+ };
1514
+ }
1515
+ content = applied.value;
1516
+ }
1517
+ if (touched) {
1518
+ appliedPatchIds.push(patchId);
1519
+ }
1520
+ }
1521
+ if (deleted || content === undefined) {
1522
+ return {
1523
+ kind: "deleted",
1524
+ appliedPatchIds
1525
+ };
1526
+ }
1527
+ return {
1528
+ kind: "content",
1529
+ content,
1530
+ appliedPatchIds
1531
+ };
1532
+ }
1533
+
1534
+ /**
1535
+ * Resolves an EXISTING entry's `*.val.json` path (relative to rootDir) from the
1536
+ * `import(...)` path recorded in the `.val.ts` thunk (from
1537
+ * {@link analyzeJsonValuesEntries}). Existing files may have been hand-placed,
1538
+ * so the import path is authoritative (hybrid authoring).
1539
+ */
1540
+ function resolveExistingJsonPath(moduleFilePath, importPath) {
1541
+ const moduleDir = path__namespace.posix.dirname(moduleFilePath);
1542
+ return path__namespace.posix.join(moduleDir, importPath);
1543
+ }
1544
+
1545
+ /**
1546
+ * Validates the content of every `.jsonValues()` entry in a module by loading
1547
+ * each backing `*.val.json` (via its lazy import thunk) and checking it against
1548
+ * the record's item schema.
1549
+ *
1550
+ * The record-level `executeValidate` only asserts the marker shape — content
1551
+ * validation is deferred (the content isn't inlined). This performs that
1552
+ * deferred deep validation server-side. Every loadable entry is validated;
1553
+ * validation is allowed to be slower at scale, so a sha-keyed skip-cache is left
1554
+ * as a later optimization.
1555
+ *
1556
+ * Entries without a runtime import thunk (transport markers / draft entries
1557
+ * whose content lives in a patch) are skipped here — their content is validated
1558
+ * where it is loaded (the single-entry fetch path).
1559
+ *
1560
+ * ROOT-ONLY BY CONTRACT: only a module whose root schema is a `.jsonValues()`
1561
+ * record is visited. Nested `.jsonValues()` records are rejected up front as
1562
+ * module errors (see `findNestedJsonValuesRecords`), so they can never reach
1563
+ * here — if that guard is ever relaxed, this must become a recursive visitor or
1564
+ * nested entries silently get no content validation at all.
1565
+ */
1566
+ async function validateJsonValuesEntries(schema, source, modulePath) {
1567
+ const out = {};
1568
+ if (!(schema instanceof core.RecordSchema)) {
1569
+ return out;
1570
+ }
1571
+ if (!schema["executeSerialize"]().jsonValues) {
1572
+ return out;
1573
+ }
1574
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
1575
+ return out;
1576
+ }
1577
+ for (const [key, marker] of Object.entries(source)) {
1578
+ const entryPath = core.Internal.createValPathOfItem(modulePath, key);
1579
+ if (!entryPath) {
1580
+ continue;
1581
+ }
1582
+ if (!core.Internal.isJson(marker)) {
1583
+ // A value written INLINE in the `.val.ts` instead of `c.json(() => import(...))`.
1584
+ //
1585
+ // The record-level validation checks it against the item schema (so bad
1586
+ // content is still reported), but it cannot report the inlining itself:
1587
+ // the Studio substitutes loaded entry content in place of the marker
1588
+ // before validating, so from `executeValidate`'s perspective a loaded
1589
+ // entry and a hand-authored one look identical. Here the source is the
1590
+ // module as it is on disk, where a non-marker can only be inlined.
1591
+ out[entryPath] = [{
1592
+ 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.`,
1593
+ value: marker,
1594
+ fixes: ["jsonValues:extract-entry"]
1595
+ }];
1596
+ continue;
1597
+ }
1598
+ const thunk = core.Internal.getJsonImport(marker);
1599
+ if (!thunk) {
1600
+ continue;
1601
+ }
1602
+ let content;
1603
+ try {
1604
+ content = (await thunk()).default;
1605
+ } catch (err) {
1606
+ out[entryPath] = [{
1607
+ message: `Could not load JSON entry '${key}': ${err instanceof Error ? err.message : String(err)}`
1608
+ }];
1609
+ continue;
1610
+ }
1611
+ const entryErrors = schema.validateJsonEntryContent(entryPath, content);
1612
+ if (entryErrors) {
1613
+ for (const [p, errs] of Object.entries(entryErrors)) {
1614
+ out[p] = errs;
1615
+ }
1616
+ }
1617
+ }
1618
+ return out;
1619
+ }
1620
+
1144
1621
  async function createService(projectRoot, host = {
1145
1622
  ...ts__default["default"].sys,
1146
1623
  writeFile: (fileName, data, encoding) => {
@@ -1208,10 +1685,48 @@ class Service {
1208
1685
  }
1209
1686
  };
1210
1687
  }
1211
- const validation = opts.validate ? schema["executeValidate"](moduleFilePath, source) : false;
1688
+ let validation = opts.validate ? schema["executeValidate"](moduleFilePath, source) : false;
1689
+
1690
+ // `.jsonValues()` needs two checks that `executeValidate` structurally
1691
+ // cannot do, and this is the ONLY place the Service-based callers (the CLI's
1692
+ // `val validate`, chiefly) can get them. `ValOps` — the Studio's path — has
1693
+ // its own copies; without these, `val validate` reports a module with broken
1694
+ // entry content, or an unsupported nested `.jsonValues()`, as VALID, and CI
1695
+ // gates on that.
1696
+ let jsonValuesModuleError;
1697
+ if (opts.validate) {
1698
+ const nested = findNestedJsonValuesRecords(serializedSchema);
1699
+ if (nested.length > 0) {
1700
+ // Root-only is a hard contract (see findNestedJsonValuesRecords): a
1701
+ // nested one would silently get NO content validation, which is exactly
1702
+ // the failure this check exists to prevent.
1703
+ 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.`;
1704
+ } else {
1705
+ // Loads every entry's backing `*.val.json` through its thunk. That is the
1706
+ // accepted cost of having no revalidation token (locked decision #3):
1707
+ // validation is allowed to be slower at scale, but it is not allowed to
1708
+ // silently skip content.
1709
+ const entryErrors = await validateJsonValuesEntries(schema, source, moduleFilePath);
1710
+ if (Object.keys(entryErrors).length > 0) {
1711
+ // Concatenate per path, never overwrite: an entry written inline is
1712
+ // reported BOTH by the record-level validation (which checks the
1713
+ // inline value against the item schema) and here (which reports the
1714
+ // inlining itself). A spread would drop whichever came first, hiding
1715
+ // a real content error behind the inlining error or vice versa.
1716
+ const merged = {
1717
+ ...(validation || {})
1718
+ };
1719
+ for (const [entryPathS, errs] of Object.entries(entryErrors)) {
1720
+ const entryPath = entryPathS;
1721
+ merged[entryPath] = (merged[entryPath] || []).concat(errs);
1722
+ }
1723
+ validation = merged;
1724
+ }
1725
+ }
1726
+ }
1212
1727
  const resolved = core.Internal.resolvePath(modulePath, source, serializedSchema);
1213
1728
  const sourcePath = resolved.path ? [moduleFilePath, resolved.path].join(".") : moduleFilePath;
1214
- if (!validation && !moduleError) {
1729
+ if (!validation && !moduleError && !jsonValuesModuleError) {
1215
1730
  return {
1216
1731
  path: sourcePath,
1217
1732
  source: resolved.source,
@@ -1219,15 +1734,24 @@ class Service {
1219
1734
  errors: false
1220
1735
  };
1221
1736
  }
1737
+ const fatal = [];
1738
+ if (moduleError) {
1739
+ fatal.push({
1740
+ message: moduleError.message
1741
+ });
1742
+ }
1743
+ if (jsonValuesModuleError) {
1744
+ fatal.push({
1745
+ message: jsonValuesModuleError
1746
+ });
1747
+ }
1222
1748
  return {
1223
1749
  path: sourcePath,
1224
1750
  source: resolved.source,
1225
1751
  schema: resolved.schema,
1226
1752
  errors: {
1227
1753
  validation: validation || undefined,
1228
- fatal: moduleError ? [{
1229
- message: moduleError.message
1230
- }] : undefined
1754
+ fatal: fatal.length > 0 ? fatal : undefined
1231
1755
  }
1232
1756
  };
1233
1757
  }
@@ -1297,6 +1821,92 @@ function encodeJwt(payload, sessionKey) {
1297
1821
  return `${jwtHeaderBase64}.${payloadBase64}.${crypto__default["default"].createHmac("sha256", sessionKey).update(`${jwtHeaderBase64}.${payloadBase64}`).digest("base64")}`;
1298
1822
  }
1299
1823
 
1824
+ /**
1825
+ * Given the `source` object-literal expression of a `.jsonValues()` record /
1826
+ * router (the 3rd argument to `c.define`, via {@link analyzeValModule}),
1827
+ * extracts each entry's `c.json(() => import("..."))` import path, keyed by
1828
+ * entry key. Entries whose value is not a `c.json(...)` call are skipped.
1829
+ */
1830
+ function analyzeJsonValuesEntries(source) {
1831
+ const entries = new Map();
1832
+ if (!ts__default["default"].isObjectLiteralExpression(source)) {
1833
+ return entries;
1834
+ }
1835
+ for (const prop of source.properties) {
1836
+ if (!ts__default["default"].isPropertyAssignment(prop)) {
1837
+ continue;
1838
+ }
1839
+ const key = getPropertyKey(prop.name);
1840
+ if (key === null) {
1841
+ continue;
1842
+ }
1843
+ const entry = analyzeCJsonCall(prop.initializer);
1844
+ if (entry) {
1845
+ entries.set(key, entry);
1846
+ }
1847
+ }
1848
+ return entries;
1849
+ }
1850
+ function getPropertyKey(name) {
1851
+ if (ts__default["default"].isStringLiteralLike(name)) {
1852
+ return name.text;
1853
+ }
1854
+ if (ts__default["default"].isIdentifier(name)) {
1855
+ return name.text;
1856
+ }
1857
+ return null;
1858
+ }
1859
+ function analyzeCJsonCall(node) {
1860
+ // c.json(() => import("path"))
1861
+ if (!ts__default["default"].isCallExpression(node) || !isCJson(node.expression)) {
1862
+ return null;
1863
+ }
1864
+ const [thunk] = node.arguments;
1865
+ if (!thunk) {
1866
+ return null;
1867
+ }
1868
+ const importPath = getImportPathFromThunk(thunk);
1869
+ if (importPath === null) {
1870
+ return null;
1871
+ }
1872
+ return {
1873
+ importPath
1874
+ };
1875
+ }
1876
+ function isCJson(expr) {
1877
+ // c.json
1878
+ return ts__default["default"].isPropertyAccessExpression(expr) && ts__default["default"].isIdentifier(expr.expression) && expr.expression.text === "c" && ts__default["default"].isIdentifier(expr.name) && expr.name.text === "json";
1879
+ }
1880
+ function getImportPathFromThunk(thunk) {
1881
+ // () => import("path") (concise body) OR () => { return import("path"); }
1882
+ if (!ts__default["default"].isArrowFunction(thunk)) {
1883
+ return null;
1884
+ }
1885
+ let importCall;
1886
+ if (ts__default["default"].isCallExpression(thunk.body)) {
1887
+ importCall = thunk.body;
1888
+ } else if (ts__default["default"].isBlock(thunk.body)) {
1889
+ for (const stmt of thunk.body.statements) {
1890
+ if (ts__default["default"].isReturnStatement(stmt) && stmt.expression && ts__default["default"].isCallExpression(stmt.expression)) {
1891
+ importCall = stmt.expression;
1892
+ break;
1893
+ }
1894
+ }
1895
+ }
1896
+ if (!importCall || !ts__default["default"].isCallExpression(importCall)) {
1897
+ return null;
1898
+ }
1899
+ const isImport = importCall.expression.kind === ts__default["default"].SyntaxKind.ImportKeyword || ts__default["default"].isIdentifier(importCall.expression) && importCall.expression.text === "import";
1900
+ if (!isImport) {
1901
+ return null;
1902
+ }
1903
+ const arg = importCall.arguments[0];
1904
+ if (arg && ts__default["default"].isStringLiteralLike(arg)) {
1905
+ return arg.text;
1906
+ }
1907
+ return null;
1908
+ }
1909
+
1300
1910
  /* eslint-disable @typescript-eslint/no-unused-vars */
1301
1911
  const jsonOps = new patch.JSONOps();
1302
1912
  const tsOps = new TSOps(document => {
@@ -1304,6 +1914,38 @@ const tsOps = new TSOps(document => {
1304
1914
  source
1305
1915
  }) => source));
1306
1916
  });
1917
+
1918
+ /**
1919
+ * `.jsonValues()` is only supported on a module's ROOT record/router. A nested
1920
+ * one is broken end to end (the `/json` endpoint keys entries by a single
1921
+ * string, the Studio substitutes at the top level, and content validation
1922
+ * silently skips it), so reject it up front as a module error — `/sources/~`
1923
+ * then fails with "Val is not correctly setup" naming the module.
1924
+ */
1925
+ function findNestedJsonValuesModuleErrors(schemas) {
1926
+ const errors = [];
1927
+ for (const moduleFilePathS of Object.keys(schemas)) {
1928
+ const moduleFilePath = moduleFilePathS;
1929
+ const schema = schemas[moduleFilePath];
1930
+ if (!schema) {
1931
+ continue;
1932
+ }
1933
+ let serialized;
1934
+ try {
1935
+ serialized = schema["executeSerialize"]();
1936
+ } catch {
1937
+ // Serialization errors are reported elsewhere (e.g. by extractValModules).
1938
+ continue;
1939
+ }
1940
+ for (const nestedPath of findNestedJsonValuesRecords(serialized)) {
1941
+ errors.push({
1942
+ path: moduleFilePath,
1943
+ message: `Nested .jsonValues() records are not supported: '${nestedPath.join(".")}' in ${moduleFilePath}. Use .jsonValues() only on a module's root record/router.`
1944
+ });
1945
+ }
1946
+ }
1947
+ return errors;
1948
+ }
1307
1949
  // #region ValOps
1308
1950
  class ValOps {
1309
1951
  /** Sources from val modules, immutable (without patches or anything) */
@@ -1345,13 +1987,14 @@ class ValOps {
1345
1987
  async initSources() {
1346
1988
  if (this.baseSha === null || this.sourcesSha === null || this.configSha === null || this.schemaSha === null || this.sources === null || this.schemas === null || this.modulesErrors === null) {
1347
1989
  const extracted = await core.extractValModules(this.valModules);
1990
+ const moduleErrors = extracted.moduleErrors.concat(findNestedJsonValuesModuleErrors(extracted.schemas));
1348
1991
  this.sources = extracted.sources;
1349
1992
  this.schemas = extracted.schemas;
1350
1993
  this.baseSha = extracted.baseSha;
1351
1994
  this.schemaSha = extracted.schemaSha;
1352
1995
  this.sourcesSha = extracted.sourcesSha;
1353
1996
  this.configSha = extracted.configSha;
1354
- this.modulesErrors = extracted.moduleErrors;
1997
+ this.modulesErrors = moduleErrors;
1355
1998
  return {
1356
1999
  baseSha: this.baseSha,
1357
2000
  schemaSha: this.schemaSha,
@@ -1359,7 +2002,7 @@ class ValOps {
1359
2002
  configSha: this.configSha,
1360
2003
  sources: extracted.sources,
1361
2004
  schemas: extracted.schemas,
1362
- moduleErrors: extracted.moduleErrors
2005
+ moduleErrors
1363
2006
  };
1364
2007
  }
1365
2008
  return {
@@ -1382,6 +2025,221 @@ class ValOps {
1382
2025
  async getBaseSources() {
1383
2026
  return this.initSources().then(result => result.sources);
1384
2027
  }
2028
+
2029
+ /**
2030
+ * Resolves the content of ONE `.jsonValues()` entry.
2031
+ *
2032
+ * The committed content comes from the entry's import thunk on the base
2033
+ * source (so it works in both fs and http mode, with no extra I/O). With
2034
+ * `applyPatches` (the default) any pending patches for that entry are then
2035
+ * replayed on top, which is what makes draft edits visible to the runtime.
2036
+ *
2037
+ * Callers that apply patches themselves (the Studio, which owns
2038
+ * in-flight client patches the server has not seen) must pass
2039
+ * `applyPatches: false` or the same edits would be applied twice.
2040
+ */
2041
+ async getJsonEntry(moduleFilePath, entryKey, opts) {
2042
+ const res = await this.getJsonEntries(moduleFilePath, {
2043
+ keys: [entryKey]
2044
+ }, opts);
2045
+ if (res.status !== "success") {
2046
+ return res;
2047
+ }
2048
+ const entry = res.entries[0];
2049
+ if (entry !== undefined) {
2050
+ return {
2051
+ status: "success",
2052
+ content: entry.content
2053
+ };
2054
+ }
2055
+ const error = res.errors[0];
2056
+ if (error !== undefined) {
2057
+ return {
2058
+ status: "error",
2059
+ message: error.message
2060
+ };
2061
+ }
2062
+ return {
2063
+ status: "not-found",
2064
+ message: `Entry not found: ${entryKey} in ${moduleFilePath}`
2065
+ };
2066
+ }
2067
+
2068
+ /**
2069
+ * Resolves the content of MANY `.jsonValues()` entries in one pass.
2070
+ *
2071
+ * This is the single implementation; {@link getJsonEntry} is a one-key wrapper.
2072
+ * Batching matters because the expensive parts — `initSources()` and
2073
+ * `fetchPatches()` — are hoisted OUT of the per-entry loop: resolving 500
2074
+ * entries one-by-one would otherwise mean 500 patch fetches.
2075
+ *
2076
+ * Per-entry problems stay per-entry (`missing` / `errors`) so one corrupt
2077
+ * `*.val.json` cannot fail a whole batch. Only a missing or non-record MODULE
2078
+ * is a whole-request `not-found`.
2079
+ *
2080
+ * `selector` is either explicit `keys` or an `offset`/`limit` window over every
2081
+ * key of the record, in module key order. The window form requires
2082
+ * `applyPatches: false`: enumerating from the base source would silently omit
2083
+ * draft-added keys, and a silently-short key list is exactly the class of bug
2084
+ * this endpoint exists to avoid.
2085
+ */
2086
+ async getJsonEntries(moduleFilePath, selector, opts) {
2087
+ const applyPatches = (opts === null || opts === void 0 ? void 0 : opts.applyPatches) !== false;
2088
+ const isWindow = !("keys" in selector);
2089
+ if (isWindow && applyPatches) {
2090
+ return {
2091
+ status: "error",
2092
+ 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."
2093
+ };
2094
+ }
2095
+ const {
2096
+ sources,
2097
+ schemas
2098
+ } = await this.initSources();
2099
+ const moduleSource = sources[moduleFilePath];
2100
+ if (moduleSource === undefined || moduleSource === null) {
2101
+ return {
2102
+ status: "not-found",
2103
+ message: `Module not found: ${moduleFilePath}`
2104
+ };
2105
+ }
2106
+ if (typeof moduleSource !== "object" || Array.isArray(moduleSource)) {
2107
+ return {
2108
+ status: "not-found",
2109
+ message: `Module is not a record: ${moduleFilePath}`
2110
+ };
2111
+ }
2112
+ const record = moduleSource;
2113
+ const allKeys = Object.keys(record);
2114
+ const requestedKeys = isWindow ? allKeys.slice(selector.offset, selector.offset + selector.limit) : selector.keys;
2115
+
2116
+ // Fetched once for the whole batch, not per entry.
2117
+ let modulePatches = [];
2118
+ let serializedSchema = undefined;
2119
+ if (applyPatches) {
2120
+ const patchOps = await this.fetchPatches({
2121
+ excludePatchOps: false
2122
+ });
2123
+ if (patchOps.error) {
2124
+ return {
2125
+ status: "error",
2126
+ message: patchOps.error.message
2127
+ };
2128
+ }
2129
+ if (patchOps.errors && Object.keys(patchOps.errors).length > 0) {
2130
+ return {
2131
+ status: "error",
2132
+ message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
2133
+ };
2134
+ }
2135
+ modulePatches = patchOps.patches.filter(p => p.path === moduleFilePath && !p.appliedAt).map(p => ({
2136
+ patchId: p.patchId,
2137
+ patch: p.patch
2138
+ }));
2139
+ try {
2140
+ var _schemas$moduleFilePa;
2141
+ serializedSchema = (_schemas$moduleFilePa = schemas[moduleFilePath]) === null || _schemas$moduleFilePa === void 0 ? void 0 : _schemas$moduleFilePa["executeSerialize"]();
2142
+ } catch {
2143
+ // Serialization errors are reported elsewhere; treat as "no schema".
2144
+ }
2145
+ }
2146
+ const entries = [];
2147
+ const missing = [];
2148
+ const errors = [];
2149
+ const resolved = await Promise.all(requestedKeys.map(async entryKey => {
2150
+ const marker = record[entryKey];
2151
+ if (marker !== undefined && !core.Internal.isJson(marker)) {
2152
+ // Not a jsonValues entry — return the inlined value as-is (defensive).
2153
+ // `inline` skips patch replay: entry patches are expressed against a
2154
+ // jsonValues entry, and this value is part of the module source proper.
2155
+ return {
2156
+ entryKey,
2157
+ baseContent: marker,
2158
+ inline: true
2159
+ };
2160
+ }
2161
+ if (marker === undefined) {
2162
+ return {
2163
+ entryKey,
2164
+ baseContent: undefined
2165
+ };
2166
+ }
2167
+ const thunk = core.Internal.getJsonImport(marker);
2168
+ if (!thunk) {
2169
+ return {
2170
+ entryKey,
2171
+ baseContent: null
2172
+ };
2173
+ }
2174
+ try {
2175
+ return {
2176
+ entryKey,
2177
+ baseContent: (await thunk()).default ?? null
2178
+ };
2179
+ } catch (e) {
2180
+ return {
2181
+ entryKey,
2182
+ message: `Failed to load JSON entry '${entryKey}': ${e instanceof Error ? e.message : String(e)}`
2183
+ };
2184
+ }
2185
+ }));
2186
+ for (const result of resolved) {
2187
+ const {
2188
+ entryKey
2189
+ } = result;
2190
+ if ("message" in result) {
2191
+ errors.push({
2192
+ key: entryKey,
2193
+ message: result.message
2194
+ });
2195
+ continue;
2196
+ }
2197
+ const {
2198
+ baseContent
2199
+ } = result;
2200
+ if (!applyPatches || "inline" in result) {
2201
+ if (baseContent === undefined) {
2202
+ missing.push(entryKey);
2203
+ } else {
2204
+ entries.push({
2205
+ key: entryKey,
2206
+ content: baseContent
2207
+ });
2208
+ }
2209
+ continue;
2210
+ }
2211
+ const res = applyJsonValuesEntryPatches({
2212
+ serializedSchema,
2213
+ entryKey,
2214
+ baseContent,
2215
+ patches: modulePatches
2216
+ });
2217
+ if (res.kind === "error") {
2218
+ errors.push({
2219
+ key: entryKey,
2220
+ message: res.message
2221
+ });
2222
+ } else if (res.kind === "deleted") {
2223
+ missing.push(entryKey);
2224
+ } else {
2225
+ entries.push({
2226
+ key: entryKey,
2227
+ content: res.content
2228
+ });
2229
+ }
2230
+ }
2231
+ return {
2232
+ status: "success",
2233
+ entries,
2234
+ missing,
2235
+ errors,
2236
+ total: allKeys.length,
2237
+ ...(isWindow ? {
2238
+ offset: selector.offset,
2239
+ limit: selector.limit
2240
+ } : {})
2241
+ };
2242
+ }
1385
2243
  async getSchemas() {
1386
2244
  return this.initSources().then(result => result.schemas);
1387
2245
  }
@@ -1435,13 +2293,20 @@ class ValOps {
1435
2293
  // ops used to be applied twice. Idempotent for "replace", destructive for
1436
2294
  // array add/remove/move.
1437
2295
  if (hasSourceFileOps) {
2296
+ var _patchesByModule$path;
1438
2297
  const path = patch.path;
1439
2298
  if (!patchesByModule[path]) {
1440
2299
  patchesByModule[path] = [];
1441
2300
  }
1442
- patchesByModule[path].push({
1443
- patchId: patch.patchId
1444
- });
2301
+ // At most ONE entry per (module, patch): consumers treat each entry as
2302
+ // "apply this whole patch". Pushing per-op made a patch with N non-file
2303
+ // ops be applied N times — idempotent for `replace`, but it duplicates
2304
+ // `add`s and corrupts non-idempotent ops like `move`.
2305
+ if (((_patchesByModule$path = patchesByModule[path][patchesByModule[path].length - 1]) === null || _patchesByModule$path === void 0 ? void 0 : _patchesByModule$path.patchId) !== patch.patchId) {
2306
+ patchesByModule[path].push({
2307
+ patchId: patch.patchId
2308
+ });
2309
+ }
1445
2310
  }
1446
2311
  }
1447
2312
  return {
@@ -1451,6 +2316,15 @@ class ValOps {
1451
2316
  }
1452
2317
 
1453
2318
  // #region getRenders
2319
+ /**
2320
+ * Reifies each module's render from its schema INSTANCE.
2321
+ *
2322
+ * Kept even though the Studio also computes renders client-side: `select` is a
2323
+ * user function that lives on the instance and is not part of the serialized
2324
+ * schema, so a host app that does not render `<ValModulesClient>` has no
2325
+ * instances in the browser and would otherwise get no renders at all. See
2326
+ * #470.
2327
+ */
1454
2328
  async getRenders(schemas, sources) {
1455
2329
  const renders = {};
1456
2330
  for (const [pathS, schema] of Object.entries(schemas)) {
@@ -1478,6 +2352,25 @@ class ValOps {
1478
2352
  } = await this.initSources();
1479
2353
  const patchedSources = {};
1480
2354
  const errors = {};
2355
+ // Serialized schemas, resolved lazily and only for modules that actually
2356
+ // have patches, so the common (non-jsonValues) case stays free.
2357
+ const {
2358
+ schemas
2359
+ } = await this.initSources();
2360
+ const serializedSchemaCache = new Map();
2361
+ const jsonValuesSchemaFor = path => {
2362
+ if (!serializedSchemaCache.has(path)) {
2363
+ let serialized = undefined;
2364
+ try {
2365
+ var _schemas$path;
2366
+ serialized = (_schemas$path = schemas[path]) === null || _schemas$path === void 0 ? void 0 : _schemas$path["executeSerialize"]();
2367
+ } catch {
2368
+ // Serialization errors are reported elsewhere; treat as "no schema".
2369
+ }
2370
+ serializedSchemaCache.set(path, serialized);
2371
+ }
2372
+ return serializedSchemaCache.get(path);
2373
+ };
1481
2374
  for (const patchData of analysis.patches) {
1482
2375
  const path = patchData.path;
1483
2376
  if (sources[path] === undefined) {
@@ -1506,6 +2399,13 @@ class ValOps {
1506
2399
  } else {
1507
2400
  const applicableOps = [];
1508
2401
  const fileFixOps = {};
2402
+ // `.jsonValues()` entry values are opaque `{_type:"json"}` markers in
2403
+ // the module source — their content lives in the entry's `*.val.json`.
2404
+ // Ops that reach INTO an entry therefore cannot be applied here (and
2405
+ // would fail with "Cannot replace object element which does not exist",
2406
+ // poisoning the rest of this module's patch chain). See the per-op
2407
+ // routing below.
2408
+ const serializedSchema = jsonValuesSchemaFor(path);
1509
2409
  for (const op of patchData.patch) {
1510
2410
  if (op.op === "file") {
1511
2411
  if (op.value !== null) {
@@ -1522,7 +2422,45 @@ class ValOps {
1522
2422
  // null value = delete: no patch_id to inject; the "remove" op in
1523
2423
  // the patch already removes the metadata entry from the source
1524
2424
  } else {
1525
- applicableOps.push(op);
2425
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
2426
+ kind: "normal"
2427
+ };
2428
+ if (cls.kind === "normal") {
2429
+ applicableOps.push(op);
2430
+ } else if (cls.subPath.length > 0) ; else if (op.op === "add" || op.op === "replace") {
2431
+ // Whole-entry add/replace: keep the record's KEY SET correct for
2432
+ // drafts by writing the marker rather than the content. Record
2433
+ // validation only asserts `isJson`, and
2434
+ // `validateJsonValuesEntries` skips thunkless markers by design.
2435
+ applicableOps.push({
2436
+ op: op.op,
2437
+ path: op.path,
2438
+ value: {
2439
+ [core.VAL_EXTENSION]: "json",
2440
+ patch_id: patchId
2441
+ }
2442
+ });
2443
+ } else if (op.op === "remove") {
2444
+ applicableOps.push(op);
2445
+ } else {
2446
+ // move/copy of a whole entry: the destination key must appear, and
2447
+ // for a move the source key must disappear. Both are key-set
2448
+ // changes we can express with markers.
2449
+ applicableOps.push({
2450
+ op: "add",
2451
+ path: op.path,
2452
+ value: {
2453
+ [core.VAL_EXTENSION]: "json",
2454
+ patch_id: patchId
2455
+ }
2456
+ });
2457
+ if (op.op === "move" && fp.array.isNonEmpty(op.from)) {
2458
+ applicableOps.push({
2459
+ op: "remove",
2460
+ path: op.from
2461
+ });
2462
+ }
2463
+ }
1526
2464
  }
1527
2465
  }
1528
2466
  const patchRes = patch.applyPatch(patch.deepClone(patchedSources[path]),
@@ -1629,6 +2567,21 @@ class ValOps {
1629
2567
  continue;
1630
2568
  }
1631
2569
  const res = schema["executeValidate"](path, source);
2570
+ // For `.jsonValues()` records, executeValidate only checks the entry
2571
+ // markers; load + validate each entry's backing `*.val.json` content here.
2572
+ const jsonValuesErrors = await validateJsonValuesEntries(schema, source, path);
2573
+ for (const [sourcePathS, entryErrors] of Object.entries(jsonValuesErrors)) {
2574
+ const sourcePath = sourcePathS;
2575
+ if (!errors[path]) {
2576
+ errors[path] = {
2577
+ validations: {}
2578
+ };
2579
+ }
2580
+ if (!errors[path].validations[sourcePath]) {
2581
+ errors[path].validations[sourcePath] = [];
2582
+ }
2583
+ errors[path].validations[sourcePath].push(...entryErrors);
2584
+ }
1632
2585
  if (res === false) {
1633
2586
  continue;
1634
2587
  }
@@ -1871,6 +2824,10 @@ class ValOps {
1871
2824
  const previousSourceFiles = {};
1872
2825
  const partiallyPatchedSourceFiles = {};
1873
2826
  const unappliablePatches = {};
2827
+
2828
+ // Serialized schemas are needed to route ops that target `.jsonValues()`
2829
+ // entries (their content lives in `*.val.json`, not the `.val.ts`).
2830
+ const schemas = await this.getSchemas();
1874
2831
  const applySourceFilePatches = async (path, patches) => {
1875
2832
  const sourceFileRes = await this.getSourceFile(path);
1876
2833
  const errors = [];
@@ -1881,13 +2838,99 @@ class ValOps {
1881
2838
  });
1882
2839
  return {
1883
2840
  path,
1884
- errors,
1885
- skippedPatches: patches.map(p => p.patchId)
1886
- };
1887
- }
1888
- const sourceFile = sourceFileRes.data;
1889
- previousSourceFiles[path] = sourceFile;
1890
- let tsSourceFile = ts__default["default"].createSourceFile("<val>", sourceFile, ts__default["default"].ScriptTarget.ES2015);
2841
+ errors,
2842
+ skippedPatches: patches.map(p => p.patchId)
2843
+ };
2844
+ }
2845
+ const sourceFile = sourceFileRes.data;
2846
+ previousSourceFiles[path] = sourceFile;
2847
+ const originalSourceFile = ts__default["default"].createSourceFile("<val>", sourceFile, ts__default["default"].ScriptTarget.ES2015);
2848
+ let tsSourceFile = originalSourceFile;
2849
+ let tsChanged = false;
2850
+ let serializedSchema = undefined;
2851
+ try {
2852
+ var _schemas$path2;
2853
+ serializedSchema = (_schemas$path2 = schemas[path]) === null || _schemas$path2 === void 0 ? void 0 : _schemas$path2["executeSerialize"]();
2854
+ } catch {
2855
+ // Serialization errors are reported elsewhere; treat as "no schema".
2856
+ // Without this guard one unserializable schema (e.g. a `keyOf` with an
2857
+ // empty selector) rejects the whole prepare(), so NO module's patches
2858
+ // can be saved — instead of this module's ops being routed as plain
2859
+ // `.val.ts` ops and any that cannot apply reported per-patch.
2860
+ }
2861
+
2862
+ // jsonValues entry content, keyed by `*.val.json` path. `null` = delete.
2863
+ const jsonEntryContents = new Map();
2864
+ // Entries added in this commit → their new `*.val.json` path, so later
2865
+ // content ops in the same commit resolve to the freshly-created file.
2866
+ const entryKeyToJsonPath = new Map();
2867
+
2868
+ // Lazily analyzed `c.json(() => import("..."))` entries of the ORIGINAL
2869
+ // `.val.ts` (import paths are authoritative for existing/hand-placed files).
2870
+ let analyzerEntries = null;
2871
+ const resolveEntryJsonPath = entryKey => {
2872
+ const added = entryKeyToJsonPath.get(entryKey);
2873
+ if (added !== undefined) {
2874
+ return fp.result.ok(added);
2875
+ }
2876
+ if (analyzerEntries === null) {
2877
+ const analysis = analyzeValModule(originalSourceFile);
2878
+ if (fp.result.isErr(analysis)) {
2879
+ return fp.result.err(analysis.error);
2880
+ }
2881
+ analyzerEntries = analyzeJsonValuesEntries(analysis.value.source);
2882
+ }
2883
+ const entry = analyzerEntries.get(entryKey);
2884
+ if (!entry) {
2885
+ return fp.result.err({
2886
+ message: `Could not find jsonValues entry '${entryKey}' in ${path}`,
2887
+ filePath: path
2888
+ });
2889
+ }
2890
+ return fp.result.ok(resolveExistingJsonPath(path, entry.importPath));
2891
+ };
2892
+ const loadEntryContent = async jsonPath => {
2893
+ const current = jsonEntryContents.get(jsonPath);
2894
+ if (current !== undefined) {
2895
+ if (current === null) {
2896
+ return fp.result.err({
2897
+ message: `Cannot edit a removed jsonValues entry: ${jsonPath}`,
2898
+ filePath: jsonPath
2899
+ });
2900
+ }
2901
+ return fp.result.ok(current);
2902
+ }
2903
+ const res = await this.getSourceFile(jsonPath);
2904
+ if (res.error) {
2905
+ return fp.result.err({
2906
+ message: res.error.message,
2907
+ filePath: jsonPath
2908
+ });
2909
+ }
2910
+ try {
2911
+ const parsed = JSON.parse(res.data);
2912
+ jsonEntryContents.set(jsonPath, parsed);
2913
+ return fp.result.ok(parsed);
2914
+ } catch (err) {
2915
+ return fp.result.err({
2916
+ message: `Could not parse jsonValues entry ${jsonPath}: ${err instanceof Error ? err.message : String(err)}`,
2917
+ filePath: jsonPath
2918
+ });
2919
+ }
2920
+ };
2921
+ const collectPatchError = (err, patchId, op) => {
2922
+ console.error("Could not patch", JSON.stringify({
2923
+ path,
2924
+ patchId,
2925
+ error: err,
2926
+ op
2927
+ }, null, 2));
2928
+ if (Array.isArray(err)) {
2929
+ errors.push(...err);
2930
+ } else {
2931
+ errors.push(err);
2932
+ }
2933
+ };
1891
2934
  const appliedPatches = [];
1892
2935
  const triedPatches = [];
1893
2936
  for (const {
@@ -1911,43 +2954,232 @@ class ValOps {
1911
2954
  }
1912
2955
  const patch$1 = patchData.patch;
1913
2956
  const sourceFileOps = patch$1.filter(op => op.op !== "file"); // file is not a valid source file op
1914
- const patchRes = patch.applyPatch(tsSourceFile, tsOps, sourceFileOps);
1915
- if (fp.result.isErr(patchRes)) {
1916
- if (Array.isArray(patchRes.error)) {
1917
- for (const error of patchRes.error) {
1918
- console.error("Could not patch", JSON.stringify({
1919
- path,
1920
- patchId,
1921
- error,
1922
- sourceFileOps
1923
- }, null, 2));
1924
- }
1925
- errors.push(...patchRes.error);
2957
+ let patchHadError = false;
2958
+ // Where this patch's errors start, so the unappliable-patch report below
2959
+ // can name what went wrong rather than just that something did.
2960
+ const errorsBefore = errors.length;
2961
+ for (const op of sourceFileOps) {
2962
+ const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
2963
+ kind: "normal"
2964
+ };
2965
+ // `move` / `copy` also READ from a path: classify that too, so an op
2966
+ // that moves a value out of (or into) a jsonValues entry cannot slip
2967
+ // through as a plain `.val.ts` op.
2968
+ const fromCls = serializedSchema && (op.op === "move" || op.op === "copy") ? classifyJsonValuesOp(serializedSchema, op.from) : {
2969
+ kind: "normal"
2970
+ };
2971
+ if (cls.kind === "normal" && fromCls.kind === "normal") {
2972
+ const patchRes = patch.applyPatch(tsSourceFile, tsOps, [op]);
2973
+ if (fp.result.isErr(patchRes)) {
2974
+ collectPatchError(patchRes.error, patchId, op);
2975
+ patchHadError = true;
2976
+ break;
2977
+ }
2978
+ tsSourceFile = patchRes.value;
2979
+ tsChanged = true;
2980
+ continue;
2981
+ }
2982
+ if (cls.kind === "normal") {
2983
+ errors.push({
2984
+ message: `Cannot '${op.op}' a value out of a jsonValues entry and into the module source`,
2985
+ filePath: path
2986
+ });
2987
+ patchHadError = true;
2988
+ break;
2989
+ }
2990
+ // Nested `.jsonValues()` records are not supported: only the read path
2991
+ // for a module's ROOT record/router is implemented end to end. This is
2992
+ // also rejected up front in `initSources`; this is defense in depth.
2993
+ if (cls.recordPath.length > 0 || fromCls.kind === "entry" && fromCls.recordPath.length > 0) {
2994
+ errors.push({
2995
+ message: `Nested .jsonValues() records are not supported: '${cls.recordPath.join(".")}' in ${path}. Use .jsonValues() only on a module's root record/router.`,
2996
+ filePath: path
2997
+ });
2998
+ patchHadError = true;
2999
+ break;
3000
+ }
3001
+ // The op targets a `.jsonValues()` entry.
3002
+ if (cls.subPath.length === 0) {
3003
+ // Structural / whole-entry op.
3004
+ if (op.op === "add") {
3005
+ const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
3006
+ if (fp.result.isErr(newPathsRes)) {
3007
+ errors.push(newPathsRes.error);
3008
+ patchHadError = true;
3009
+ break;
3010
+ }
3011
+ const {
3012
+ jsonPath,
3013
+ importPath
3014
+ } = newPathsRes.value;
3015
+ const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
3016
+ if (fp.result.isErr(insRes)) {
3017
+ collectPatchError(insRes.error, patchId, op);
3018
+ patchHadError = true;
3019
+ break;
3020
+ }
3021
+ tsSourceFile = insRes.value;
3022
+ tsChanged = true;
3023
+ jsonEntryContents.set(jsonPath, op.value);
3024
+ entryKeyToJsonPath.set(cls.entryKey, jsonPath);
3025
+ } else if (op.op === "remove") {
3026
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3027
+ if (fp.result.isErr(jsonPathRes)) {
3028
+ errors.push(jsonPathRes.error);
3029
+ patchHadError = true;
3030
+ break;
3031
+ }
3032
+ const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey);
3033
+ if (fp.result.isErr(remRes)) {
3034
+ collectPatchError(remRes.error, patchId, op);
3035
+ patchHadError = true;
3036
+ break;
3037
+ }
3038
+ tsSourceFile = remRes.value;
3039
+ tsChanged = true;
3040
+ jsonEntryContents.set(jsonPathRes.value, null);
3041
+ } else if (op.op === "replace") {
3042
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3043
+ if (fp.result.isErr(jsonPathRes)) {
3044
+ errors.push(jsonPathRes.error);
3045
+ patchHadError = true;
3046
+ break;
3047
+ }
3048
+ jsonEntryContents.set(jsonPathRes.value, op.value);
3049
+ } else if (op.op === "move" || op.op === "copy") {
3050
+ // Rename (move) or duplicate (copy) a whole entry. The new entry
3051
+ // gets its own `*.val.json` written with the source entry's
3052
+ // content plus a `c.json(...)` thunk; a move additionally drops
3053
+ // the old thunk and deletes the old file.
3054
+ if (fromCls.kind !== "entry" || fromCls.subPath.length !== 0 || fromCls.recordPath.join("\0") !== cls.recordPath.join("\0")) {
3055
+ errors.push({
3056
+ message: `Cannot '${op.op}' a jsonValues entry across records or from a non-entry path (from '${op.from.join(".")}' to '${op.path.join(".")}')`,
3057
+ filePath: path
3058
+ });
3059
+ patchHadError = true;
3060
+ break;
3061
+ }
3062
+ const fromKey = fromCls.entryKey;
3063
+ const fromPathRes = resolveEntryJsonPath(fromKey);
3064
+ if (fp.result.isErr(fromPathRes)) {
3065
+ errors.push(fromPathRes.error);
3066
+ patchHadError = true;
3067
+ break;
3068
+ }
3069
+ // Load BEFORE marking anything deleted: `loadEntryContent` errors
3070
+ // on a path that has already been nulled in this commit.
3071
+ const contentRes = await loadEntryContent(fromPathRes.value);
3072
+ if (fp.result.isErr(contentRes)) {
3073
+ errors.push(contentRes.error);
3074
+ patchHadError = true;
3075
+ break;
3076
+ }
3077
+ const content = patch.deepClone(contentRes.value);
3078
+ if (op.op === "move") {
3079
+ const remRes = removeValJsonEntry(tsSourceFile, cls.recordPath, fromKey);
3080
+ if (fp.result.isErr(remRes)) {
3081
+ collectPatchError(remRes.error, patchId, op);
3082
+ patchHadError = true;
3083
+ break;
3084
+ }
3085
+ tsSourceFile = remRes.value;
3086
+ }
3087
+ // LOCKED convention: the destination always uses the generated
3088
+ // path, so renaming a hand-placed file relocates it.
3089
+ const newPathsRes = getNewJsonEntryPaths(path, cls.entryKey);
3090
+ if (fp.result.isErr(newPathsRes)) {
3091
+ errors.push(newPathsRes.error);
3092
+ patchHadError = true;
3093
+ break;
3094
+ }
3095
+ const {
3096
+ jsonPath,
3097
+ importPath
3098
+ } = newPathsRes.value;
3099
+ const insRes = insertValJsonEntry(tsSourceFile, cls.recordPath, cls.entryKey, importPath);
3100
+ if (fp.result.isErr(insRes)) {
3101
+ collectPatchError(insRes.error, patchId, op);
3102
+ patchHadError = true;
3103
+ break;
3104
+ }
3105
+ tsSourceFile = insRes.value;
3106
+ tsChanged = true;
3107
+ jsonEntryContents.set(jsonPath, content);
3108
+ entryKeyToJsonPath.set(cls.entryKey, jsonPath);
3109
+ if (op.op === "move" && fromPathRes.value !== jsonPath) {
3110
+ jsonEntryContents.set(fromPathRes.value, null);
3111
+ }
3112
+ } else {
3113
+ errors.push({
3114
+ message: `Unsupported op '${op.op}' on jsonValues entry '${cls.entryKey}' (supported: add, remove, replace, move, copy)`,
3115
+ filePath: path
3116
+ });
3117
+ patchHadError = true;
3118
+ break;
3119
+ }
1926
3120
  } else {
1927
- console.error("Could not patch", JSON.stringify({
1928
- path,
1929
- patchId,
1930
- error: patchRes.error,
1931
- sourceFileOps
1932
- }, null, 2));
1933
- errors.push(patchRes.error);
3121
+ // Content sub-op: replay against the entry's `*.val.json`.
3122
+ // `rebaseContentOp` slices `from` by the same prefix as `path`, so a
3123
+ // cross-entry move/copy would silently corrupt the target entry.
3124
+ if ((op.op === "move" || op.op === "copy") && (fromCls.kind !== "entry" || fromCls.entryKey !== cls.entryKey)) {
3125
+ errors.push({
3126
+ message: `Cannot '${op.op}' between different jsonValues entries`,
3127
+ filePath: path
3128
+ });
3129
+ patchHadError = true;
3130
+ break;
3131
+ }
3132
+ const jsonPathRes = resolveEntryJsonPath(cls.entryKey);
3133
+ if (fp.result.isErr(jsonPathRes)) {
3134
+ errors.push(jsonPathRes.error);
3135
+ patchHadError = true;
3136
+ break;
3137
+ }
3138
+ const jsonPath = jsonPathRes.value;
3139
+ const contentRes = await loadEntryContent(jsonPath);
3140
+ if (fp.result.isErr(contentRes)) {
3141
+ errors.push(contentRes.error);
3142
+ patchHadError = true;
3143
+ break;
3144
+ }
3145
+ const rebasedRes = rebaseContentOp(op, cls.recordPath.length + 1);
3146
+ if (fp.result.isErr(rebasedRes)) {
3147
+ errors.push({
3148
+ message: rebasedRes.error.message,
3149
+ filePath: jsonPath
3150
+ });
3151
+ patchHadError = true;
3152
+ break;
3153
+ }
3154
+ const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
3155
+ if (fp.result.isErr(applied)) {
3156
+ collectPatchError(applied.error, patchId, op);
3157
+ patchHadError = true;
3158
+ break;
3159
+ }
3160
+ jsonEntryContents.set(jsonPath, applied.value);
1934
3161
  }
3162
+ }
3163
+ if (patchHadError) {
1935
3164
  unappliablePatches[patchId] = {
1936
3165
  moduleFilePath: path,
1937
- message: formatPatchSourceError(patchRes.error)
3166
+ // The per-op loop reports through `errors`, so take what it added
3167
+ // for THIS patch — the same information a single applyPatch gives
3168
+ // via formatPatchSourceError.
3169
+ message: errors.slice(errorsBefore).map(formatPatchSourceError).join("\n") || `Could not apply patch: ${patchId}`
1938
3170
  };
1939
3171
  triedPatches.push(patchId);
1940
3172
  if (continueOnError) {
1941
- // Continue from the unchanged source file: the failing patch made
1942
- // no change, so the rest of the chain applies on top of the state
1943
- // it had before it. This mirrors what the client already does in
1944
- // ValSyncEngine.getPatchedSource.
3173
+ // Carry on so a single run reports EVERY unappliable patch, not just
3174
+ // the first per module. Note that the ops of this patch BEFORE the
3175
+ // failing one have already been applied, so what follows builds on a
3176
+ // partially patched state — fine, because continueOnError is
3177
+ // diagnosis only and the commit is refused regardless.
1945
3178
  continue;
1946
3179
  }
1947
3180
  break;
1948
3181
  }
1949
3182
  appliedPatches.push(patchId);
1950
- tsSourceFile = patchRes.value;
1951
3183
  }
1952
3184
  if (errors.length > 0 && continueOnError) {
1953
3185
  // Diagnosis: expose what the source file would look like with the
@@ -1956,33 +3188,58 @@ class ValOps {
1956
3188
  partiallyPatchedSourceFiles[path] = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
1957
3189
  }
1958
3190
  if (errors.length === 0) {
1959
- var _this$options;
1960
3191
  // https://github.com/microsoft/TypeScript/issues/36174
1961
- let sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
1962
- if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.formatter) {
1963
- try {
1964
- sourceFileText = await this.options.formatter(sourceFileText, path);
1965
- } catch (err) {
1966
- errors.push({
1967
- message: "Could not format source file: " + (err instanceof Error ? err.message : "Unknown error")
1968
- });
3192
+ let sourceFileText = null;
3193
+ if (tsChanged) {
3194
+ var _this$options;
3195
+ sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
3196
+ if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.formatter) {
3197
+ try {
3198
+ sourceFileText = await this.options.formatter(sourceFileText, path);
3199
+ } catch (err) {
3200
+ errors.push({
3201
+ message: "Could not format source file: " + (err instanceof Error ? err.message : "Unknown error")
3202
+ });
3203
+ }
1969
3204
  }
1970
3205
  }
1971
- return {
1972
- path,
1973
- appliedPatches,
1974
- result: sourceFileText
1975
- };
1976
- } else {
1977
- const skippedPatches = patches.slice(appliedPatches.length + triedPatches.length).map(p => p.patchId);
1978
- return {
1979
- path,
1980
- appliedPatches,
1981
- triedPatches,
1982
- skippedPatches,
1983
- errors
1984
- };
3206
+ const extraFiles = {};
3207
+ for (const [jsonPath, content] of Array.from(jsonEntryContents)) {
3208
+ var _this$options2;
3209
+ if (content === null) {
3210
+ extraFiles[jsonPath] = null;
3211
+ continue;
3212
+ }
3213
+ let jsonText = JSON.stringify(content, null, 2);
3214
+ if ((_this$options2 = this.options) !== null && _this$options2 !== void 0 && _this$options2.formatter) {
3215
+ try {
3216
+ jsonText = await this.options.formatter(jsonText, jsonPath);
3217
+ } catch (err) {
3218
+ errors.push({
3219
+ message: "Could not format jsonValues entry: " + (err instanceof Error ? err.message : "Unknown error"),
3220
+ filePath: jsonPath
3221
+ });
3222
+ }
3223
+ }
3224
+ extraFiles[jsonPath] = jsonText;
3225
+ }
3226
+ if (errors.length === 0) {
3227
+ return {
3228
+ path,
3229
+ appliedPatches,
3230
+ result: sourceFileText,
3231
+ extraFiles
3232
+ };
3233
+ }
1985
3234
  }
3235
+ const skippedPatches = patches.slice(appliedPatches.length + triedPatches.length).map(p => p.patchId);
3236
+ return {
3237
+ path,
3238
+ appliedPatches,
3239
+ triedPatches,
3240
+ skippedPatches,
3241
+ errors
3242
+ };
1986
3243
  };
1987
3244
  const allResults = await Promise.all(Object.entries(patchesByModule).map(([path, patches]) => applySourceFilePatches(path, patches)));
1988
3245
  let hasErrors = false;
@@ -2001,7 +3258,14 @@ class ValOps {
2001
3258
  triedPatches[res.path] = res.triedPatches ?? [];
2002
3259
  skippedPatches[res.path] = res.skippedPatches ?? [];
2003
3260
  } else {
2004
- patchedSourceFiles[res.path] = res.result;
3261
+ // `result` is null when the `.val.ts` itself was not changed (pure
3262
+ // jsonValues content edits only write `*.val.json` extraFiles).
3263
+ if (res.result !== null) {
3264
+ patchedSourceFiles[res.path] = res.result;
3265
+ }
3266
+ for (const [extraPath, data] of Object.entries(res.extraFiles)) {
3267
+ patchedSourceFiles[extraPath] = data;
3268
+ }
2005
3269
  appliedPatches[res.path] = res.appliedPatches ?? [];
2006
3270
  }
2007
3271
  for (const patchId of res.appliedPatches ?? []) {
@@ -2178,6 +3442,130 @@ function bufferFromDataUrl(dataUrl) {
2178
3442
  }
2179
3443
  }
2180
3444
 
3445
+ /**
3446
+ * The `*.val.json` files backing a `.jsonValues()` module's entries, as paths
3447
+ * relative to the project root.
3448
+ *
3449
+ * Read out of the `.val.ts` rather than off the loaded module: the entry's path
3450
+ * only exists as the string literal inside its `c.json(() => import("..."))`
3451
+ * thunk, and the loaded marker does not carry it.
3452
+ */
3453
+ function findJsonEntryFilePathsInSource(moduleFilePath, valTsSourceFile) {
3454
+ let analysis;
3455
+ try {
3456
+ analysis = analyzeValModule(valTsSourceFile);
3457
+ } catch {
3458
+ return [];
3459
+ }
3460
+ if (fp.result.isErr(analysis)) {
3461
+ return [];
3462
+ }
3463
+ const paths = [];
3464
+ analyzeJsonValuesEntries(analysis.value.source).forEach(entry => {
3465
+ paths.push(resolveExistingJsonPath(moduleFilePath, entry.importPath));
3466
+ });
3467
+ return paths;
3468
+ }
3469
+
3470
+ /**
3471
+ * A fingerprint of every `.jsonValues()` entry file on disk, for change
3472
+ * detection.
3473
+ *
3474
+ * Uses each file's SIZE + MTIME, never its content: reading every entry on every
3475
+ * stat would undo the point of `.jsonValues()`, and the question here is only
3476
+ * "did any of them change", not "what do they say now".
3477
+ *
3478
+ * Why this needs to exist at all: `sourcesSha` and `baseSha` are computed from
3479
+ * `JSON.stringify(source)`, and a jsonValues module's source is just markers —
3480
+ * the content is behind a thunk, which `JSON.stringify` drops. So no existing sha
3481
+ * can see an entry edit, and the Studio has no way to learn that a hand-edited
3482
+ * `*.val.json` changed.
3483
+ *
3484
+ * Deliberately NOT folded into `sourcesSha`: that is computed by the client too
3485
+ * (from the ValModules registry, which has no entry content), so the two would
3486
+ * disagree and trip the schema-out-of-date machinery.
3487
+ */
3488
+ class JsonEntryFilesFingerprint {
3489
+ /** Entry paths per module, memoized on the `.val.ts` mtime that produced them. */
3490
+ cache = new Map();
3491
+ constructor(rootDir) {
3492
+ this.rootDir = rootDir;
3493
+ }
3494
+ compute(schemas) {
3495
+ const parts = [];
3496
+ for (const entryFilePath of this.entryFilePaths(schemas)) {
3497
+ parts.push(`${entryFilePath}:${this.fileFingerprint(entryFilePath)}`);
3498
+ }
3499
+ return parts.join("|");
3500
+ }
3501
+
3502
+ /**
3503
+ * Every `.jsonValues()` entry file the given schemas reach, rootDir-relative and
3504
+ * in a stable order.
3505
+ *
3506
+ * Public because the fingerprint is only half the story: the fs polling fallback
3507
+ * (which exists because `fs.watch` is unreliable on some systems, notably WSL)
3508
+ * has to watch these files too, or on those systems a hand-edited entry waits
3509
+ * out the whole long-poll interval.
3510
+ */
3511
+ entryFilePaths(schemas) {
3512
+ const paths = [];
3513
+ for (const moduleFilePathS of Object.keys(schemas).sort()) {
3514
+ const moduleFilePath = moduleFilePathS;
3515
+ const schema = schemas[moduleFilePath];
3516
+ // Root-only, per locked decision #7 — so a module whose root is not a
3517
+ // jsonValues record has no entry files to fingerprint.
3518
+ if (schema === undefined || schema.type !== "record" || schema.jsonValues !== true) {
3519
+ continue;
3520
+ }
3521
+ paths.push(...this.entryFilePathsOf(moduleFilePath));
3522
+ }
3523
+ return paths;
3524
+ }
3525
+ entryFilePathsOf(moduleFilePath) {
3526
+ const absModulePath = path__namespace["default"].join(this.rootDir, moduleFilePath);
3527
+ let moduleMtimeMs;
3528
+ try {
3529
+ moduleMtimeMs = fs__default["default"].statSync(absModulePath).mtimeMs;
3530
+ } catch {
3531
+ return [];
3532
+ }
3533
+ const cached = this.cache.get(moduleFilePath);
3534
+ // The entry LIST only changes when the `.val.ts` does (that is where the
3535
+ // thunks live), so parsing it again on every stat would be wasted work.
3536
+ if (cached && cached.moduleMtimeMs === moduleMtimeMs) {
3537
+ return cached.entryFilePaths;
3538
+ }
3539
+ let entryFilePaths = [];
3540
+ try {
3541
+ const contents = fs__default["default"].readFileSync(absModulePath, "utf-8");
3542
+ entryFilePaths = findJsonEntryFilePathsInSource(moduleFilePath, ts__default["default"].createSourceFile(absModulePath, contents, ts__default["default"].ScriptTarget.ES2015));
3543
+ } catch {
3544
+ entryFilePaths = [];
3545
+ }
3546
+ this.cache.set(moduleFilePath, {
3547
+ moduleMtimeMs,
3548
+ entryFilePaths
3549
+ });
3550
+ return entryFilePaths;
3551
+ }
3552
+ fileFingerprint(entryFilePath) {
3553
+ try {
3554
+ // NANOSECOND mtime, not `mtimeMs`: two writes inside the same millisecond
3555
+ // that happen to preserve the file size would otherwise be indistinguishable
3556
+ // from no change at all, and the edit would silently never reach the Studio.
3557
+ const stat = fs__default["default"].statSync(path__namespace["default"].join(this.rootDir, entryFilePath), {
3558
+ bigint: true
3559
+ });
3560
+ return `${stat.size}:${stat.mtimeNs}`;
3561
+ } catch {
3562
+ // Missing is a state too — going from present to absent must change the
3563
+ // fingerprint, not be indistinguishable from unchanged.
3564
+ return "missing";
3565
+ }
3566
+ }
3567
+ }
3568
+
2181
3569
  /**
2182
3570
  * Computes the changed patch parent references based on the current patches and the patch IDs to be deleted.
2183
3571
  *
@@ -2269,6 +3657,14 @@ function getFileExt(filePath) {
2269
3657
  return filePath.split(".").pop() || "";
2270
3658
  }
2271
3659
 
3660
+ /** Serializes a schema, or gives up quietly — serialization errors are reported elsewhere. */
3661
+ function serializeSchemaSafely(schema) {
3662
+ try {
3663
+ return schema === null || schema === void 0 ? void 0 : schema["executeSerialize"]();
3664
+ } catch {
3665
+ return undefined;
3666
+ }
3667
+ }
2272
3668
  class ValOpsFS extends ValOps {
2273
3669
  static VAL_DIR = ".val";
2274
3670
  constructor(contentUrl, rootDir, valModules, options) {
@@ -2276,7 +3672,14 @@ class ValOpsFS extends ValOps {
2276
3672
  this.contentUrl = contentUrl;
2277
3673
  this.rootDir = rootDir;
2278
3674
  this.host = new FSOpsHost();
3675
+ this.jsonEntryFilesFingerprint = new JsonEntryFilesFingerprint(rootDir);
2279
3676
  }
3677
+
3678
+ /**
3679
+ * Change detection for `.jsonValues()` entry files, which no sha can see (their
3680
+ * content lives behind a thunk that `JSON.stringify` drops).
3681
+ */
3682
+
2280
3683
  async onInit() {
2281
3684
  // do nothing
2282
3685
  }
@@ -2365,7 +3768,11 @@ class ValOpsFS extends ValOps {
2365
3768
  const currentBaseSha = await this.getBaseSha();
2366
3769
  const currentSchemaSha = await this.getSchemaSha();
2367
3770
  const currentSourcesSha = await this.getSourcesSha();
2368
- const moduleFilePaths = Object.keys(await this.getSchemas());
3771
+ const schemas = await this.getSchemas();
3772
+ const moduleFilePaths = Object.keys(schemas);
3773
+ const serializedSchemas = Object.fromEntries(Object.entries(schemas).map(([path, schema]) => [path, serializeSchemaSafely(schema)]));
3774
+ const currentJsonEntriesSha = this.jsonEntryFilesFingerprint.compute(serializedSchemas);
3775
+ const jsonEntryFilePaths = this.jsonEntryFilesFingerprint.entryFilePaths(serializedSchemas);
2369
3776
  const patchData = await this.readPatches();
2370
3777
  const patches = [];
2371
3778
  // TODO: use proper patch sequences when available:
@@ -2375,7 +3782,10 @@ class ValOpsFS extends ValOps {
2375
3782
  patches.push(patchId);
2376
3783
  }
2377
3784
  // something changed: return immediately
2378
- const didChange = !params || currentBaseSha !== params.baseSha ||
3785
+ const didChange = !params ||
3786
+ // An entry file changed on disk: nothing else here can see that, since a
3787
+ // jsonValues module's source is markers.
3788
+ params.jsonEntriesSha !== undefined && currentJsonEntriesSha !== params.jsonEntriesSha || currentBaseSha !== params.baseSha ||
2379
3789
  // base sha covers both sources sha and schema sha, so we could remove checks for schema sha and sources sha
2380
3790
  currentSourcesSha !== params.sourcesSha || currentSchemaSha !== params.schemaSha || patches.length !== params.patches.length || patches.some((p, i) => p !== params.patches[i]);
2381
3791
  if (didChange) {
@@ -2384,7 +3794,8 @@ class ValOpsFS extends ValOps {
2384
3794
  baseSha: currentBaseSha,
2385
3795
  schemaSha: currentSchemaSha,
2386
3796
  sourcesSha: currentSourcesSha,
2387
- patches
3797
+ patches,
3798
+ jsonEntriesSha: currentJsonEntriesSha
2388
3799
  };
2389
3800
  }
2390
3801
  let fsWatcher = null;
@@ -2467,7 +3878,12 @@ class ValOpsFS extends ValOps {
2467
3878
  patchesDirHandle = handle;
2468
3879
  }),
2469
3880
  // we poll the files that Val depends on for changes
2470
- disableFilePolling ? new Promise(() => {}) : didFilesChangeUsingPolling([path__namespace["default"].join(this.rootDir, "val.config.ts"), path__namespace["default"].join(this.rootDir, "val.modules.ts"), path__namespace["default"].join(this.rootDir, "val.config.js"), path__namespace["default"].join(this.rootDir, "val.modules.js"), ...moduleFilePaths.map(p => path__namespace["default"].join(this.rootDir, p))], statFilePollingInterval, handle => {
3881
+ disableFilePolling ? new Promise(() => {}) : didFilesChangeUsingPolling([path__namespace["default"].join(this.rootDir, "val.config.ts"), path__namespace["default"].join(this.rootDir, "val.modules.ts"), path__namespace["default"].join(this.rootDir, "val.config.js"), path__namespace["default"].join(this.rootDir, "val.modules.js"), ...moduleFilePaths.map(p => path__namespace["default"].join(this.rootDir, p)),
3882
+ // The `.jsonValues()` entry files too: their content is invisible
3883
+ // to every sha, and this polling fallback exists for the systems
3884
+ // where the `fs.watch` below does not fire (notably WSL) — without
3885
+ // them a hand-edited entry waits out the whole long-poll interval.
3886
+ ...jsonEntryFilePaths.map(p => path__namespace["default"].join(this.rootDir, p))], statFilePollingInterval, handle => {
2471
3887
  valFilesIntervalHandle = handle;
2472
3888
  }), new Promise(resolve => {
2473
3889
  fsWatcher = fs__default["default"].watch(this.rootDir, {
@@ -2476,7 +3892,11 @@ class ValOpsFS extends ValOps {
2476
3892
  if (!filename) {
2477
3893
  return;
2478
3894
  }
2479
- 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");
3895
+ const isChange = filename.startsWith(this.getPatchesDir().slice(this.rootDir.length + 1)) || filename.endsWith(".val.ts") || filename.endsWith(".val.js") ||
3896
+ // A `.jsonValues()` entry file. Its content is invisible to every
3897
+ // sha (see JsonEntryFilesFingerprint), so without this a
3898
+ // hand-edited entry never reaches an open Studio.
3899
+ filename.endsWith(".val.json") || filename.endsWith("val.config.ts") || filename.endsWith("val.config.js") || filename.endsWith("val.modules.ts") || filename.endsWith("val.modules.js");
2480
3900
  if (isChange) {
2481
3901
  // a file that Val depends on just changed or a patch was created, break connection and request stat again to get the new values
2482
3902
  resolve("request-again");
@@ -2498,7 +3918,8 @@ class ValOpsFS extends ValOps {
2498
3918
  baseSha: currentBaseSha,
2499
3919
  schemaSha: currentSchemaSha,
2500
3920
  sourcesSha: currentSourcesSha,
2501
- patches
3921
+ patches,
3922
+ jsonEntriesSha: currentJsonEntriesSha
2502
3923
  };
2503
3924
  } catch (err) {
2504
3925
  if (err instanceof Error) {
@@ -5557,6 +6978,144 @@ const ValServer = (valModules, options, callbacks) => {
5557
6978
  };
5558
6979
  }
5559
6980
  },
6981
+ // #region json
6982
+ // Loads the content of a single `.jsonValues()` entry by key, so the Studio
6983
+ // can lazily load just the entry being opened, and the runtime can read draft
6984
+ // edits. With apply_patches (default true) pending patches for the entry are
6985
+ // replayed server-side; the Studio passes false and overlays its own.
6986
+ "/json": {
6987
+ GET: async req => {
6988
+ const auth = getAuth(req.cookies);
6989
+ if (auth.error) {
6990
+ return {
6991
+ status: 401,
6992
+ json: {
6993
+ message: auth.error
6994
+ }
6995
+ };
6996
+ }
6997
+ if (serverOps instanceof ValOpsHttp && !("id" in auth)) {
6998
+ return {
6999
+ status: 401,
7000
+ json: {
7001
+ message: "Unauthorized"
7002
+ }
7003
+ };
7004
+ }
7005
+ const moduleFilePath = req.query.path;
7006
+ const {
7007
+ key,
7008
+ keys,
7009
+ offset,
7010
+ limit
7011
+ } = req.query;
7012
+ // Defaults to true, mirroring /sources/~. The Studio opts out.
7013
+ const applyPatches = req.query.apply_patches !== false;
7014
+ const isWindow = offset !== undefined || limit !== undefined;
7015
+ const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
7016
+ if (shapes !== 1) {
7017
+ return {
7018
+ status: 400,
7019
+ json: {
7020
+ message: "Exactly one of 'key', 'keys' or 'offset'+'limit' must be given"
7021
+ }
7022
+ };
7023
+ }
7024
+ if (isWindow && (offset === undefined || limit === undefined)) {
7025
+ return {
7026
+ status: 400,
7027
+ json: {
7028
+ message: "'offset' and 'limit' must be given together"
7029
+ }
7030
+ };
7031
+ }
7032
+ if (key !== undefined) {
7033
+ const res = await serverOps.getJsonEntry(moduleFilePath, key, {
7034
+ applyPatches
7035
+ });
7036
+ if (res.status === "unauthorized") {
7037
+ return {
7038
+ status: 401,
7039
+ json: {
7040
+ message: res.message
7041
+ }
7042
+ };
7043
+ }
7044
+ if (res.status === "not-found") {
7045
+ return {
7046
+ status: 404,
7047
+ json: {
7048
+ message: res.message
7049
+ }
7050
+ };
7051
+ }
7052
+ if (res.status === "error") {
7053
+ return {
7054
+ status: 500,
7055
+ json: {
7056
+ message: res.message
7057
+ }
7058
+ };
7059
+ }
7060
+ return {
7061
+ status: 200,
7062
+ json: {
7063
+ path: moduleFilePath,
7064
+ key,
7065
+ content: res.content
7066
+ }
7067
+ };
7068
+ }
7069
+ const res = await serverOps.getJsonEntries(moduleFilePath, keys !== undefined ? {
7070
+ keys
7071
+ } : {
7072
+ offset: offset,
7073
+ limit: limit
7074
+ }, {
7075
+ applyPatches
7076
+ });
7077
+ if (res.status === "unauthorized") {
7078
+ return {
7079
+ status: 401,
7080
+ json: {
7081
+ message: res.message
7082
+ }
7083
+ };
7084
+ }
7085
+ if (res.status === "not-found") {
7086
+ return {
7087
+ status: 404,
7088
+ json: {
7089
+ message: res.message
7090
+ }
7091
+ };
7092
+ }
7093
+ if (res.status === "error") {
7094
+ return {
7095
+ status: 500,
7096
+ json: {
7097
+ message: res.message
7098
+ }
7099
+ };
7100
+ }
7101
+ return {
7102
+ status: 200,
7103
+ json: {
7104
+ path: moduleFilePath,
7105
+ entries: res.entries,
7106
+ missing: res.missing,
7107
+ errors: res.errors,
7108
+ ...(res.offset !== undefined ? {
7109
+ offset: res.offset
7110
+ } : {}),
7111
+ ...(res.limit !== undefined ? {
7112
+ limit: res.limit
7113
+ } : {}),
7114
+ total: res.total
7115
+ }
7116
+ };
7117
+ }
7118
+ },
5560
7119
  // #region sources
5561
7120
  "/sources/~": {
5562
7121
  PUT: async req => {
@@ -8738,6 +10297,23 @@ function createModulePathMap(sourceFile) {
8738
10297
  }
8739
10298
  }
8740
10299
  }
10300
+
10301
+ /**
10302
+ * The {@link createModulePathMap} equivalent for a `.jsonValues()` entry's
10303
+ * backing `*.val.json`.
10304
+ *
10305
+ * The file IS the entry's value, so the map is rooted at the JSON document
10306
+ * rather than at a `c.define` argument — but everything below is the same shape,
10307
+ * which means a module path like `"title"` (the part after the entry key)
10308
+ * resolves against it exactly as it would inside a `.val.ts`.
10309
+ */
10310
+ function createJsonEntryPathMap(jsonSourceFile) {
10311
+ const statement = jsonSourceFile.statements[0];
10312
+ if (!statement) {
10313
+ return undefined;
10314
+ }
10315
+ return traverse(statement.expression, jsonSourceFile);
10316
+ }
8741
10317
  function traverse(node, sourceFile) {
8742
10318
  if (ts__default["default"].isStringLiteral(node) || ts__default["default"].isNumericLiteral(node)) {
8743
10319
  return {
@@ -8827,6 +10403,93 @@ function traverseObjectLiteral(node, sourceFile) {
8827
10403
  }, {});
8828
10404
  }
8829
10405
 
10406
+ /**
10407
+ * Which `*.val.json` holds a `.jsonValues()` entry's content, given the module's
10408
+ * `.val.ts`.
10409
+ *
10410
+ * Tooling that maps a validation `sourcePath` back to a place in a file needs
10411
+ * this: for a jsonValues module the offending value is NOT in the `.val.ts` at
10412
+ * all — that file only holds `c.json(() => import("./x.val.json"))` — so a
10413
+ * resolver that only ever looks at the `.val.ts` can report the error but not
10414
+ * where it lives, which for a record with hundreds of entries is not much of a
10415
+ * report.
10416
+ *
10417
+ * Returns a path relative to the project root (leading slash), or undefined when
10418
+ * the module has no such entry (not a jsonValues module, unparseable, or the key
10419
+ * is not backed by a `c.json` thunk).
10420
+ */
10421
+ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
10422
+ let analysis;
10423
+ try {
10424
+ analysis = analyzeValModule(valTsSourceFile);
10425
+ } catch {
10426
+ return undefined;
10427
+ }
10428
+ if (fp.result.isErr(analysis)) {
10429
+ return undefined;
10430
+ }
10431
+ const entry = analyzeJsonValuesEntries(analysis.value.source).get(entryKey);
10432
+ if (!entry) {
10433
+ return undefined;
10434
+ }
10435
+ return resolveExistingJsonPath(moduleFilePath, entry.importPath);
10436
+ }
10437
+
10438
+ /**
10439
+ * Moves ONE `.jsonValues()` entry that was written inline in the `.val.ts` into
10440
+ * its own `*.val.json`, replacing the inline value with
10441
+ * `c.json(() => import("./<key>.val.json"))`.
10442
+ *
10443
+ * This is the fix for the `jsonValues:extract-entry` validation error. It is not
10444
+ * expressible as a patch (a patch edits one `.val.ts` and cannot create the
10445
+ * backing JSON file), so it writes both files directly — JSON first, so a
10446
+ * failure part-way through never leaves the module pointing at a file that does
10447
+ * not exist.
10448
+ *
10449
+ * Root-only, like the rest of the `.jsonValues()` machinery: the entry is looked
10450
+ * up in the module's root record/router object literal.
10451
+ */
10452
+ function extractJsonValuesEntry(moduleFilePath, rootDir, entryKey, content, sourceFileHandler) {
10453
+ const valTsPath = sourceFileHandler.resolveSourceModulePath(getSyntheticContainingPath(rootDir), `.${moduleFilePath.replace(".val.ts", ".val").replace(".val.js", ".val").replace(".val.jsx", ".val").replace(".val.tsx", ".val")}`);
10454
+ const sourceFile = sourceFileHandler.getSourceFile(valTsPath);
10455
+ if (!sourceFile) {
10456
+ throw Error(`Source file ${valTsPath} not found`);
10457
+ }
10458
+ const pathsRes = getNewJsonEntryPaths(moduleFilePath, entryKey);
10459
+ if (fp.result.isErr(pathsRes)) {
10460
+ throw Error(formatPatchSourceError(pathsRes.error));
10461
+ }
10462
+ const {
10463
+ jsonPath,
10464
+ importPath
10465
+ } = pathsRes.value;
10466
+ const absoluteJsonPath = path__namespace["default"].join(rootDir, jsonPath);
10467
+ if (sourceFileHandler.host.fileExists(absoluteJsonPath)) {
10468
+ throw Error(`Cannot extract .jsonValues() entry '${entryKey}' of ${moduleFilePath}: '${jsonPath}' already exists`);
10469
+ }
10470
+
10471
+ // Remove the inline property, then add the `c.json(...)` reference back. The
10472
+ // entry moves to the end of the record: entry ORDER in a jsonValues record is
10473
+ // not meaningful (the Studio and the runtime key entries by name), and
10474
+ // insert-in-place would mean reimplementing insertValJsonEntry.
10475
+ const removed = removeValJsonEntry(sourceFile, [], entryKey);
10476
+ if (fp.result.isErr(removed)) {
10477
+ throw Error(`${valTsPath}\n${formatOpsError(removed.error, sourceFile)}`);
10478
+ }
10479
+ const inserted = insertValJsonEntry(removed.value, [], entryKey, importPath);
10480
+ if (fp.result.isErr(inserted)) {
10481
+ throw Error(`${valTsPath}\n${formatOpsError(inserted.error, removed.value)}`);
10482
+ }
10483
+ sourceFileHandler.writeFile(absoluteJsonPath, JSON.stringify(content, null, 2) + "\n", "utf8");
10484
+ sourceFileHandler.writeSourceFile(inserted.value);
10485
+ }
10486
+ function formatOpsError(error, sourceFile) {
10487
+ if (error instanceof patch.PatchError) {
10488
+ return error.message;
10489
+ }
10490
+ return flatMapErrors(error, e => formatSyntaxError(e, sourceFile)).join("\n");
10491
+ }
10492
+
8830
10493
  /**
8831
10494
  * Replays a `val debug` snapshot: applies its patches the way /save does and
8832
10495
  * validates the result.
@@ -8941,6 +10604,7 @@ exports.awaitValLoginConfirmation = awaitValLoginConfirmation;
8941
10604
  exports.checkRemoteRef = checkRemoteRef;
8942
10605
  exports.compareWithCapturedReport = compareWithCapturedReport;
8943
10606
  exports.createFixPatch = createFixPatch;
10607
+ exports.createJsonEntryPathMap = createJsonEntryPathMap;
8944
10608
  exports.createModulePathMap = createModulePathMap;
8945
10609
  exports.createService = createService;
8946
10610
  exports.createValApiRouter = createValApiRouter;
@@ -8951,7 +10615,9 @@ exports.encodeJwt = encodeJwt;
8951
10615
  exports.evalValConfigFile = evalValConfigFile;
8952
10616
  exports.extractFileMetadata = extractFileMetadata;
8953
10617
  exports.extractImageMetadata = extractImageMetadata;
10618
+ exports.extractJsonValuesEntry = extractJsonValuesEntry;
8954
10619
  exports.findAndEvalValConfigFile = findAndEvalValConfigFile;
10620
+ exports.findJsonEntryFilePath = findJsonEntryFilePath;
8955
10621
  exports.formatPatchSourceError = formatPatchSourceError;
8956
10622
  exports.formatSyntaxErrorTree = formatSyntaxErrorTree;
8957
10623
  exports.getCachedRemoteFileDir = getCachedRemoteFileDir;