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