@marktiderman/genesis-forms 0.4.1 → 0.4.2

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/testing.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 same2 = 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 (same2 && entry.last.inputs[i] !== v) same2 = 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 (same2) {
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 = {
@@ -1135,8 +1179,8 @@ function rulesAllow(def, blockId, scope) {
1135
1179
  }
1136
1180
  function walkPath(def, scope) {
1137
1181
  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]));
1182
+ let index;
1183
+ let position;
1140
1184
  const jumps = (def.logic ?? []).filter(
1141
1185
  (r) => r.effect === "jump"
1142
1186
  );
@@ -1160,6 +1204,7 @@ function walkPath(def, scope) {
1160
1204
  ending = jump.to.ending;
1161
1205
  break;
1162
1206
  }
1207
+ index ??= new Map(list.map((b, at) => [b.id, at]));
1163
1208
  const target = index.get(jump.to);
1164
1209
  if (target !== void 0 && target > i) {
1165
1210
  i = nextVisible(target);
@@ -1169,6 +1214,7 @@ function walkPath(def, scope) {
1169
1214
  i = nextVisible(i + 1);
1170
1215
  }
1171
1216
  const scopeAt = (blockId, inclusive = false) => {
1217
+ position ??= new Map(def.blocks.map((b, i2) => [b.id, i2]));
1172
1218
  const at = position.get(blockId) ?? -1;
1173
1219
  const values = /* @__PURE__ */ Object.create(null);
1174
1220
  for (const id of path) {
@@ -1231,10 +1277,19 @@ function validateField(field, value, options = {}) {
1231
1277
  }
1232
1278
 
1233
1279
  // src/engine.ts
1280
+ function readsAny(node, ids) {
1281
+ if (Array.isArray(node)) return node.some((n) => readsAny(n, ids));
1282
+ if (!node || typeof node !== "object") return false;
1283
+ for (const [k, v] of Object.entries(node)) {
1284
+ if (k === "field" && typeof v === "string" && ids.has(v)) return true;
1285
+ if (typeof v === "object" && readsAny(v, ids)) return true;
1286
+ }
1287
+ return false;
1288
+ }
1234
1289
  function createForm(definition, options = {}) {
1235
1290
  return build(definition, options).engine;
1236
1291
  }
1237
- function build(definition, options, replaying = false) {
1292
+ function build(definition, options, replaying = false, budget) {
1238
1293
  const fields = fieldMap(definition);
1239
1294
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1240
1295
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1252,15 +1307,24 @@ function build(definition, options, replaying = false) {
1252
1307
  if (!declared.has(name)) initialHidden[name] = value;
1253
1308
  }
1254
1309
  Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
1255
- const calc = calculator(definition);
1310
+ const calc = calculator(definition, budget);
1311
+ const pathOf = (values, hidden) => {
1312
+ spend(budget, definition.blocks.length);
1313
+ return computePath(definition, scopeOf(values, hidden));
1314
+ };
1315
+ const logicReadsCalculated = calc.active && readsAny(definition.logic, calc.ids);
1256
1316
  const settle = (values, hidden) => {
1257
- let { path } = computePath(definition, scopeOf(values, hidden));
1317
+ let { path } = pathOf(values, hidden);
1258
1318
  if (!calc.active) return { values, path };
1319
+ if (!logicReadsCalculated) return { values: calc.withCalculated(values, path), path };
1259
1320
  for (let round2 = 0; round2 < 4; round2++) {
1260
1321
  const next = calc.withCalculated(values, path);
1261
1322
  if (next === values) break;
1262
1323
  values = next;
1263
- path = computePath(definition, scopeOf(values, hidden)).path;
1324
+ const again = pathOf(values, hidden).path;
1325
+ const same2 = again.length === path.length && again.every((id, i) => id === path[i]);
1326
+ path = again;
1327
+ if (same2) break;
1264
1328
  }
1265
1329
  return { values, path };
1266
1330
  };
@@ -1302,14 +1366,30 @@ function build(definition, options, replaying = false) {
1302
1366
  return errors;
1303
1367
  };
1304
1368
  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 };
1369
+ let formulasStale = false;
1370
+ const settleNow = (needValues = true) => {
1371
+ if (unsettled && calc.active && !logicReadsCalculated && !needValues) {
1372
+ unsettled = false;
1373
+ formulasStale = true;
1374
+ state = { ...state, path: pathOf(state.values, state.hidden).path };
1375
+ return;
1376
+ }
1377
+ if (unsettled) {
1378
+ unsettled = false;
1379
+ formulasStale = false;
1380
+ const { values, path } = settle(state.values, state.hidden);
1381
+ state = { ...state, values, path };
1382
+ } else if (formulasStale && needValues) {
1383
+ formulasStale = false;
1384
+ state = { ...state, values: calc.withCalculated(state.values, state.path) };
1385
+ }
1310
1386
  };
1311
1387
  const answer = (id, value, event) => {
1312
- if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
1388
+ if (state.status === "completed" || isCalculated(fields.get(id))) return;
1389
+ if (own(state.values, id) === value) {
1390
+ if (value === null || typeof value !== "object") return;
1391
+ calc.forget(value);
1392
+ }
1313
1393
  const errors = { ...state.errors };
1314
1394
  delete errors[id];
1315
1395
  if (batch) {
@@ -1375,7 +1455,7 @@ function build(definition, options, replaying = false) {
1375
1455
  emit({ ...state, errors, current: firstError, events });
1376
1456
  return false;
1377
1457
  }
1378
- const { ending } = computePath(definition, scopeOf(state.values, state.hidden));
1458
+ const { ending } = pathOf(state.values, state.hidden);
1379
1459
  emit({ ...state, errors: {}, status: "completed", ending, events });
1380
1460
  return true;
1381
1461
  },
@@ -1407,10 +1487,11 @@ function build(definition, options, replaying = false) {
1407
1487
  answer(event.field, event.value, logged);
1408
1488
  }
1409
1489
  } else if (event.type === "next") {
1410
- settleNow();
1490
+ settleNow(false);
1491
+ if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1411
1492
  engine.next();
1412
1493
  } else if (event.type === "back") {
1413
- settleNow();
1494
+ settleNow(false);
1414
1495
  engine.back();
1415
1496
  } else if (event.type === "submit") {
1416
1497
  settleNow();
@@ -1425,9 +1506,18 @@ function build(definition, options, replaying = false) {
1425
1506
  return { engine, apply, finish };
1426
1507
  }
1427
1508
  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();
1509
+ return replayWithin(definition, events, options);
1510
+ }
1511
+ function replayWithin(definition, events, options, maxWork) {
1512
+ const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1513
+ const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1514
+ try {
1515
+ for (const event of events) apply(event);
1516
+ finish();
1517
+ } finally {
1518
+ if (budget) budget.used;
1519
+ }
1520
+ if (budget) budget.max = Infinity;
1431
1521
  return engine;
1432
1522
  }
1433
1523
 
@@ -2110,6 +2200,41 @@ function parseForm(input) {
2110
2200
 
2111
2201
  // src/server.ts
2112
2202
  var MAX_EVENTS = 1e4;
2203
+ var MAX_SUBMISSION_VALUES = 2e5;
2204
+ var MAX_REPLAY_WORK = 1e6;
2205
+ var RAW_EVENTS_PER_EVENT = 10;
2206
+ function countValues(value, limit) {
2207
+ let count = 0;
2208
+ const stack = [value];
2209
+ while (stack.length && count <= limit) {
2210
+ const v = stack.pop();
2211
+ if (v === void 0) continue;
2212
+ count++;
2213
+ if (Array.isArray(v)) for (const item of v) stack.push(item);
2214
+ else if (v !== null && typeof v === "object") for (const k in v) stack.push(v[k]);
2215
+ }
2216
+ return count;
2217
+ }
2218
+ var answerTo = (e) => isPlainObject(e) && e.type === "answer" && typeof e.field === "string" ? e.field : void 0;
2219
+ function leanEvents(events) {
2220
+ const out = [];
2221
+ for (let i = 0; i < events.length; i++) {
2222
+ const field = answerTo(events[i]);
2223
+ if (field !== void 0 && answerTo(events[i + 1]) === field) continue;
2224
+ out.push(events[i]);
2225
+ }
2226
+ return out;
2227
+ }
2228
+ function submissionValues(input, limit) {
2229
+ let count = countValues(input.hidden, limit);
2230
+ if (Array.isArray(input.events)) {
2231
+ for (const e of input.events) {
2232
+ if (count > limit) break;
2233
+ if (answerTo(e) !== void 0) count += countValues(e.value, limit - count);
2234
+ }
2235
+ } else count += countValues(input.answers, limit - count);
2236
+ return count;
2237
+ }
2113
2238
  var MALFORMED = { ok: false, errors: { "": "Malformed submission." } };
2114
2239
  var parsedDefinitions = /* @__PURE__ */ new WeakMap();
2115
2240
  function checkDefinition(definition) {
@@ -2139,7 +2264,24 @@ function wellFormed(input) {
2139
2264
  function validateSubmission(definition, submission, options = {}) {
2140
2265
  const invalid = checkDefinition(definition);
2141
2266
  if (invalid) return { ok: false, errors: { "": invalid } };
2142
- const input = wellFormed(submission);
2267
+ if (!isPlainObject(submission)) return MALFORMED;
2268
+ const maxEvents = options.maxEvents ?? MAX_EVENTS;
2269
+ let sent = submission;
2270
+ if (Array.isArray(submission.events)) {
2271
+ const maxRaw = maxEvents * RAW_EVENTS_PER_EVENT;
2272
+ if (submission.events.length > maxRaw) {
2273
+ return { ok: false, errors: { "": `Too many events: ${submission.events.length} sent; the limit is ${maxRaw} before compaction.` } };
2274
+ }
2275
+ sent = { ...submission, events: leanEvents(submission.events) };
2276
+ }
2277
+ const maxValues = options.maxValues ?? MAX_SUBMISSION_VALUES;
2278
+ if (submissionValues(sent, maxValues) > maxValues) {
2279
+ return {
2280
+ ok: false,
2281
+ errors: { "": `Too much data: the answers hold more than ${maxValues} values (items, rows and cells); the limit is ${maxValues}.` }
2282
+ };
2283
+ }
2284
+ const input = wellFormed(sent);
2143
2285
  if (!input) return MALFORMED;
2144
2286
  const ignored = /* @__PURE__ */ new Set();
2145
2287
  const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
@@ -2158,12 +2300,20 @@ function validateSubmission(definition, submission, options = {}) {
2158
2300
  let engine;
2159
2301
  if (input.events) {
2160
2302
  const events = compactEvents(input.events);
2161
- const max = options.maxEvents ?? MAX_EVENTS;
2162
- if (events.length > max) {
2163
- return { ok: false, errors: { "": `Too many events: ${events.length} after compaction; the limit is ${max}.` } };
2303
+ if (events.length > maxEvents) {
2304
+ return { ok: false, errors: { "": `Too many events: ${events.length} after compaction; the limit is ${maxEvents}.` } };
2164
2305
  }
2165
2306
  const kept = events.filter((e) => e.type !== "answer" || accept(e.field) !== false);
2166
- engine = replay(definition, kept, { hidden: engineHidden, validators: options.validators });
2307
+ const maxWork = options.maxReplayWork ?? MAX_REPLAY_WORK;
2308
+ try {
2309
+ engine = replayWithin(definition, kept, { hidden: engineHidden, validators: options.validators }, maxWork);
2310
+ } catch (e) {
2311
+ if (!(e instanceof ReplayWorkExceeded)) throw e;
2312
+ return {
2313
+ ok: false,
2314
+ errors: { "": `Too much work: replaying these events (the path and calculated fields) would take more than ${maxWork} steps; the limit is ${maxWork}.` }
2315
+ };
2316
+ }
2167
2317
  } else {
2168
2318
  const answers2 = {};
2169
2319
  for (const id of Object.keys(input.answers ?? {})) {