@marktiderman/genesis-forms 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.cjs CHANGED
@@ -354,8 +354,7 @@ var round = (n, decimals) => {
354
354
  const r = Number(n.toFixed(decimals));
355
355
  return Object.is(r, -0) ? 0 : r;
356
356
  };
357
- function calculatedValue(result, field) {
358
- const { format, decimals } = calculatedProps(field);
357
+ function valueAs(result, { format, decimals }) {
359
358
  if (result === null || Array.isArray(result)) return void 0;
360
359
  if (format === "number" || format === "percent") {
361
360
  const n = formulaNumber(result);
@@ -382,19 +381,6 @@ var extract = (value, column) => column !== void 0 ? value.flatMap((row) => {
382
381
  const cell = row[column];
383
382
  return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
384
383
  }) : value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
385
- var extracted = /* @__PURE__ */ new WeakMap();
386
- function cachedList(value, column) {
387
- let byColumn = extracted.get(value);
388
- if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
389
- const key = column ?? "";
390
- let list = byColumn.get(key);
391
- if (!list) byColumn.set(key, list = ownFormulaList(extract(value, column)));
392
- return list;
393
- }
394
- function readAnswer(value, column) {
395
- if (Array.isArray(value)) return cachedList(value, column);
396
- return column !== void 0 ? null : formulaInput(value);
397
- }
398
384
  function formulaInput(value, column) {
399
385
  if (value === void 0 || value === null) return null;
400
386
  if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
@@ -409,9 +395,28 @@ function refNodes(node, out = []) {
409
395
  else if (node.kind === "call") for (const a of node.args) refNodes(a, out);
410
396
  return out;
411
397
  }
398
+ function formulaSize(node) {
399
+ if (node.kind === "unary") return 1 + formulaSize(node.arg);
400
+ if (node.kind === "binary") return 1 + formulaSize(node.left) + formulaSize(node.right);
401
+ if (node.kind === "if") return 1 + formulaSize(node.test) + formulaSize(node.then) + formulaSize(node.else);
402
+ if (node.kind === "call") return node.args.reduce((n, a) => n + formulaSize(a), 1);
403
+ return 1;
404
+ }
405
+ var ReplayWorkExceeded = class extends Error {
406
+ constructor(limit) {
407
+ super(`Replaying the response would take more than ${limit} steps of work.`);
408
+ this.limit = limit;
409
+ }
410
+ limit;
411
+ };
412
+ function spend(budget, amount) {
413
+ if (!budget) return;
414
+ budget.used += amount;
415
+ if (budget.used > budget.max) throw new ReplayWorkExceeded(budget.max);
416
+ }
412
417
  var UNREAD = /* @__PURE__ */ Symbol("unread");
413
418
  var OVER = /* @__PURE__ */ Symbol("over");
414
- function calculator(definition) {
419
+ function calculator(definition, work) {
415
420
  const compiled = [];
416
421
  const idByKey = /* @__PURE__ */ new Map();
417
422
  for (const block of definition.blocks) {
@@ -420,7 +425,8 @@ function calculator(definition) {
420
425
  }
421
426
  for (const block of definition.blocks) {
422
427
  if (block.kind !== "field" || !isCalculated(block)) continue;
423
- const parsed = parseFormula(calculatedProps(block).formula);
428
+ const props = calculatedProps(block);
429
+ const parsed = parseFormula(props.formula);
424
430
  const reads = [];
425
431
  const slots = /* @__PURE__ */ new Map();
426
432
  const byKey = /* @__PURE__ */ new Map();
@@ -434,33 +440,65 @@ function calculator(definition) {
434
440
  }
435
441
  slots.set(ref, slot);
436
442
  }
437
- compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, slots, last: null });
443
+ compiled.push({ field: block, props, ast: parsed.ok ? parsed.ast : null, size: parsed.ok ? formulaSize(parsed.ast) : 0, reads, slots, last: null });
438
444
  }
445
+ const extracted = /* @__PURE__ */ new WeakMap();
446
+ const cachedList = (value, column) => {
447
+ let byColumn = extracted.get(value);
448
+ if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
449
+ const key = column ?? "";
450
+ let list = byColumn.get(key);
451
+ if (!list) {
452
+ byColumn.set(key, list = ownFormulaList(extract(value, column)));
453
+ }
454
+ return list;
455
+ };
456
+ const readAnswer = (value, column) => {
457
+ if (Array.isArray(value)) return cachedList(value, column);
458
+ return column !== void 0 ? null : formulaInput(value);
459
+ };
460
+ const forget = (value) => {
461
+ if (!value || typeof value !== "object") return;
462
+ if (Array.isArray(value)) extracted.delete(value);
463
+ for (const entry of compiled) if (entry.last?.inputs.includes(value)) entry.last = null;
464
+ };
439
465
  const withCalculated = (values, path) => {
440
466
  if (!compiled.length) return values;
441
467
  const visible = new Set(path);
442
468
  let next = values;
469
+ let copied = false;
443
470
  let budget = MAX_SETTLE_ITEMS;
444
471
  const within = /* @__PURE__ */ new Map();
445
- const input = ({ id, key }) => {
472
+ const input = ({ id }) => {
446
473
  if (id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id)) return UNREAD;
447
474
  const value = next[id];
448
475
  if (!Array.isArray(value)) return value;
449
- let ok = within.get(key);
476
+ let ok = within.get(id);
450
477
  if (ok === void 0) {
451
478
  ok = value.length <= MAX_FORMULA_ITEMS && value.length <= budget;
452
479
  if (ok) budget -= value.length;
453
- within.set(key, ok);
480
+ within.set(id, ok);
454
481
  }
455
482
  return ok ? value : OVER;
456
483
  };
457
484
  for (const entry of compiled) {
458
485
  const { field, ast } = entry;
459
- const inputs = entry.reads.map(input);
486
+ const { reads } = entry;
487
+ spend(work, 1);
488
+ const inputs = new Array(reads.length);
489
+ let over = false;
490
+ let same = entry.last !== null;
491
+ for (let i = 0; i < reads.length; i++) {
492
+ const v = input(reads[i]);
493
+ inputs[i] = v;
494
+ if (v === OVER) over = true;
495
+ if (same && entry.last.inputs[i] !== v) same = false;
496
+ }
460
497
  let value;
461
- if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
498
+ if (same) {
462
499
  value = entry.last.value;
463
500
  } else {
501
+ spend(work, entry.size);
464
502
  const resolved = new Array(inputs.length);
465
503
  const read = (ref) => {
466
504
  const slot = entry.slots.get(ref);
@@ -468,19 +506,25 @@ function calculator(definition) {
468
506
  let v = resolved[slot];
469
507
  if (v === void 0) {
470
508
  const raw = inputs[slot];
471
- v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, entry.reads[slot].column);
509
+ v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, reads[slot].column);
472
510
  }
473
511
  return v;
474
512
  };
475
- value = ast && !inputs.includes(OVER) ? calculatedValue(evaluateFormula(ast, read), field) : void 0;
513
+ value = ast && !over ? valueAs(evaluateFormula(ast, read), entry.props) : void 0;
476
514
  entry.last = { inputs, value };
477
515
  }
478
516
  const current = Object.prototype.hasOwnProperty.call(next, field.id) ? next[field.id] : void 0;
479
- if (current !== value) next = { ...next, [field.id]: value };
517
+ if (current !== value) {
518
+ if (!copied) {
519
+ next = { ...next };
520
+ copied = true;
521
+ }
522
+ next[field.id] = value;
523
+ }
480
524
  }
481
525
  return next;
482
526
  };
483
- return { active: compiled.length > 0, withCalculated };
527
+ return { active: compiled.length > 0, ids: new Set(compiled.map((c) => c.field.id)), withCalculated, forget };
484
528
  }
485
529
  var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
486
530
  var calculatedFieldType = {
@@ -677,6 +721,80 @@ var structuredFieldTypes = [
677
721
  }
678
722
  ];
679
723
 
724
+ // src/photo.ts
725
+ var PHOTO_TYPE = "photo";
726
+ var PHOTO_FORMATS = {
727
+ jpeg: ["image/jpeg"],
728
+ png: ["image/png"],
729
+ heic: ["image/heic", "image/heif"],
730
+ webp: ["image/webp"]
731
+ };
732
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
733
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
734
+ var DEFAULT_PHOTO_FILES = 3;
735
+ var MAX_PHOTO_FILES = 5;
736
+ var DEFAULT_PHOTO_SIZE_MB = 10;
737
+ var MAX_PHOTO_SIZE_MB = 25;
738
+ var MAX_PHOTO_ID_LENGTH = 200;
739
+ var MAX_PHOTO_NAME_LENGTH = 255;
740
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
741
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
742
+ function photoLimits(field) {
743
+ const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
744
+ const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
745
+ const raw = field.props?.formats;
746
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
747
+ const formats = picked.length ? picked : [...ALL_FORMATS];
748
+ return {
749
+ maxFiles,
750
+ maxSizeMb,
751
+ maxBytes: maxSizeMb * 1024 * 1024,
752
+ formats,
753
+ mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
754
+ };
755
+ }
756
+ function isPhotoRef(v) {
757
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
758
+ const r = v;
759
+ return typeof r.id === "string" && r.id.length > 0 && r.id.length <= MAX_PHOTO_ID_LENGTH && typeof r.name === "string" && r.name.length <= MAX_PHOTO_NAME_LENGTH && typeof r.type === "string" && typeof r.size === "number" && Number.isInteger(r.size) && r.size > 0;
760
+ }
761
+ function photoRefs(value) {
762
+ if (!Array.isArray(value)) return [];
763
+ const refs = [];
764
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
765
+ return refs;
766
+ }
767
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
768
+ function photoFormatList(formats) {
769
+ const names = formats.map((f) => FORMAT_NAMES[f]);
770
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
771
+ }
772
+ var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
773
+ var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
774
+ var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
775
+ var photoFieldType = {
776
+ type: PHOTO_TYPE,
777
+ label: "Photo",
778
+ category: "media",
779
+ value: { json: true },
780
+ operators: ["answered", "empty"],
781
+ a11y: { semantics: "group" },
782
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
783
+ validate: (value, field) => {
784
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
785
+ const limits = photoLimits(field);
786
+ if (value.length > limits.maxFiles) return photoCountMessage(field);
787
+ const refs = value;
788
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
789
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
790
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
791
+ return null;
792
+ },
793
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
794
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
795
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
796
+ };
797
+
680
798
  // src/fields.ts
681
799
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
682
800
  var globalScope = globalThis;
@@ -713,6 +831,20 @@ function summarizeValue(value, field) {
713
831
  if (Array.isArray(value)) return value.join(", ");
714
832
  return String(value);
715
833
  }
834
+ var OTHER_MAX_LENGTH = 200;
835
+ function otherOption(field) {
836
+ const other = field.props?.other;
837
+ if (other === true) return { label: "Other" };
838
+ if (other && typeof other === "object" && !Array.isArray(other)) {
839
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
840
+ return { label };
841
+ }
842
+ return null;
843
+ }
844
+ function isOtherValue(field, value) {
845
+ return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
846
+ }
847
+ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
716
848
  function choiceOptions(field) {
717
849
  const raw = field.props?.options;
718
850
  if (!Array.isArray(raw)) return [];
@@ -720,6 +852,42 @@ function choiceOptions(field) {
720
852
  (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
721
853
  );
722
854
  }
855
+ function optionsSource(field) {
856
+ const raw = field.props?.optionsFrom;
857
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
858
+ return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
859
+ }
860
+ function hostList(hostOptions, key) {
861
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
862
+ const list = hostOptions[key];
863
+ if (!Array.isArray(list)) return [];
864
+ const seen = /* @__PURE__ */ new Set();
865
+ const out = [];
866
+ for (const o of list) {
867
+ if (!o || typeof o !== "object" || Array.isArray(o)) continue;
868
+ const { value, label } = o;
869
+ if (typeof value !== "string" || !value || seen.has(value)) continue;
870
+ seen.add(value);
871
+ out.push(typeof label === "string" ? { value, label } : { value });
872
+ }
873
+ return out;
874
+ }
875
+ function withHostOptions(definition, hostOptions) {
876
+ let changed = false;
877
+ const blocks = definition.blocks.map((block) => {
878
+ if (block.kind !== "field") return block;
879
+ const from = optionsSource(block);
880
+ if (!from) return block;
881
+ const own2 = choiceOptions(block);
882
+ const taken = new Set(own2.map((o) => o.value));
883
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
884
+ if (!added.length) return block;
885
+ changed = true;
886
+ const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
887
+ return { ...block, props: { ...block.props, options } };
888
+ });
889
+ return changed ? { ...definition, blocks } : definition;
890
+ }
723
891
  function optionLabel(field, value) {
724
892
  const option = choiceOptions(field).find((o) => o.value === value);
725
893
  return option?.label ?? value;
@@ -838,7 +1006,7 @@ var builtins = [
838
1006
  value: { json: true },
839
1007
  operators: ["eq", "neq", "in", "answered", "empty"],
840
1008
  a11y: { semantics: "group" },
841
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1009
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
842
1010
  summarize: (value, field) => optionLabel(field, String(value))
843
1011
  },
844
1012
  {
@@ -850,7 +1018,8 @@ var builtins = [
850
1018
  a11y: { semantics: "group" },
851
1019
  validate: (value, field) => {
852
1020
  const allowed = new Set(choiceOptions(field).map((o) => o.value));
853
- return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
1021
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1022
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
854
1023
  },
855
1024
  summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
856
1025
  },
@@ -871,7 +1040,7 @@ var builtins = [
871
1040
  value: { json: true },
872
1041
  operators: ["eq", "neq", "in", "answered", "empty"],
873
1042
  a11y: { semantics: "control" },
874
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1043
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
875
1044
  summarize: (value, field) => optionLabel(field, String(value))
876
1045
  },
877
1046
  {
@@ -978,7 +1147,7 @@ function dateLimits(field) {
978
1147
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
979
1148
  return out;
980
1149
  }
981
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1150
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
982
1151
 
983
1152
  // src/events.ts
984
1153
  function isPlainObject(value) {
@@ -1137,8 +1306,8 @@ function rulesAllow(def, blockId, scope) {
1137
1306
  }
1138
1307
  function walkPath(def, scope) {
1139
1308
  const list = steps(def);
1140
- const index = new Map(list.map((b, i2) => [b.id, i2]));
1141
- const position = new Map(def.blocks.map((b, i2) => [b.id, i2]));
1309
+ let index;
1310
+ let position;
1142
1311
  const jumps = (def.logic ?? []).filter(
1143
1312
  (r) => r.effect === "jump"
1144
1313
  );
@@ -1162,6 +1331,7 @@ function walkPath(def, scope) {
1162
1331
  ending = jump.to.ending;
1163
1332
  break;
1164
1333
  }
1334
+ index ??= new Map(list.map((b, at) => [b.id, at]));
1165
1335
  const target = index.get(jump.to);
1166
1336
  if (target !== void 0 && target > i) {
1167
1337
  i = nextVisible(target);
@@ -1171,6 +1341,7 @@ function walkPath(def, scope) {
1171
1341
  i = nextVisible(i + 1);
1172
1342
  }
1173
1343
  const scopeAt = (blockId, inclusive = false) => {
1344
+ position ??= new Map(def.blocks.map((b, i2) => [b.id, i2]));
1174
1345
  const at = position.get(blockId) ?? -1;
1175
1346
  const values = /* @__PURE__ */ Object.create(null);
1176
1347
  for (const id of path) {
@@ -1233,10 +1404,20 @@ function validateField(field, value, options = {}) {
1233
1404
  }
1234
1405
 
1235
1406
  // src/engine.ts
1407
+ function readsAny(node, ids) {
1408
+ if (Array.isArray(node)) return node.some((n) => readsAny(n, ids));
1409
+ if (!node || typeof node !== "object") return false;
1410
+ for (const [k, v] of Object.entries(node)) {
1411
+ if (k === "field" && typeof v === "string" && ids.has(v)) return true;
1412
+ if (typeof v === "object" && readsAny(v, ids)) return true;
1413
+ }
1414
+ return false;
1415
+ }
1236
1416
  function createForm(definition, options = {}) {
1237
1417
  return build(definition, options).engine;
1238
1418
  }
1239
- function build(definition, options, replaying = false) {
1419
+ function build(given, options, replaying = false, budget) {
1420
+ const definition = withHostOptions(given, options.hostOptions);
1240
1421
  const fields = fieldMap(definition);
1241
1422
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1242
1423
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1254,15 +1435,24 @@ function build(definition, options, replaying = false) {
1254
1435
  if (!declared.has(name)) initialHidden[name] = value;
1255
1436
  }
1256
1437
  Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
1257
- const calc = calculator(definition);
1438
+ const calc = calculator(definition, budget);
1439
+ const pathOf = (values, hidden) => {
1440
+ spend(budget, definition.blocks.length);
1441
+ return computePath(definition, scopeOf(values, hidden));
1442
+ };
1443
+ const logicReadsCalculated = calc.active && readsAny(definition.logic, calc.ids);
1258
1444
  const settle = (values, hidden) => {
1259
- let { path } = computePath(definition, scopeOf(values, hidden));
1445
+ let { path } = pathOf(values, hidden);
1260
1446
  if (!calc.active) return { values, path };
1447
+ if (!logicReadsCalculated) return { values: calc.withCalculated(values, path), path };
1261
1448
  for (let round2 = 0; round2 < 4; round2++) {
1262
1449
  const next = calc.withCalculated(values, path);
1263
1450
  if (next === values) break;
1264
1451
  values = next;
1265
- path = computePath(definition, scopeOf(values, hidden)).path;
1452
+ const again = pathOf(values, hidden).path;
1453
+ const same = again.length === path.length && again.every((id, i) => id === path[i]);
1454
+ path = again;
1455
+ if (same) break;
1266
1456
  }
1267
1457
  return { values, path };
1268
1458
  };
@@ -1273,6 +1463,7 @@ function build(definition, options, replaying = false) {
1273
1463
  path: first.path,
1274
1464
  current: first.path[0] ?? null,
1275
1465
  history: [],
1466
+ furthest: first.path[0] ?? null,
1276
1467
  errors: {},
1277
1468
  status: "in_progress",
1278
1469
  ending: null,
@@ -1304,14 +1495,30 @@ function build(definition, options, replaying = false) {
1304
1495
  return errors;
1305
1496
  };
1306
1497
  let unsettled = false;
1307
- const settleNow = () => {
1308
- if (!unsettled) return;
1309
- unsettled = false;
1310
- const { values, path } = settle(state.values, state.hidden);
1311
- state = { ...state, values, path };
1498
+ let formulasStale = false;
1499
+ const settleNow = (needValues = true) => {
1500
+ if (unsettled && calc.active && !logicReadsCalculated && !needValues) {
1501
+ unsettled = false;
1502
+ formulasStale = true;
1503
+ state = { ...state, path: pathOf(state.values, state.hidden).path };
1504
+ return;
1505
+ }
1506
+ if (unsettled) {
1507
+ unsettled = false;
1508
+ formulasStale = false;
1509
+ const { values, path } = settle(state.values, state.hidden);
1510
+ state = { ...state, values, path };
1511
+ } else if (formulasStale && needValues) {
1512
+ formulasStale = false;
1513
+ state = { ...state, values: calc.withCalculated(state.values, state.path) };
1514
+ }
1312
1515
  };
1313
1516
  const answer = (id, value, event) => {
1314
- if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
1517
+ if (state.status === "completed" || isCalculated(fields.get(id))) return;
1518
+ if (own(state.values, id) === value) {
1519
+ if (value === null || typeof value !== "object") return;
1520
+ calc.forget(value);
1521
+ }
1315
1522
  const errors = { ...state.errors };
1316
1523
  delete errors[id];
1317
1524
  if (batch) {
@@ -1357,7 +1564,8 @@ function build(definition, options, replaying = false) {
1357
1564
  emit({ ...state, events });
1358
1565
  return true;
1359
1566
  }
1360
- emit({ ...state, current: target, history: [...state.history, state.current], events });
1567
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
1568
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
1361
1569
  return true;
1362
1570
  },
1363
1571
  back() {
@@ -1377,7 +1585,7 @@ function build(definition, options, replaying = false) {
1377
1585
  emit({ ...state, errors, current: firstError, events });
1378
1586
  return false;
1379
1587
  }
1380
- const { ending } = computePath(definition, scopeOf(state.values, state.hidden));
1588
+ const { ending } = pathOf(state.values, state.hidden);
1381
1589
  emit({ ...state, errors: {}, status: "completed", ending, events });
1382
1590
  return true;
1383
1591
  },
@@ -1402,6 +1610,15 @@ function build(definition, options, replaying = false) {
1402
1610
  settleNow();
1403
1611
  batch = wasBatch;
1404
1612
  }
1613
+ const resumeAt = options.resumeAt;
1614
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
1615
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
1616
+ if (!engine.next()) {
1617
+ state = { ...state, errors: {} };
1618
+ break;
1619
+ }
1620
+ }
1621
+ }
1405
1622
  const apply = (event) => {
1406
1623
  if (event.type === "answer") {
1407
1624
  if (fields.has(event.field)) {
@@ -1409,10 +1626,11 @@ function build(definition, options, replaying = false) {
1409
1626
  answer(event.field, event.value, logged);
1410
1627
  }
1411
1628
  } else if (event.type === "next") {
1412
- settleNow();
1629
+ settleNow(false);
1630
+ if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1413
1631
  engine.next();
1414
1632
  } else if (event.type === "back") {
1415
- settleNow();
1633
+ settleNow(false);
1416
1634
  engine.back();
1417
1635
  } else if (event.type === "submit") {
1418
1636
  settleNow();
@@ -1426,10 +1644,16 @@ function build(definition, options, replaying = false) {
1426
1644
  };
1427
1645
  return { engine, apply, finish };
1428
1646
  }
1429
- function replay(definition, events, options = {}) {
1430
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true);
1431
- for (const event of events) apply(event);
1432
- finish();
1647
+ function replayWithin(definition, events, options, maxWork) {
1648
+ const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1649
+ const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1650
+ try {
1651
+ for (const event of events) apply(event);
1652
+ finish();
1653
+ } finally {
1654
+ if (budget) budget.used;
1655
+ }
1656
+ if (budget) budget.max = Infinity;
1433
1657
  return engine;
1434
1658
  }
1435
1659
 
@@ -1582,6 +1806,22 @@ function parseForm(input) {
1582
1806
  }
1583
1807
  }
1584
1808
  if (type === "consent") optionalText(props.text, `${path}.text`);
1809
+ if (type === PHOTO_TYPE) {
1810
+ if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
1811
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1812
+ }
1813
+ if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1814
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1815
+ }
1816
+ if (props.formats !== void 0) {
1817
+ const formats = props.formats;
1818
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1819
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
1820
+ } else if (new Set(formats).size !== formats.length) {
1821
+ issue(`${path}.formats`, "Name each format once.");
1822
+ }
1823
+ }
1824
+ }
1585
1825
  };
1586
1826
  if (!isObject2(input)) {
1587
1827
  issue("", "A form definition must be an object.");
@@ -1602,6 +1842,7 @@ function parseForm(input) {
1602
1842
  else {
1603
1843
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1604
1844
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
1845
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1605
1846
  }
1606
1847
  }
1607
1848
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1642,6 +1883,14 @@ function parseForm(input) {
1642
1883
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1643
1884
  }
1644
1885
  if (block.props !== void 0) {
1886
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
1887
+ if (from !== void 0) {
1888
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
1889
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
1890
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1891
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
1892
+ }
1893
+ }
1645
1894
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1646
1895
  else if (block.props.options !== void 0) {
1647
1896
  const options = block.props.options;
@@ -1652,10 +1901,20 @@ function parseForm(input) {
1652
1901
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1653
1902
  else optionalText(o.label, `${at}.label`);
1654
1903
  });
1655
- choices.set(
1656
- block.id,
1657
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1658
- );
1904
+ if (from === void 0) {
1905
+ choices.set(
1906
+ block.id,
1907
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1908
+ );
1909
+ }
1910
+ }
1911
+ }
1912
+ const other = isObject2(block.props) ? block.props.other : void 0;
1913
+ if (other !== void 0) {
1914
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
1915
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
1916
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1917
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1659
1918
  }
1660
1919
  }
1661
1920
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1730,6 +1989,8 @@ function parseForm(input) {
1730
1989
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1731
1990
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1732
1991
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
1992
+ } else if (targetType === PHOTO_TYPE) {
1993
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1733
1994
  }
1734
1995
  }
1735
1996
  });
@@ -1933,6 +2194,41 @@ function parseForm(input) {
1933
2194
 
1934
2195
  // src/server.ts
1935
2196
  var MAX_EVENTS = 1e4;
2197
+ var MAX_SUBMISSION_VALUES = 2e5;
2198
+ var MAX_REPLAY_WORK = 1e6;
2199
+ var RAW_EVENTS_PER_EVENT = 10;
2200
+ function countValues(value, limit) {
2201
+ let count = 0;
2202
+ const stack = [value];
2203
+ while (stack.length && count <= limit) {
2204
+ const v = stack.pop();
2205
+ if (v === void 0) continue;
2206
+ count++;
2207
+ if (Array.isArray(v)) for (const item of v) stack.push(item);
2208
+ else if (v !== null && typeof v === "object") for (const k in v) stack.push(v[k]);
2209
+ }
2210
+ return count;
2211
+ }
2212
+ var answerTo = (e) => isPlainObject(e) && e.type === "answer" && typeof e.field === "string" ? e.field : void 0;
2213
+ function leanEvents(events) {
2214
+ const out = [];
2215
+ for (let i = 0; i < events.length; i++) {
2216
+ const field = answerTo(events[i]);
2217
+ if (field !== void 0 && answerTo(events[i + 1]) === field) continue;
2218
+ out.push(events[i]);
2219
+ }
2220
+ return out;
2221
+ }
2222
+ function submissionValues(input, limit) {
2223
+ let count = countValues(input.hidden, limit);
2224
+ if (Array.isArray(input.events)) {
2225
+ for (const e of input.events) {
2226
+ if (count > limit) break;
2227
+ if (answerTo(e) !== void 0) count += countValues(e.value, limit - count);
2228
+ }
2229
+ } else count += countValues(input.answers, limit - count);
2230
+ return count;
2231
+ }
1936
2232
  var MALFORMED = { ok: false, errors: { "": "Malformed submission." } };
1937
2233
  var parsedDefinitions = /* @__PURE__ */ new WeakMap();
1938
2234
  function checkDefinition(definition) {
@@ -1959,10 +2255,28 @@ function wellFormed(input) {
1959
2255
  }
1960
2256
  return { answers, events: known, hidden };
1961
2257
  }
1962
- function validateSubmission(definition, submission, options = {}) {
1963
- const invalid = checkDefinition(definition);
2258
+ function validateSubmission(published, submission, options = {}) {
2259
+ const invalid = checkDefinition(published);
1964
2260
  if (invalid) return { ok: false, errors: { "": invalid } };
1965
- const input = wellFormed(submission);
2261
+ const definition = withHostOptions(published, options.hostOptions);
2262
+ if (!isPlainObject(submission)) return MALFORMED;
2263
+ const maxEvents = options.maxEvents ?? MAX_EVENTS;
2264
+ let sent = submission;
2265
+ if (Array.isArray(submission.events)) {
2266
+ const maxRaw = maxEvents * RAW_EVENTS_PER_EVENT;
2267
+ if (submission.events.length > maxRaw) {
2268
+ return { ok: false, errors: { "": `Too many events: ${submission.events.length} sent; the limit is ${maxRaw} before compaction.` } };
2269
+ }
2270
+ sent = { ...submission, events: leanEvents(submission.events) };
2271
+ }
2272
+ const maxValues = options.maxValues ?? MAX_SUBMISSION_VALUES;
2273
+ if (submissionValues(sent, maxValues) > maxValues) {
2274
+ return {
2275
+ ok: false,
2276
+ errors: { "": `Too much data: the answers hold more than ${maxValues} values (items, rows and cells); the limit is ${maxValues}.` }
2277
+ };
2278
+ }
2279
+ const input = wellFormed(sent);
1966
2280
  if (!input) return MALFORMED;
1967
2281
  const ignored = /* @__PURE__ */ new Set();
1968
2282
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
@@ -1981,12 +2295,20 @@ function validateSubmission(definition, submission, options = {}) {
1981
2295
  let engine;
1982
2296
  if (input.events) {
1983
2297
  const events = compactEvents(input.events);
1984
- const max = options.maxEvents ?? MAX_EVENTS;
1985
- if (events.length > max) {
1986
- return { ok: false, errors: { "": `Too many events: ${events.length} after compaction; the limit is ${max}.` } };
2298
+ if (events.length > maxEvents) {
2299
+ return { ok: false, errors: { "": `Too many events: ${events.length} after compaction; the limit is ${maxEvents}.` } };
1987
2300
  }
1988
2301
  const kept = events.filter((e) => e.type !== "answer" || accept(e.field) !== false);
1989
- engine = replay(definition, kept, { hidden: engineHidden, validators: options.validators });
2302
+ const maxWork = options.maxReplayWork ?? MAX_REPLAY_WORK;
2303
+ try {
2304
+ engine = replayWithin(definition, kept, { hidden: engineHidden, validators: options.validators }, maxWork);
2305
+ } catch (e) {
2306
+ if (!(e instanceof ReplayWorkExceeded)) throw e;
2307
+ return {
2308
+ ok: false,
2309
+ errors: { "": `Too much work: replaying these events (the path and calculated fields) would take more than ${maxWork} steps; the limit is ${maxWork}.` }
2310
+ };
2311
+ }
1990
2312
  } else {
1991
2313
  const answers2 = {};
1992
2314
  for (const id of Object.keys(input.answers ?? {})) {
@@ -2054,6 +2376,9 @@ function publicDefinition(definition) {
2054
2376
  }
2055
2377
 
2056
2378
  exports.MAX_EVENTS = MAX_EVENTS;
2379
+ exports.MAX_REPLAY_WORK = MAX_REPLAY_WORK;
2380
+ exports.MAX_SUBMISSION_VALUES = MAX_SUBMISSION_VALUES;
2381
+ exports.RAW_EVENTS_PER_EVENT = RAW_EVENTS_PER_EVENT;
2057
2382
  exports.publicDefinition = publicDefinition;
2058
2383
  exports.validateSubmission = validateSubmission;
2059
2384
  //# sourceMappingURL=server.cjs.map