@10x-media/form-builder 0.1.0-beta.11 → 0.1.0-beta.12

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.
Files changed (151) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/aggregation/resolveResultsRequest.d.ts +6 -0
  3. package/dist/aggregation/resolveResultsRequest.js +32 -3
  4. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  5. package/dist/calc/computeCalcFields.d.ts +5 -3
  6. package/dist/calc/computeCalcFields.js +4 -3
  7. package/dist/calc/computeCalcFields.js.map +1 -1
  8. package/dist/calc/evaluate.d.ts +26 -3
  9. package/dist/calc/evaluate.js +33 -11
  10. package/dist/calc/evaluate.js.map +1 -1
  11. package/dist/calc/formatCalc.d.ts +11 -0
  12. package/dist/calc/formatCalc.js +35 -0
  13. package/dist/calc/formatCalc.js.map +1 -0
  14. package/dist/calc/formatCalcValue.d.ts +17 -0
  15. package/dist/calc/formatCalcValue.js +17 -0
  16. package/dist/calc/formatCalcValue.js.map +1 -0
  17. package/dist/calc/normalizeCalc.d.ts +17 -3
  18. package/dist/calc/normalizeCalc.js +39 -17
  19. package/dist/calc/normalizeCalc.js.map +1 -1
  20. package/dist/calc/registry.d.ts +43 -0
  21. package/dist/calc/registry.js +27 -0
  22. package/dist/calc/registry.js.map +1 -0
  23. package/dist/calc/resolveCalcContext.d.ts +36 -0
  24. package/dist/calc/resolveCalcContext.js +87 -0
  25. package/dist/calc/resolveCalcContext.js.map +1 -0
  26. package/dist/calc/types.d.ts +18 -3
  27. package/dist/calc/types.js +2 -1
  28. package/dist/calc/types.js.map +1 -1
  29. package/dist/client/CalcExpressionBuilder.d.ts +20 -0
  30. package/dist/client/CalcExpressionBuilder.js +890 -0
  31. package/dist/client/CalcExpressionBuilder.js.map +1 -0
  32. package/dist/client/admin.css +282 -0
  33. package/dist/collections/formSubmissions.js +7 -9
  34. package/dist/collections/formSubmissions.js.map +1 -1
  35. package/dist/collections/forms.js +59 -34
  36. package/dist/collections/forms.js.map +1 -1
  37. package/dist/collections/formsEndpoints.js +4 -2
  38. package/dist/collections/formsEndpoints.js.map +1 -1
  39. package/dist/collections/settingsFields.d.ts +31 -0
  40. package/dist/collections/settingsFields.js +45 -0
  41. package/dist/collections/settingsFields.js.map +1 -0
  42. package/dist/exports/client.d.ts +2 -1
  43. package/dist/exports/client.js +2 -1
  44. package/dist/exports/react.d.ts +7 -3
  45. package/dist/exports/react.js +6 -2
  46. package/dist/exports/rsc.js +1 -1
  47. package/dist/exports/types.d.ts +1 -1
  48. package/dist/fields/buildFieldBlocks.js +2 -1
  49. package/dist/fields/buildFieldBlocks.js.map +1 -1
  50. package/dist/fields/builtin/calculation.d.ts +17 -0
  51. package/dist/fields/builtin/calculation.js +170 -115
  52. package/dist/fields/builtin/calculation.js.map +1 -1
  53. package/dist/fields/builtin/checkbox.js +15 -0
  54. package/dist/fields/builtin/checkbox.js.map +1 -1
  55. package/dist/fields/builtin/email.js +7 -0
  56. package/dist/fields/builtin/email.js.map +1 -1
  57. package/dist/fields/builtin/index.d.ts +6 -2
  58. package/dist/fields/builtin/index.js +9 -4
  59. package/dist/fields/builtin/index.js.map +1 -1
  60. package/dist/fields/builtin/select.js +20 -1
  61. package/dist/fields/builtin/select.js.map +1 -1
  62. package/dist/fields/builtin/text.js +7 -0
  63. package/dist/fields/builtin/text.js.map +1 -1
  64. package/dist/fields/instanceOptions.js +1 -1
  65. package/dist/fields/instanceOptions.js.map +1 -1
  66. package/dist/fields/sharedConfig.js +15 -11
  67. package/dist/fields/sharedConfig.js.map +1 -1
  68. package/dist/fields/types.d.ts +8 -2
  69. package/dist/form/toFormDocument.d.ts +7 -0
  70. package/dist/form/toFormDocument.js +2 -1
  71. package/dist/form/toFormDocument.js.map +1 -1
  72. package/dist/form/types.d.ts +7 -0
  73. package/dist/index.d.ts +12 -4
  74. package/dist/index.js +35 -8
  75. package/dist/index.js.map +1 -1
  76. package/dist/options.d.ts +35 -0
  77. package/dist/plugin/registerCollections.js +29 -19
  78. package/dist/plugin/registerCollections.js.map +1 -1
  79. package/dist/poll/closeJob.d.ts +4 -3
  80. package/dist/poll/closeJob.js +6 -4
  81. package/dist/poll/closeJob.js.map +1 -1
  82. package/dist/poll/pollTypeRegistry.js +1 -1
  83. package/dist/poll/resolvePollCloseRequest.js +2 -1
  84. package/dist/poll/resolvePollCloseRequest.js.map +1 -1
  85. package/dist/poll/resolvePollOutcome.d.ts +6 -0
  86. package/dist/poll/resolvePollOutcome.js +17 -3
  87. package/dist/poll/resolvePollOutcome.js.map +1 -1
  88. package/dist/poll/votes/aggregateFromVotes.d.ts +27 -0
  89. package/dist/poll/votes/aggregateFromVotes.js +53 -0
  90. package/dist/poll/votes/aggregateFromVotes.js.map +1 -0
  91. package/dist/poll/votes/bumpPollVote.js +72 -0
  92. package/dist/poll/votes/bumpPollVote.js.map +1 -0
  93. package/dist/poll/votes/recountPollVotes.d.ts +20 -0
  94. package/dist/poll/votes/recountPollVotes.js +63 -0
  95. package/dist/poll/votes/recountPollVotes.js.map +1 -0
  96. package/dist/poll/votes/voteTallyHook.js +58 -0
  97. package/dist/poll/votes/voteTallyHook.js.map +1 -0
  98. package/dist/poll/votes/votesCollection.d.ts +19 -0
  99. package/dist/poll/votes/votesCollection.js +89 -0
  100. package/dist/poll/votes/votesCollection.js.map +1 -0
  101. package/dist/react/Form.d.ts +7 -0
  102. package/dist/react/Form.js +20 -3
  103. package/dist/react/Form.js.map +1 -1
  104. package/dist/react/FormContext.d.ts +8 -1
  105. package/dist/react/FormContext.js.map +1 -1
  106. package/dist/react/primitives/Checkbox.d.ts +4 -2
  107. package/dist/react/primitives/Checkbox.js +3 -2
  108. package/dist/react/primitives/Checkbox.js.map +1 -1
  109. package/dist/react/primitives/ChoiceGroup.d.ts +40 -0
  110. package/dist/react/primitives/ChoiceGroup.js +33 -0
  111. package/dist/react/primitives/ChoiceGroup.js.map +1 -0
  112. package/dist/react/primitives/FieldShell.d.ts +11 -1
  113. package/dist/react/primitives/FieldShell.js +43 -33
  114. package/dist/react/primitives/FieldShell.js.map +1 -1
  115. package/dist/react/primitives/Input.d.ts +3 -1
  116. package/dist/react/primitives/Input.js +2 -1
  117. package/dist/react/primitives/Input.js.map +1 -1
  118. package/dist/react/renderers/calculation.js +6 -1
  119. package/dist/react/renderers/calculation.js.map +1 -1
  120. package/dist/react/renderers/checkbox.js +2 -1
  121. package/dist/react/renderers/checkbox.js.map +1 -1
  122. package/dist/react/renderers/email.js +3 -1
  123. package/dist/react/renderers/email.js.map +1 -1
  124. package/dist/react/renderers/select.js +20 -2
  125. package/dist/react/renderers/select.js.map +1 -1
  126. package/dist/react/renderers/text.js +3 -1
  127. package/dist/react/renderers/text.js.map +1 -1
  128. package/dist/react/useCalcValues.d.ts +10 -0
  129. package/dist/react/useCalcValues.js +13 -0
  130. package/dist/react/useCalcValues.js.map +1 -0
  131. package/dist/recall/resolver.js +1 -1
  132. package/dist/recall/resolver.js.map +1 -1
  133. package/dist/submissions/formIdOf.js +13 -0
  134. package/dist/submissions/formIdOf.js.map +1 -0
  135. package/dist/submissions/runSubmission.js +3 -3
  136. package/dist/submissions/runSubmission.js.map +1 -1
  137. package/dist/submissions/validateSubmission.js +26 -2
  138. package/dist/submissions/validateSubmission.js.map +1 -1
  139. package/dist/translations/de.js +58 -3
  140. package/dist/translations/de.js.map +1 -1
  141. package/dist/translations/en.js +58 -3
  142. package/dist/translations/en.js.map +1 -1
  143. package/dist/translations/keys.d.ts +56 -1
  144. package/dist/translations/keys.js +56 -1
  145. package/dist/translations/keys.js.map +1 -1
  146. package/dist/validation/builtin/index.js +2 -0
  147. package/dist/validation/builtin/index.js.map +1 -1
  148. package/dist/validation/builtin/integer.js +15 -0
  149. package/dist/validation/builtin/integer.js.map +1 -0
  150. package/package.json +10 -4
  151. package/styles/form-builder.css +84 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.12
4
+
5
+ ### Minor Changes
6
+
7
+ - Authoring UX round: a visual calculation builder, display variants, an optional-by-default field config surface, and a poll vote tally store.
8
+
9
+ - **Visual calculation builder.** The calculation field's expression is authored in a native left-to-right chain editor: start with a value (Field, Number, Weighted field, Function, or a registered source), add operation steps, optionally finish with a function, with a live readable preview and honest remove affordances throughout. The stored expression tree is unchanged and any API-written expression loads as the equivalent chain; read-only and error states are wired, and field pickers only offer numeric fields (calculations restricted to earlier ones, matching evaluation order).
10
+ - **Calculation extensibility.** A new `calc` option opens the expression engine to host data: `calc.sources` registers named value resolvers (`resolve` returns one number per render/submission, e.g. a tax rate or member discount; `resolveWeights` returns per-option numbers for a chosen field, e.g. each selectable product's live price) that resolve server-side, ride the form document for the client preview, and re-resolve authoritatively at submit; `calc.functions` registers custom calculation functions (client previews additionally accept the same map via `<Form calcFunctions>`). The calculation field also gains display formatting (`decimals`, localized `prefix`/`suffix`) applied in the renderer, admin answers, and email output. New utilities: `defineCalcSource`, `defineCalcFunction`, `resolveCalcContext`, `formatCalc`, `formatCalcValue`, and a `useCalcValues()` hook for composed frontends reading live computed values.
11
+ - **Display variants.** Select fields gain an optional `display` setting (`dropdown` default, `radio`, `buttons`) rendered as an accessible radio group with the invalid-state focus routing intact; checkbox fields gain `checkbox` or `switch`. The `ChoiceGroup` primitive is exported for custom renderers.
12
+ - **Optional field settings.** Field width and select option labels are no longer required: width defaults to full (the renderer already falls back), and a missing or blank option label falls back to the value everywhere (renderer, admin answers, aggregation, recall). Width option labels are localized.
13
+ - **Sidebar form flags with a compose seam.** `multistep`, `pollEnabled`, and `persistSubmissions` are stacked sidebar checkboxes, composed through the new `settings.fields` option so a host can relocate, wrap, extend, or drop them. Forms now registers before form-submissions in the admin nav.
14
+ - **Advanced tab seam.** Field types can append settings to the Advanced tab via `advancedConfig`; text and email use it for an `autocomplete` autofill hint. The `hidden` flag documents that hidden fields are still validated. New `integer` validation rule for number fields; flow validation messages are localized.
15
+ - **Poll vote store (default on).** Votes are counted into a hidden append-only tally collection (`form-poll-votes`) when a submission completes: results reads and `mostVoted` resolution become one indexed find (no scan, no 10k truncation), tallies survive `persistSubmissions: false` and admin deletions (votes are permanent), rows are pure aggregate so a persist-off poll retains zero per-visitor data, and writes are atomic, transaction-joined, and sharded against concurrent write conflicts. `poll.votes: false` restores scan-based results and rejects persist-off polls at save. Exported: `recountPollVotes`, `aggregateFromVotes`, `POLL_VOTES_SLUG`, `RESPONDENTS_VALUE`, `VOTE_SHARDS`. Postgres hosts create the new `form_poll_votes` table with their next migration; `@10x-media/form-builder` declares `@payloadcms/db-postgres` as an optional peer for the tally write path.
16
+
3
17
  ## 0.1.0-beta.11
4
18
 
5
19
  ### Minor Changes
@@ -29,6 +29,12 @@ type ResolveResultsRequestArgs = {
29
29
  * answers as leftover result buckets.
30
30
  */
31
31
  eligibleTypes?: readonly string[];
32
+ /**
33
+ * Whether the hidden tally store backs poll reads. When true, the poll results field is served
34
+ * from `aggregateFromVotes` (anonymous reads, and an authed read naming exactly that field)
35
+ * instead of the submission scan; every authorization gate stays identical either way.
36
+ */
37
+ pollVotesEnabled?: boolean;
32
38
  };
33
39
  type ResolveResultsRequestResult = {
34
40
  status: number;
@@ -1,8 +1,9 @@
1
1
  import { isPollClosed, pollConfigOf } from "../form/pollState.js";
2
2
  import { aggregateFormResponses, fieldHasOptions } from "./aggregateResponses.js";
3
+ import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
4
+ import { aggregateFromVotes } from "../poll/votes/aggregateFromVotes.js";
3
5
  import { resolvePollOutcome } from "../poll/resolvePollOutcome.js";
4
6
  import { shouldAutoResolvePoll } from "../poll/closeJob.js";
5
- import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
6
7
  import { FORMS_SLUG } from "../collections/forms.js";
7
8
  //#region src/aggregation/resolveResultsRequest.ts
8
9
  const forbidden = {
@@ -13,6 +14,11 @@ const unavailable = {
13
14
  status: 503,
14
15
  body: { errors: [{ message: "Poll options unavailable" }] }
15
16
  };
17
+ /** Scan-shape parity for a tally-served field: label from the live instance, else the field name. */
18
+ const tallyMetaOf = (instance, field) => ({
19
+ label: typeof instance?.label === "string" && instance.label.length > 0 ? instance.label : field,
20
+ fieldType: instance?.blockType
21
+ });
16
22
  /**
17
23
  * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
18
24
  * enumerable fields) and bypass the `access` seam; for a poll-enabled form they also get the results
@@ -29,7 +35,7 @@ const unavailable = {
29
35
  * closed (503) on the anonymous path. Returns only aggregate counts, never raw submissions.
30
36
  */
31
37
  const resolveFormResultsRequest = async (args) => {
32
- const { payload, formId, field, isAuthed, req, access, eligibleTypes } = args;
38
+ const { payload, formId, field, isAuthed, req, access, eligibleTypes, pollVotesEnabled } = args;
33
39
  if (formId == null) return {
34
40
  status: 400,
35
41
  body: { errors: [{ message: "Missing form id" }] }
@@ -48,7 +54,8 @@ const resolveFormResultsRequest = async (args) => {
48
54
  if (shouldAutoResolvePoll(form)) await resolvePollOutcome({
49
55
  payload,
50
56
  formId,
51
- req
57
+ req,
58
+ pollVotesEnabled
52
59
  }).catch(() => void 0);
53
60
  let fields;
54
61
  let resolvedOptions;
@@ -63,6 +70,17 @@ const resolveFormResultsRequest = async (args) => {
63
70
  form
64
71
  }).catch(() => []);
65
72
  if (options.length > 0) resolvedOptions = { [resultsField]: options };
73
+ if (pollVotesEnabled === true && field === resultsField) return {
74
+ status: 200,
75
+ body: { results: [await aggregateFromVotes({
76
+ payload,
77
+ formId,
78
+ field: resultsField,
79
+ meta: tallyMetaOf((Array.isArray(form.fields) ? form.fields : []).find((entry) => entry.name === resultsField), resultsField),
80
+ options: resolvedOptions?.[resultsField] ?? [],
81
+ req
82
+ })] }
83
+ };
66
84
  }
67
85
  } else {
68
86
  const poll = pollConfigOf(form.poll);
@@ -93,6 +111,17 @@ const resolveFormResultsRequest = async (args) => {
93
111
  return unavailable;
94
112
  }
95
113
  if (options.length === 0) return forbidden;
114
+ if (pollVotesEnabled === true) return {
115
+ status: 200,
116
+ body: { results: [await aggregateFromVotes({
117
+ payload,
118
+ formId,
119
+ field: publicField,
120
+ meta: tallyMetaOf(instance, publicField),
121
+ options,
122
+ req
123
+ })] }
124
+ };
96
125
  resolvedOptions = { [publicField]: options };
97
126
  fields = [publicField];
98
127
  }
@@ -1 +1 @@
1
- {"version":3,"file":"resolveResultsRequest.js","names":[],"sources":["../../src/aggregation/resolveResultsRequest.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORMS_SLUG } from '../collections/forms'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { type PollFormLike, shouldAutoResolvePoll } from '../poll/closeJob'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport { resolvePollOutcome } from '../poll/resolvePollOutcome'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\n\nexport type FormResultsAccessArgs = {\n\treq: PayloadRequest\n\t/** The loaded forms document (depth 0). Untyped beyond `id`: hosts read their own fields (e.g. `form.tenant`). */\n\tform: { id: number | string } & Record<string, unknown>\n}\n\n/**\n * Host seam gating anonymous results reads, evaluated after the form is loaded and before anything\n * is aggregated. Multi-tenant recipe: compare `form.tenant` against the tenant derived from `req`\n * (host header, cookie, or auth context) and return `false` for a cross-tenant read. Authenticated\n * callers bypass this seam (they are admin-trusted, like the rest of the results endpoint).\n */\nexport type FormResultsAccess = (args: FormResultsAccessArgs) => boolean | Promise<boolean>\n\nexport type ResolveResultsRequestArgs = {\n\tpayload: Payload\n\tformId: number | string | undefined\n\t/** The requested field (query param). */\n\tfield?: string\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq?: PayloadRequest\n\t/** Optional host seam for anonymous reads; absent means plugin-default gating only. */\n\taccess?: FormResultsAccess\n\t/**\n\t * Poll-eligible field types (registry-derived). When set, an anonymous read of an\n\t * option-source poll also requires the results field's instance to be one of these types,\n\t * so legacy or db-written docs pointing at a free-text field can never expose stored\n\t * answers as leftover result buckets.\n\t */\n\teligibleTypes?: readonly string[]\n}\n\nexport type ResolveResultsRequestResult = {\n\tstatus: number\n\tbody: { results: FieldAggregation[] } | { errors: { message: string }[] }\n}\n\nconst forbidden: ResolveResultsRequestResult = {\n\tstatus: 403,\n\tbody: { errors: [{ message: 'Forbidden' }] },\n}\n\n// 503 over 403 for a failed option-source resolve: the form and its poll config are already\n// publicly readable, so \"temporarily unavailable\" leaks nothing new, matches the submission\n// path's precedent, and does not mislabel a transient server fault as an authorization denial.\nconst unavailable: ResolveResultsRequestResult = {\n\tstatus: 503,\n\tbody: { errors: [{ message: 'Poll options unavailable' }] },\n}\n\n/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields) and bypass the `access` seam; for a poll-enabled form they also get the results\n * field's effective options injected so sourced/resolver-backed polls render labels (best-effort, a\n * resolve failure degrades to raw values rather than blocking the trusted read). Anonymous callers are\n * allowed only when the form's poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`:\n * any time; `afterClose`: only once `closesAt` has passed), and the optional host `access` seam\n * approves; and then only for the configured `poll.resultsField`, and only if that field is enumerable\n * so a misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. A\n * static poll is enumerable through its authored options; a poll whose options come from an\n * `optionSource` or the field type's own `resolveOptions` resolves them here (registry via\n * `config.custom`, per-request cache), gated by `eligibleTypes` and enumerable only when resolution\n * yields any, with the resolved options driving bucket order and labels. Resolution failure fails\n * closed (503) on the anonymous path. Returns only aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req, access, eligibleTypes } = args\n\tif (formId == null) {\n\t\treturn { status: 400, body: { errors: [{ message: 'Missing form id' }] } }\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn { status: 404, body: { errors: [{ message: 'Not found' }] } }\n\t}\n\n\t// No-runner safety net: when a closed poll's `mostVoted`/`source` outcome was never auto-resolved\n\t// (no wired job runner fired the close task), a results read heals it. Idempotent and gate-neutral,\n\t// it runs on the already-loaded form via overrideAccess and writes only through the outcome hook, so\n\t// it cannot relax the anonymous authorization below. A failure degrades silently to the normal path.\n\tif (shouldAutoResolvePoll(form as PollFormLike)) {\n\t\tawait resolvePollOutcome({ payload, formId, req }).catch(() => undefined)\n\t}\n\n\tlet fields: string[] | undefined\n\tlet resolvedOptions: Record<string, { value: string; label: string }[]> | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t\t// Label a poll's results field for admins: resolve its effective options (a source, the\n\t\t// field type's own resolver, or authored options) and inject them so a sourced/resolved poll\n\t\t// shows labels instead of raw stored values, and so an all-fields read still surfaces a\n\t\t// resolver-backed field that carries no authored options. Best-effort: a resolver/source\n\t\t// failure degrades to raw values rather than blocking the trusted read (the anonymous path\n\t\t// below fails closed instead). Only run when the read would actually surface the results field.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst resultsField =\n\t\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (form.pollEnabled === true && resultsField && (!field || field === resultsField)) {\n\t\t\tconst options: PollOption[] = await resolveEffectivePollOptions({\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\tform,\n\t\t\t}).catch(() => [])\n\t\t\tif (options.length > 0) {\n\t\t\t\tresolvedOptions = { [resultsField]: options }\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tif (form.pollEnabled !== true || !poll) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (poll.resultsVisibility === 'afterClose' && !isPollClosed(poll)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (access) {\n\t\t\t// No req means the seam cannot be evaluated; fail closed rather than skip a configured gate.\n\t\t\t// The concrete generated Form doc has no index signature; the seam's Record<string, unknown>\n\t\t\t// is the ergonomic host-facing contract, so widen the doc to it here.\n\t\t\tconst allowed = req\n\t\t\t\t? await access({ req, form: form as unknown as FormResultsAccessArgs['form'] })\n\t\t\t\t: false\n\t\t\tif (!allowed) {\n\t\t\t\treturn forbidden\n\t\t\t}\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof poll.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (!publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (field && field !== publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\tconst instance = instances.find((entry) => entry.name === publicField)\n\t\tif (!instance) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst hasSource = typeof poll.optionSource === 'string' && poll.optionSource.length > 0\n\t\tconst authored = fieldHasOptions(instance)\n\t\t// Options that don't come from the author's own literal `options` list (a source or a\n\t\t// field-typed resolver) require the results field to be a poll-eligible type, so a legacy or\n\t\t// db-written doc pointing `resultsField` at a free-text field can never dump raw answers as\n\t\t// leftover buckets. A static authored poll is a choice field already, so it keeps serving.\n\t\tif ((hasSource || !authored) && eligibleTypes && !eligibleTypes.includes(instance.blockType)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tlet options: PollOption[]\n\t\ttry {\n\t\t\toptions = await resolveEffectivePollOptions({ payload, req, form })\n\t\t} catch {\n\t\t\treturn unavailable\n\t\t}\n\t\tif (options.length === 0) {\n\t\t\treturn forbidden\n\t\t}\n\t\tresolvedOptions = { [publicField]: options }\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req, resolvedOptions })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;;;;;AAiDA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;AAKA,MAAM,cAA2C;CAChD,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,2BAA2B,CAAC,EAAE;AAC3D;;;;;;;;;;;;;;;;AAiBA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,KAAK,QAAQ,kBAAkB;CACzE,IAAI,UAAU,MACb,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,kBAAkB,CAAC,EAAE;CAAE;CAE1E,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAOpE,IAAI,sBAAsB,IAAoB,GAC7C,MAAM,mBAAmB;EAAE;EAAS;EAAQ;CAAI,CAAC,EAAE,YAAY,KAAA,CAAS;CAGzE,IAAI;CACJ,IAAI;CACJ,IAAI,UAAU;EACb,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;EAO3B,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;EACJ,IAAI,KAAK,gBAAgB,QAAQ,iBAAiB,CAAC,SAAS,UAAU,eAAe;GACpF,MAAM,UAAwB,MAAM,4BAA4B;IAC/D;IACA;IACA;GACD,CAAC,EAAE,YAAY,CAAC,CAAC;GACjB,IAAI,QAAQ,SAAS,GACpB,kBAAkB,GAAG,eAAe,QAAQ;EAE9C;CACD,OAAO;EACN,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,IAAI,KAAK,gBAAgB,QAAQ,CAAC,MACjC,OAAO;EAER,IAAI,KAAK,sBAAsB,gBAAgB,CAAC,aAAa,IAAI,GAChE,OAAO;EAER,IAAI;OAOC,EAHY,MACb,MAAM,OAAO;IAAE;IAAW;GAAiD,CAAC,IAC5E,QAEF,OAAO;EAAA;EAGT,MAAM,cACL,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjE,KAAK,eACL,KAAA;EACJ,IAAI,CAAC,aACJ,OAAO;EAER,IAAI,SAAS,UAAU,aACtB,OAAO;EAGR,MAAM,YADY,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,WAAW;EACrE,IAAI,CAAC,UACJ,OAAO;EAER,MAAM,YAAY,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS;EACtF,MAAM,WAAW,gBAAgB,QAAQ;EAKzC,KAAK,aAAa,CAAC,aAAa,iBAAiB,CAAC,cAAc,SAAS,SAAS,SAAS,GAC1F,OAAO;EAER,IAAI;EACJ,IAAI;GACH,UAAU,MAAM,4BAA4B;IAAE;IAAS;IAAK;GAAK,CAAC;EACnE,QAAQ;GACP,OAAO;EACR;EACA,IAAI,QAAQ,WAAW,GACtB,OAAO;EAER,kBAAkB,GAAG,cAAc,QAAQ;EAC3C,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;GAAK;EAAgB,CAAC,EACxD;CAAE;AACzC"}
1
+ {"version":3,"file":"resolveResultsRequest.js","names":[],"sources":["../../src/aggregation/resolveResultsRequest.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORMS_SLUG } from '../collections/forms'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { type PollFormLike, shouldAutoResolvePoll } from '../poll/closeJob'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport { resolvePollOutcome } from '../poll/resolvePollOutcome'\nimport { aggregateFromVotes } from '../poll/votes/aggregateFromVotes'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\n\nexport type FormResultsAccessArgs = {\n\treq: PayloadRequest\n\t/** The loaded forms document (depth 0). Untyped beyond `id`: hosts read their own fields (e.g. `form.tenant`). */\n\tform: { id: number | string } & Record<string, unknown>\n}\n\n/**\n * Host seam gating anonymous results reads, evaluated after the form is loaded and before anything\n * is aggregated. Multi-tenant recipe: compare `form.tenant` against the tenant derived from `req`\n * (host header, cookie, or auth context) and return `false` for a cross-tenant read. Authenticated\n * callers bypass this seam (they are admin-trusted, like the rest of the results endpoint).\n */\nexport type FormResultsAccess = (args: FormResultsAccessArgs) => boolean | Promise<boolean>\n\nexport type ResolveResultsRequestArgs = {\n\tpayload: Payload\n\tformId: number | string | undefined\n\t/** The requested field (query param). */\n\tfield?: string\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq?: PayloadRequest\n\t/** Optional host seam for anonymous reads; absent means plugin-default gating only. */\n\taccess?: FormResultsAccess\n\t/**\n\t * Poll-eligible field types (registry-derived). When set, an anonymous read of an\n\t * option-source poll also requires the results field's instance to be one of these types,\n\t * so legacy or db-written docs pointing at a free-text field can never expose stored\n\t * answers as leftover result buckets.\n\t */\n\teligibleTypes?: readonly string[]\n\t/**\n\t * Whether the hidden tally store backs poll reads. When true, the poll results field is served\n\t * from `aggregateFromVotes` (anonymous reads, and an authed read naming exactly that field)\n\t * instead of the submission scan; every authorization gate stays identical either way.\n\t */\n\tpollVotesEnabled?: boolean\n}\n\nexport type ResolveResultsRequestResult = {\n\tstatus: number\n\tbody: { results: FieldAggregation[] } | { errors: { message: string }[] }\n}\n\nconst forbidden: ResolveResultsRequestResult = {\n\tstatus: 403,\n\tbody: { errors: [{ message: 'Forbidden' }] },\n}\n\n// 503 over 403 for a failed option-source resolve: the form and its poll config are already\n// publicly readable, so \"temporarily unavailable\" leaks nothing new, matches the submission\n// path's precedent, and does not mislabel a transient server fault as an authorization denial.\nconst unavailable: ResolveResultsRequestResult = {\n\tstatus: 503,\n\tbody: { errors: [{ message: 'Poll options unavailable' }] },\n}\n\n/** Scan-shape parity for a tally-served field: label from the live instance, else the field name. */\nconst tallyMetaOf = (\n\tinstance: FormFieldInstance | undefined,\n\tfield: string\n): { label: string; fieldType?: string } => ({\n\tlabel: typeof instance?.label === 'string' && instance.label.length > 0 ? instance.label : field,\n\tfieldType: instance?.blockType,\n})\n\n/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields) and bypass the `access` seam; for a poll-enabled form they also get the results\n * field's effective options injected so sourced/resolver-backed polls render labels (best-effort, a\n * resolve failure degrades to raw values rather than blocking the trusted read). Anonymous callers are\n * allowed only when the form's poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`:\n * any time; `afterClose`: only once `closesAt` has passed), and the optional host `access` seam\n * approves; and then only for the configured `poll.resultsField`, and only if that field is enumerable\n * so a misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. A\n * static poll is enumerable through its authored options; a poll whose options come from an\n * `optionSource` or the field type's own `resolveOptions` resolves them here (registry via\n * `config.custom`, per-request cache), gated by `eligibleTypes` and enumerable only when resolution\n * yields any, with the resolved options driving bucket order and labels. Resolution failure fails\n * closed (503) on the anonymous path. Returns only aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req, access, eligibleTypes, pollVotesEnabled } = args\n\tif (formId == null) {\n\t\treturn { status: 400, body: { errors: [{ message: 'Missing form id' }] } }\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn { status: 404, body: { errors: [{ message: 'Not found' }] } }\n\t}\n\n\t// No-runner safety net: when a closed poll's `mostVoted`/`source` outcome was never auto-resolved\n\t// (no wired job runner fired the close task), a results read heals it. Idempotent and gate-neutral,\n\t// it runs on the already-loaded form via overrideAccess and writes only through the outcome hook, so\n\t// it cannot relax the anonymous authorization below. A failure degrades silently to the normal path.\n\tif (shouldAutoResolvePoll(form as PollFormLike)) {\n\t\tawait resolvePollOutcome({ payload, formId, req, pollVotesEnabled }).catch(() => undefined)\n\t}\n\n\tlet fields: string[] | undefined\n\tlet resolvedOptions: Record<string, { value: string; label: string }[]> | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t\t// Label a poll's results field for admins: resolve its effective options (a source, the\n\t\t// field type's own resolver, or authored options) and inject them so a sourced/resolved poll\n\t\t// shows labels instead of raw stored values, and so an all-fields read still surfaces a\n\t\t// resolver-backed field that carries no authored options. Best-effort: a resolver/source\n\t\t// failure degrades to raw values rather than blocking the trusted read (the anonymous path\n\t\t// below fails closed instead). Only run when the read would actually surface the results field.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst resultsField =\n\t\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (form.pollEnabled === true && resultsField && (!field || field === resultsField)) {\n\t\t\tconst options: PollOption[] = await resolveEffectivePollOptions({\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\tform,\n\t\t\t}).catch(() => [])\n\t\t\tif (options.length > 0) {\n\t\t\t\tresolvedOptions = { [resultsField]: options }\n\t\t\t}\n\t\t\t// Only the explicit single-field poll read switches to the tally store; an all-fields\n\t\t\t// (or multi-field) read keeps the scan, which is the only source for non-poll fields.\n\t\t\tif (pollVotesEnabled === true && field === resultsField) {\n\t\t\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\t\t\tconst instance = instances.find((entry) => entry.name === resultsField)\n\t\t\t\tconst aggregation = await aggregateFromVotes({\n\t\t\t\t\tpayload,\n\t\t\t\t\tformId,\n\t\t\t\t\tfield: resultsField,\n\t\t\t\t\tmeta: tallyMetaOf(instance, resultsField),\n\t\t\t\t\toptions: resolvedOptions?.[resultsField] ?? [],\n\t\t\t\t\treq,\n\t\t\t\t})\n\t\t\t\treturn { status: 200, body: { results: [aggregation] } }\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tif (form.pollEnabled !== true || !poll) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (poll.resultsVisibility === 'afterClose' && !isPollClosed(poll)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (access) {\n\t\t\t// No req means the seam cannot be evaluated; fail closed rather than skip a configured gate.\n\t\t\t// The concrete generated Form doc has no index signature; the seam's Record<string, unknown>\n\t\t\t// is the ergonomic host-facing contract, so widen the doc to it here.\n\t\t\tconst allowed = req\n\t\t\t\t? await access({ req, form: form as unknown as FormResultsAccessArgs['form'] })\n\t\t\t\t: false\n\t\t\tif (!allowed) {\n\t\t\t\treturn forbidden\n\t\t\t}\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof poll.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (!publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (field && field !== publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\tconst instance = instances.find((entry) => entry.name === publicField)\n\t\tif (!instance) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst hasSource = typeof poll.optionSource === 'string' && poll.optionSource.length > 0\n\t\tconst authored = fieldHasOptions(instance)\n\t\t// Options that don't come from the author's own literal `options` list (a source or a\n\t\t// field-typed resolver) require the results field to be a poll-eligible type, so a legacy or\n\t\t// db-written doc pointing `resultsField` at a free-text field can never dump raw answers as\n\t\t// leftover buckets. A static authored poll is a choice field already, so it keeps serving.\n\t\tif ((hasSource || !authored) && eligibleTypes && !eligibleTypes.includes(instance.blockType)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tlet options: PollOption[]\n\t\ttry {\n\t\t\toptions = await resolveEffectivePollOptions({ payload, req, form })\n\t\t} catch {\n\t\t\treturn unavailable\n\t\t}\n\t\tif (options.length === 0) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (pollVotesEnabled === true) {\n\t\t\tconst aggregation = await aggregateFromVotes({\n\t\t\t\tpayload,\n\t\t\t\tformId,\n\t\t\t\tfield: publicField,\n\t\t\t\tmeta: tallyMetaOf(instance, publicField),\n\t\t\t\toptions,\n\t\t\t\treq,\n\t\t\t})\n\t\t\treturn { status: 200, body: { results: [aggregation] } }\n\t\t}\n\t\tresolvedOptions = { [publicField]: options }\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req, resolvedOptions })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;;;;;;AAwDA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;AAKA,MAAM,cAA2C;CAChD,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,2BAA2B,CAAC,EAAE;AAC3D;;AAGA,MAAM,eACL,UACA,WAC4C;CAC5C,OAAO,OAAO,UAAU,UAAU,YAAY,SAAS,MAAM,SAAS,IAAI,SAAS,QAAQ;CAC3F,WAAW,UAAU;AACtB;;;;;;;;;;;;;;;;AAiBA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,KAAK,QAAQ,eAAe,qBAAqB;CAC3F,IAAI,UAAU,MACb,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,kBAAkB,CAAC,EAAE;CAAE;CAE1E,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAOpE,IAAI,sBAAsB,IAAoB,GAC7C,MAAM,mBAAmB;EAAE;EAAS;EAAQ;EAAK;CAAiB,CAAC,EAAE,YAAY,KAAA,CAAS;CAG3F,IAAI;CACJ,IAAI;CACJ,IAAI,UAAU;EACb,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;EAO3B,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;EACJ,IAAI,KAAK,gBAAgB,QAAQ,iBAAiB,CAAC,SAAS,UAAU,eAAe;GACpF,MAAM,UAAwB,MAAM,4BAA4B;IAC/D;IACA;IACA;GACD,CAAC,EAAE,YAAY,CAAC,CAAC;GACjB,IAAI,QAAQ,SAAS,GACpB,kBAAkB,GAAG,eAAe,QAAQ;GAI7C,IAAI,qBAAqB,QAAQ,UAAU,cAW1C,OAAO;IAAE,QAAQ;IAAK,MAAM,EAAE,SAAS,CAAC,MARd,mBAAmB;KAC5C;KACA;KACA,OAAO;KACP,MAAM,aANW,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,YAKhC,GAAG,YAAY;KACxC,SAAS,kBAAkB,iBAAiB,CAAC;KAC7C;IACD,CAAC,CACkD,EAAE;GAAE;EAEzD;CACD,OAAO;EACN,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,IAAI,KAAK,gBAAgB,QAAQ,CAAC,MACjC,OAAO;EAER,IAAI,KAAK,sBAAsB,gBAAgB,CAAC,aAAa,IAAI,GAChE,OAAO;EAER,IAAI;OAOC,EAHY,MACb,MAAM,OAAO;IAAE;IAAW;GAAiD,CAAC,IAC5E,QAEF,OAAO;EAAA;EAGT,MAAM,cACL,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjE,KAAK,eACL,KAAA;EACJ,IAAI,CAAC,aACJ,OAAO;EAER,IAAI,SAAS,UAAU,aACtB,OAAO;EAGR,MAAM,YADY,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,WAAW;EACrE,IAAI,CAAC,UACJ,OAAO;EAER,MAAM,YAAY,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS;EACtF,MAAM,WAAW,gBAAgB,QAAQ;EAKzC,KAAK,aAAa,CAAC,aAAa,iBAAiB,CAAC,cAAc,SAAS,SAAS,SAAS,GAC1F,OAAO;EAER,IAAI;EACJ,IAAI;GACH,UAAU,MAAM,4BAA4B;IAAE;IAAS;IAAK;GAAK,CAAC;EACnE,QAAQ;GACP,OAAO;EACR;EACA,IAAI,QAAQ,WAAW,GACtB,OAAO;EAER,IAAI,qBAAqB,MASxB,OAAO;GAAE,QAAQ;GAAK,MAAM,EAAE,SAAS,CAAC,MARd,mBAAmB;IAC5C;IACA;IACA,OAAO;IACP,MAAM,YAAY,UAAU,WAAW;IACvC;IACA;GACD,CAAC,CACkD,EAAE;EAAE;EAExD,kBAAkB,GAAG,cAAc,QAAQ;EAC3C,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;GAAK;EAAgB,CAAC,EACxD;CAAE;AACzC"}
@@ -1,5 +1,6 @@
1
- import { FormFieldInstance } from "../submissions/types.js";
2
1
  import { CalcExpression } from "./types.js";
2
+ import { FormFieldInstance } from "../submissions/types.js";
3
+ import { CalcResolved } from "./evaluate.js";
3
4
 
4
5
  //#region src/calc/computeCalcFields.d.ts
5
6
  /** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */
@@ -7,9 +8,10 @@ declare const calcExpressionOf: (field: FormFieldInstance) => CalcExpression | u
7
8
  /**
8
9
  * Returns answers with every calc field's value derived from its expression,
9
10
  * folded in declaration order so a calc may reference an earlier calc's result.
10
- * Identity when no field carries an expression.
11
+ * Identity when no field carries an expression. `resolved` threads server-resolved
12
+ * source values, weight maps, and custom functions into each evaluation.
11
13
  */
12
- declare const computeCalcFields: (fields: FormFieldInstance[], answers: Record<string, unknown>) => Record<string, unknown>;
14
+ declare const computeCalcFields: (fields: FormFieldInstance[], answers: Record<string, unknown>, resolved?: CalcResolved) => Record<string, unknown>;
13
15
  //#endregion
14
16
  export { calcExpressionOf, computeCalcFields };
15
17
  //# sourceMappingURL=computeCalcFields.d.ts.map
@@ -9,15 +9,16 @@ const calcExpressionOf = (field) => {
9
9
  /**
10
10
  * Returns answers with every calc field's value derived from its expression,
11
11
  * folded in declaration order so a calc may reference an earlier calc's result.
12
- * Identity when no field carries an expression.
12
+ * Identity when no field carries an expression. `resolved` threads server-resolved
13
+ * source values, weight maps, and custom functions into each evaluation.
13
14
  */
14
- const computeCalcFields = (fields, answers) => {
15
+ const computeCalcFields = (fields, answers, resolved) => {
15
16
  let next = answers;
16
17
  for (const field of fields) {
17
18
  const expr = calcExpressionOf(field);
18
19
  if (expr && isNamedField(field)) next = {
19
20
  ...next,
20
- [field.name]: evaluateCalc(expr, next)
21
+ [field.name]: evaluateCalc(expr, next, resolved)
21
22
  };
22
23
  }
23
24
  return next;
@@ -1 +1 @@
1
- {"version":3,"file":"computeCalcFields.js","names":[],"sources":["../../src/calc/computeCalcFields.ts"],"sourcesContent":["import { isNamedField } from '../fields/fieldKey'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { evaluateCalc } from './evaluate'\nimport type { CalcExpression } from './types'\n\n/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */\nexport const calcExpressionOf = (field: FormFieldInstance): CalcExpression | undefined => {\n\tconst expr = field.expression\n\treturn expr !== null && typeof expr === 'object' && !Array.isArray(expr)\n\t\t? (expr as CalcExpression)\n\t\t: undefined\n}\n\n/**\n * Returns answers with every calc field's value derived from its expression,\n * folded in declaration order so a calc may reference an earlier calc's result.\n * Identity when no field carries an expression.\n */\nexport const computeCalcFields = (\n\tfields: FormFieldInstance[],\n\tanswers: Record<string, unknown>\n): Record<string, unknown> => {\n\tlet next = answers\n\tfor (const field of fields) {\n\t\tconst expr = calcExpressionOf(field)\n\t\tif (expr && isNamedField(field)) {\n\t\t\tnext = { ...next, [field.name]: evaluateCalc(expr, next) }\n\t\t}\n\t}\n\treturn next\n}\n"],"mappings":";;;;AAMA,MAAa,oBAAoB,UAAyD;CACzF,MAAM,OAAO,MAAM;CACnB,OAAO,SAAS,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,IACnE,OACD,KAAA;AACJ;;;;;;AAOA,MAAa,qBACZ,QACA,YAC6B;CAC7B,IAAI,OAAO;CACX,KAAK,MAAM,SAAS,QAAQ;EAC3B,MAAM,OAAO,iBAAiB,KAAK;EACnC,IAAI,QAAQ,aAAa,KAAK,GAC7B,OAAO;GAAE,GAAG;IAAO,MAAM,OAAO,aAAa,MAAM,IAAI;EAAE;CAE3D;CACA,OAAO;AACR"}
1
+ {"version":3,"file":"computeCalcFields.js","names":[],"sources":["../../src/calc/computeCalcFields.ts"],"sourcesContent":["import { isNamedField } from '../fields/fieldKey'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { type CalcResolved, evaluateCalc } from './evaluate'\nimport type { CalcExpression } from './types'\n\n/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */\nexport const calcExpressionOf = (field: FormFieldInstance): CalcExpression | undefined => {\n\tconst expr = field.expression\n\treturn expr !== null && typeof expr === 'object' && !Array.isArray(expr)\n\t\t? (expr as CalcExpression)\n\t\t: undefined\n}\n\n/**\n * Returns answers with every calc field's value derived from its expression,\n * folded in declaration order so a calc may reference an earlier calc's result.\n * Identity when no field carries an expression. `resolved` threads server-resolved\n * source values, weight maps, and custom functions into each evaluation.\n */\nexport const computeCalcFields = (\n\tfields: FormFieldInstance[],\n\tanswers: Record<string, unknown>,\n\tresolved?: CalcResolved\n): Record<string, unknown> => {\n\tlet next = answers\n\tfor (const field of fields) {\n\t\tconst expr = calcExpressionOf(field)\n\t\tif (expr && isNamedField(field)) {\n\t\t\tnext = { ...next, [field.name]: evaluateCalc(expr, next, resolved) }\n\t\t}\n\t}\n\treturn next\n}\n"],"mappings":";;;;AAMA,MAAa,oBAAoB,UAAyD;CACzF,MAAM,OAAO,MAAM;CACnB,OAAO,SAAS,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,IACnE,OACD,KAAA;AACJ;;;;;;;AAQA,MAAa,qBACZ,QACA,SACA,aAC6B;CAC7B,IAAI,OAAO;CACX,KAAK,MAAM,SAAS,QAAQ;EAC3B,MAAM,OAAO,iBAAiB,KAAK;EACnC,IAAI,QAAQ,aAAa,KAAK,GAC7B,OAAO;GAAE,GAAG;IAAO,MAAM,OAAO,aAAa,MAAM,MAAM,QAAQ;EAAE;CAErE;CACA,OAAO;AACR"}
@@ -1,8 +1,31 @@
1
1
  import { CalcExpression } from "./types.js";
2
2
 
3
3
  //#region src/calc/evaluate.d.ts
4
- /** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */
5
- declare const evaluateCalc: (expr: CalcExpression | null | undefined, answers: Record<string, unknown>) => number;
4
+ /**
5
+ * Server-resolved calc extension values, threaded into evaluation as data (never re-resolved
6
+ * here, so the evaluator stays sync and isomorphic). `sources`/`weights` are serializable and
7
+ * ride the form document for the client's live preview; `functions` never serialize and are
8
+ * supplied per environment (registry `apply` fns on the server, the Form prop on the client).
9
+ */
10
+ type CalcResolved = {
11
+ sources?: Record<string, number>; /** Keyed `source + ' ' + field` (see `calcWeightKey` / `resolveCalcContext`). */
12
+ weights?: Record<string, Record<string, number>>;
13
+ functions?: Record<string, (args: number[]) => number>;
14
+ };
15
+ /**
16
+ * The `CalcResolved.weights` key for a sourced weight node: source key + space + field name.
17
+ * Unambiguous because registered source keys can never contain a space
18
+ * (`assertValidCalcSourceKeys` enforces `[\w.-]` at boot), so the first space always ends the
19
+ * source segment.
20
+ */
21
+ declare const calcWeightKey: (source: string, field: string) => string;
22
+ /**
23
+ * Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod
24
+ * by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). `resolved` supplies
25
+ * server-resolved source values, sourced weight maps, and custom functions; any extension node
26
+ * whose value is absent evaluates to 0.
27
+ */
28
+ declare const evaluateCalc: (expr: CalcExpression | null | undefined, answers: Record<string, unknown>, resolved?: CalcResolved) => number;
6
29
  //#endregion
7
- export { evaluateCalc };
30
+ export { CalcResolved, calcWeightKey, evaluateCalc };
8
31
  //# sourceMappingURL=evaluate.d.ts.map
@@ -1,4 +1,11 @@
1
1
  //#region src/calc/evaluate.ts
2
+ /**
3
+ * The `CalcResolved.weights` key for a sourced weight node: source key + space + field name.
4
+ * Unambiguous because registered source keys can never contain a space
5
+ * (`assertValidCalcSourceKeys` enforces `[\w.-]` at boot), so the first space always ends the
6
+ * source segment.
7
+ */
8
+ const calcWeightKey = (source, field) => `${source} ${field}`;
2
9
  const toNumber = (value) => {
3
10
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
4
11
  if (typeof value === "string" && value.trim() !== "") {
@@ -16,15 +23,17 @@ const FUNCTIONS = {
16
23
  ceil: (a) => Math.ceil(a[0] ?? 0),
17
24
  floor: (a) => Math.floor(a[0] ?? 0)
18
25
  };
19
- const evalNode = (expr, answers, depth) => {
26
+ const evalNode = (expr, ctx, depth) => {
20
27
  if (depth > 64 || expr == null || typeof expr !== "object") return 0;
28
+ const { answers, resolved } = ctx;
21
29
  switch (expr.type) {
22
30
  case "lit": return finite(toNumber(expr.value));
23
31
  case "ref": return toNumber(answers[expr.field]);
24
- case "neg": return finite(-evalNode(expr.operand, answers, depth + 1));
32
+ case "source": return toNumber(resolved?.sources?.[expr.source]);
33
+ case "neg": return finite(-evalNode(expr.operand, ctx, depth + 1));
25
34
  case "op": {
26
- const l = evalNode(expr.left, answers, depth + 1);
27
- const r = evalNode(expr.right, answers, depth + 1);
35
+ const l = evalNode(expr.left, ctx, depth + 1);
36
+ const r = evalNode(expr.right, ctx, depth + 1);
28
37
  switch (expr.op) {
29
38
  case "+": return finite(l + r);
30
39
  case "-": return finite(l - r);
@@ -35,24 +44,37 @@ const evalNode = (expr, answers, depth) => {
35
44
  }
36
45
  }
37
46
  case "fn": {
38
- const fn = FUNCTIONS[expr.fn];
47
+ const fn = FUNCTIONS[expr.fn] ?? resolved?.functions?.[expr.fn];
39
48
  if (!fn) return 0;
40
- return finite(fn(Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, answers, depth + 1)) : []));
49
+ const args = Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, ctx, depth + 1)) : [];
50
+ try {
51
+ return finite(fn(args));
52
+ } catch {
53
+ return 0;
54
+ }
41
55
  }
42
56
  case "weight": {
43
- const weights = expr.weights ?? {};
57
+ const weights = typeof expr.source === "string" ? resolved?.weights?.[calcWeightKey(expr.source, expr.field)] ?? {} : expr.weights ?? {};
44
58
  const value = answers[expr.field];
45
59
  return finite((Array.isArray(value) ? value : value == null || value === "" ? [] : [value]).reduce((sum, v) => sum + toNumber(weights[String(v)]), 0));
46
60
  }
47
61
  default: return 0;
48
62
  }
49
63
  };
50
- /** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */
51
- const evaluateCalc = (expr, answers) => {
64
+ /**
65
+ * Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod
66
+ * by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). `resolved` supplies
67
+ * server-resolved source values, sourced weight maps, and custom functions; any extension node
68
+ * whose value is absent evaluates to 0.
69
+ */
70
+ const evaluateCalc = (expr, answers, resolved) => {
52
71
  if (!expr) return 0;
53
- return evalNode(expr, answers, 0);
72
+ return evalNode(expr, {
73
+ answers,
74
+ resolved
75
+ }, 0);
54
76
  };
55
77
  //#endregion
56
- export { evaluateCalc };
78
+ export { calcWeightKey, evaluateCalc };
57
79
 
58
80
  //# sourceMappingURL=evaluate.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import { type CalcExpression, MAX_DEPTH } from './types'\n\nconst toNumber = (value: unknown): number => {\n\tif (typeof value === 'number') {\n\t\treturn Number.isFinite(value) ? value : 0\n\t}\n\tif (typeof value === 'string' && value.trim() !== '') {\n\t\tconst n = Number(value)\n\t\treturn Number.isFinite(n) ? n : 0\n\t}\n\treturn 0\n}\n\nconst finite = (n: number): number => (Number.isFinite(n) ? n : 0)\n\nconst FUNCTIONS: Record<string, (args: number[]) => number> = {\n\tmin: (a) => (a.length ? Math.min(...a) : 0),\n\tmax: (a) => (a.length ? Math.max(...a) : 0),\n\tround: (a) => Math.round(a[0] ?? 0),\n\tabs: (a) => Math.abs(a[0] ?? 0),\n\tceil: (a) => Math.ceil(a[0] ?? 0),\n\tfloor: (a) => Math.floor(a[0] ?? 0),\n}\n\nconst evalNode = (\n\texpr: CalcExpression,\n\tanswers: Record<string, unknown>,\n\tdepth: number\n): number => {\n\tif (depth > MAX_DEPTH || expr == null || typeof expr !== 'object') {\n\t\treturn 0\n\t}\n\tswitch (expr.type) {\n\t\tcase 'lit':\n\t\t\treturn finite(toNumber(expr.value))\n\t\tcase 'ref':\n\t\t\treturn toNumber(answers[expr.field])\n\t\tcase 'neg':\n\t\t\treturn finite(-evalNode(expr.operand, answers, depth + 1))\n\t\tcase 'op': {\n\t\t\tconst l = evalNode(expr.left, answers, depth + 1)\n\t\t\tconst r = evalNode(expr.right, answers, depth + 1)\n\t\t\tswitch (expr.op) {\n\t\t\t\tcase '+':\n\t\t\t\t\treturn finite(l + r)\n\t\t\t\tcase '-':\n\t\t\t\t\treturn finite(l - r)\n\t\t\t\tcase '*':\n\t\t\t\t\treturn finite(l * r)\n\t\t\t\tcase '/':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l / r)\n\t\t\t\tcase '%':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l % r)\n\t\t\t\tdefault:\n\t\t\t\t\treturn 0\n\t\t\t}\n\t\t}\n\t\tcase 'fn': {\n\t\t\tconst fn = FUNCTIONS[expr.fn]\n\t\t\tif (!fn) {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t\tconst args = Array.isArray(expr.args)\n\t\t\t\t? expr.args.map((a) => evalNode(a, answers, depth + 1))\n\t\t\t\t: []\n\t\t\treturn finite(fn(args))\n\t\t}\n\t\tcase 'weight': {\n\t\t\tconst weights = expr.weights ?? {}\n\t\t\tconst value = answers[expr.field]\n\t\t\tconst chosen = Array.isArray(value) ? value : value == null || value === '' ? [] : [value]\n\t\t\treturn finite(\n\t\t\t\tchosen.reduce((sum: number, v: unknown) => sum + toNumber(weights[String(v)]), 0)\n\t\t\t)\n\t\t}\n\t\tdefault:\n\t\t\treturn 0\n\t}\n}\n\n/** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */\nexport const evaluateCalc = (\n\texpr: CalcExpression | null | undefined,\n\tanswers: Record<string, unknown>\n): number => {\n\tif (!expr) {\n\t\treturn 0\n\t}\n\treturn evalNode(expr, answers, 0)\n}\n"],"mappings":";AAEA,MAAM,YAAY,UAA2B;CAC5C,IAAI,OAAO,UAAU,UACpB,OAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;CAEzC,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,IAAI;EACrD,MAAM,IAAI,OAAO,KAAK;EACtB,OAAO,OAAO,SAAS,CAAC,IAAI,IAAI;CACjC;CACA,OAAO;AACR;AAEA,MAAM,UAAU,MAAuB,OAAO,SAAS,CAAC,IAAI,IAAI;AAEhE,MAAM,YAAwD;CAC7D,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;CAClC,MAAM,MAAM,KAAK,IAAI,EAAE,MAAM,CAAC;CAC9B,OAAO,MAAM,KAAK,KAAK,EAAE,MAAM,CAAC;CAChC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;AACnC;AAEA,MAAM,YACL,MACA,SACA,UACY;CACZ,IAAI,QAAA,MAAqB,QAAQ,QAAQ,OAAO,SAAS,UACxD,OAAO;CAER,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,SAAS,KAAK,KAAK,CAAC;EACnC,KAAK,OACJ,OAAO,SAAS,QAAQ,KAAK,MAAM;EACpC,KAAK,OACJ,OAAO,OAAO,CAAC,SAAS,KAAK,SAAS,SAAS,QAAQ,CAAC,CAAC;EAC1D,KAAK,MAAM;GACV,MAAM,IAAI,SAAS,KAAK,MAAM,SAAS,QAAQ,CAAC;GAChD,MAAM,IAAI,SAAS,KAAK,OAAO,SAAS,QAAQ,CAAC;GACjD,QAAQ,KAAK,IAAb;IACC,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,SACC,OAAO;GACT;EACD;EACA,KAAK,MAAM;GACV,MAAM,KAAK,UAAU,KAAK;GAC1B,IAAI,CAAC,IACJ,OAAO;GAKR,OAAO,OAAO,GAHD,MAAM,QAAQ,KAAK,IAAI,IACjC,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG,SAAS,QAAQ,CAAC,CAAC,IACpD,CAAC,CACiB,CAAC;EACvB;EACA,KAAK,UAAU;GACd,MAAM,UAAU,KAAK,WAAW,CAAC;GACjC,MAAM,QAAQ,QAAQ,KAAK;GAE3B,OAAO,QADQ,MAAM,QAAQ,KAAK,IAAI,QAAQ,SAAS,QAAQ,UAAU,KAAK,CAAC,IAAI,CAAC,KAAK,GAEjF,QAAQ,KAAa,MAAe,MAAM,SAAS,QAAQ,OAAO,CAAC,EAAE,GAAG,CAAC,CACjF;EACD;EACA,SACC,OAAO;CACT;AACD;;AAGA,MAAa,gBACZ,MACA,YACY;CACZ,IAAI,CAAC,MACJ,OAAO;CAER,OAAO,SAAS,MAAM,SAAS,CAAC;AACjC"}
1
+ {"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import { type CalcExpression, MAX_DEPTH } from './types'\n\n/**\n * Server-resolved calc extension values, threaded into evaluation as data (never re-resolved\n * here, so the evaluator stays sync and isomorphic). `sources`/`weights` are serializable and\n * ride the form document for the client's live preview; `functions` never serialize and are\n * supplied per environment (registry `apply` fns on the server, the Form prop on the client).\n */\nexport type CalcResolved = {\n\tsources?: Record<string, number>\n\t/** Keyed `source + ' ' + field` (see `calcWeightKey` / `resolveCalcContext`). */\n\tweights?: Record<string, Record<string, number>>\n\tfunctions?: Record<string, (args: number[]) => number>\n}\n\n/**\n * The `CalcResolved.weights` key for a sourced weight node: source key + space + field name.\n * Unambiguous because registered source keys can never contain a space\n * (`assertValidCalcSourceKeys` enforces `[\\w.-]` at boot), so the first space always ends the\n * source segment.\n */\nexport const calcWeightKey = (source: string, field: string): string => `${source} ${field}`\n\nconst toNumber = (value: unknown): number => {\n\tif (typeof value === 'number') {\n\t\treturn Number.isFinite(value) ? value : 0\n\t}\n\tif (typeof value === 'string' && value.trim() !== '') {\n\t\tconst n = Number(value)\n\t\treturn Number.isFinite(n) ? n : 0\n\t}\n\treturn 0\n}\n\nconst finite = (n: number): number => (Number.isFinite(n) ? n : 0)\n\nconst FUNCTIONS: Record<string, (args: number[]) => number> = {\n\tmin: (a) => (a.length ? Math.min(...a) : 0),\n\tmax: (a) => (a.length ? Math.max(...a) : 0),\n\tround: (a) => Math.round(a[0] ?? 0),\n\tabs: (a) => Math.abs(a[0] ?? 0),\n\tceil: (a) => Math.ceil(a[0] ?? 0),\n\tfloor: (a) => Math.floor(a[0] ?? 0),\n}\n\ntype EvalContext = { answers: Record<string, unknown>; resolved?: CalcResolved }\n\nconst evalNode = (expr: CalcExpression, ctx: EvalContext, depth: number): number => {\n\tif (depth > MAX_DEPTH || expr == null || typeof expr !== 'object') {\n\t\treturn 0\n\t}\n\tconst { answers, resolved } = ctx\n\tswitch (expr.type) {\n\t\tcase 'lit':\n\t\t\treturn finite(toNumber(expr.value))\n\t\tcase 'ref':\n\t\t\treturn toNumber(answers[expr.field])\n\t\tcase 'source':\n\t\t\treturn toNumber(resolved?.sources?.[expr.source])\n\t\tcase 'neg':\n\t\t\treturn finite(-evalNode(expr.operand, ctx, depth + 1))\n\t\tcase 'op': {\n\t\t\tconst l = evalNode(expr.left, ctx, depth + 1)\n\t\t\tconst r = evalNode(expr.right, ctx, depth + 1)\n\t\t\tswitch (expr.op) {\n\t\t\t\tcase '+':\n\t\t\t\t\treturn finite(l + r)\n\t\t\t\tcase '-':\n\t\t\t\t\treturn finite(l - r)\n\t\t\t\tcase '*':\n\t\t\t\t\treturn finite(l * r)\n\t\t\t\tcase '/':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l / r)\n\t\t\t\tcase '%':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l % r)\n\t\t\t\tdefault:\n\t\t\t\t\treturn 0\n\t\t\t}\n\t\t}\n\t\tcase 'fn': {\n\t\t\t// Built-ins always win, so a resolved custom fn can never shadow the canonical grammar.\n\t\t\tconst fn = FUNCTIONS[expr.fn] ?? resolved?.functions?.[expr.fn]\n\t\t\tif (!fn) {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t\tconst args = Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, ctx, depth + 1)) : []\n\t\t\t// Custom fns are host code: a throw must degrade to 0, not break the whole evaluation.\n\t\t\ttry {\n\t\t\t\treturn finite(fn(args))\n\t\t\t} catch {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t}\n\t\tcase 'weight': {\n\t\t\t// A sourced weight always reads the resolved map (missing map -> 0 per chosen); inline weights are ignored.\n\t\t\tconst weights =\n\t\t\t\ttypeof expr.source === 'string'\n\t\t\t\t\t? (resolved?.weights?.[calcWeightKey(expr.source, expr.field)] ?? {})\n\t\t\t\t\t: (expr.weights ?? {})\n\t\t\tconst value = answers[expr.field]\n\t\t\tconst chosen = Array.isArray(value) ? value : value == null || value === '' ? [] : [value]\n\t\t\treturn finite(\n\t\t\t\tchosen.reduce((sum: number, v: unknown) => sum + toNumber(weights[String(v)]), 0)\n\t\t\t)\n\t\t}\n\t\tdefault:\n\t\t\treturn 0\n\t}\n}\n\n/**\n * Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod\n * by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). `resolved` supplies\n * server-resolved source values, sourced weight maps, and custom functions; any extension node\n * whose value is absent evaluates to 0.\n */\nexport const evaluateCalc = (\n\texpr: CalcExpression | null | undefined,\n\tanswers: Record<string, unknown>,\n\tresolved?: CalcResolved\n): number => {\n\tif (!expr) {\n\t\treturn 0\n\t}\n\treturn evalNode(expr, { answers, resolved }, 0)\n}\n"],"mappings":";;;;;;;AAqBA,MAAa,iBAAiB,QAAgB,UAA0B,GAAG,OAAO,GAAG;AAErF,MAAM,YAAY,UAA2B;CAC5C,IAAI,OAAO,UAAU,UACpB,OAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;CAEzC,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,IAAI;EACrD,MAAM,IAAI,OAAO,KAAK;EACtB,OAAO,OAAO,SAAS,CAAC,IAAI,IAAI;CACjC;CACA,OAAO;AACR;AAEA,MAAM,UAAU,MAAuB,OAAO,SAAS,CAAC,IAAI,IAAI;AAEhE,MAAM,YAAwD;CAC7D,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;CAClC,MAAM,MAAM,KAAK,IAAI,EAAE,MAAM,CAAC;CAC9B,OAAO,MAAM,KAAK,KAAK,EAAE,MAAM,CAAC;CAChC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;AACnC;AAIA,MAAM,YAAY,MAAsB,KAAkB,UAA0B;CACnF,IAAI,QAAA,MAAqB,QAAQ,QAAQ,OAAO,SAAS,UACxD,OAAO;CAER,MAAM,EAAE,SAAS,aAAa;CAC9B,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,SAAS,KAAK,KAAK,CAAC;EACnC,KAAK,OACJ,OAAO,SAAS,QAAQ,KAAK,MAAM;EACpC,KAAK,UACJ,OAAO,SAAS,UAAU,UAAU,KAAK,OAAO;EACjD,KAAK,OACJ,OAAO,OAAO,CAAC,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAC,CAAC;EACtD,KAAK,MAAM;GACV,MAAM,IAAI,SAAS,KAAK,MAAM,KAAK,QAAQ,CAAC;GAC5C,MAAM,IAAI,SAAS,KAAK,OAAO,KAAK,QAAQ,CAAC;GAC7C,QAAQ,KAAK,IAAb;IACC,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,SACC,OAAO;GACT;EACD;EACA,KAAK,MAAM;GAEV,MAAM,KAAK,UAAU,KAAK,OAAO,UAAU,YAAY,KAAK;GAC5D,IAAI,CAAC,IACJ,OAAO;GAER,MAAM,OAAO,MAAM,QAAQ,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC;GAE7F,IAAI;IACH,OAAO,OAAO,GAAG,IAAI,CAAC;GACvB,QAAQ;IACP,OAAO;GACR;EACD;EACA,KAAK,UAAU;GAEd,MAAM,UACL,OAAO,KAAK,WAAW,WACnB,UAAU,UAAU,cAAc,KAAK,QAAQ,KAAK,KAAK,MAAM,CAAC,IAChE,KAAK,WAAW,CAAC;GACtB,MAAM,QAAQ,QAAQ,KAAK;GAE3B,OAAO,QADQ,MAAM,QAAQ,KAAK,IAAI,QAAQ,SAAS,QAAQ,UAAU,KAAK,CAAC,IAAI,CAAC,KAAK,GAEjF,QAAQ,KAAa,MAAe,MAAM,SAAS,QAAQ,OAAO,CAAC,EAAE,GAAG,CAAC,CACjF;EACD;EACA,SACC,OAAO;CACT;AACD;;;;;;;AAQA,MAAa,gBACZ,MACA,SACA,aACY;CACZ,IAAI,CAAC,MACJ,OAAO;CAER,OAAO,SAAS,MAAM;EAAE;EAAS;CAAS,GAAG,CAAC;AAC/C"}
@@ -0,0 +1,11 @@
1
+ import { CalcExpression } from "./types.js";
2
+
3
+ //#region src/calc/formatCalc.d.ts
4
+ /**
5
+ * Renders a CalcExpression as a human-readable one-line string for the visual expression builder's
6
+ * live preview. `sourceLabel` resolves a calc source key to its display label (raw key when omitted).
7
+ */
8
+ declare const formatCalc: (expr: CalcExpression, labelOf: (field: string) => string, sourceLabel?: (key: string) => string) => string;
9
+ //#endregion
10
+ export { formatCalc };
11
+ //# sourceMappingURL=formatCalc.d.ts.map
@@ -0,0 +1,35 @@
1
+ //#region src/calc/formatCalc.ts
2
+ const OP_GLYPHS = {
3
+ "+": "+",
4
+ "-": "−",
5
+ "*": "×",
6
+ "/": "÷",
7
+ "%": "%"
8
+ };
9
+ const wrap = (child, resolvers, wrapTypes = ["op"]) => {
10
+ const formatted = format(child, resolvers);
11
+ return wrapTypes.includes(child.type) ? `(${formatted})` : formatted;
12
+ };
13
+ const format = (expr, resolvers) => {
14
+ switch (expr.type) {
15
+ case "lit": return String(expr.value);
16
+ case "ref": return resolvers.labelOf(expr.field);
17
+ case "neg": return `−${wrap(expr.operand, resolvers, ["op", "neg"])}`;
18
+ case "op": return `${wrap(expr.left, resolvers)} ${OP_GLYPHS[expr.op]} ${wrap(expr.right, resolvers)}`;
19
+ case "fn": return `${expr.fn}(${expr.args.map((arg) => format(arg, resolvers)).join(", ")})`;
20
+ case "source": return resolvers.sourceLabel(expr.source);
21
+ case "weight": return `weights(${resolvers.labelOf(expr.field)})`;
22
+ }
23
+ };
24
+ /**
25
+ * Renders a CalcExpression as a human-readable one-line string for the visual expression builder's
26
+ * live preview. `sourceLabel` resolves a calc source key to its display label (raw key when omitted).
27
+ */
28
+ const formatCalc = (expr, labelOf, sourceLabel = (key) => key) => format(expr, {
29
+ labelOf,
30
+ sourceLabel
31
+ });
32
+ //#endregion
33
+ export { formatCalc };
34
+
35
+ //# sourceMappingURL=formatCalc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatCalc.js","names":[],"sources":["../../src/calc/formatCalc.ts"],"sourcesContent":["import type { CalcExpression, CalcOp } from './types'\n\nconst OP_GLYPHS: Record<CalcOp, string> = {\n\t'+': '+',\n\t'-': '−',\n\t'*': '×',\n\t'/': '÷',\n\t'%': '%',\n}\n\ntype LabelResolvers = {\n\tlabelOf: (field: string) => string\n\tsourceLabel: (key: string) => string\n}\n\nconst wrap = (\n\tchild: CalcExpression,\n\tresolvers: LabelResolvers,\n\twrapTypes: readonly CalcExpression['type'][] = ['op']\n): string => {\n\tconst formatted = format(child, resolvers)\n\treturn wrapTypes.includes(child.type) ? `(${formatted})` : formatted\n}\n\nconst format = (expr: CalcExpression, resolvers: LabelResolvers): string => {\n\tswitch (expr.type) {\n\t\tcase 'lit':\n\t\t\treturn String(expr.value)\n\t\tcase 'ref':\n\t\t\treturn resolvers.labelOf(expr.field)\n\t\tcase 'neg':\n\t\t\t// Parenthesize a nested `neg` operand too, otherwise \"-(-5)\" glues into the ambiguous \"--5\".\n\t\t\treturn `−${wrap(expr.operand, resolvers, ['op', 'neg'])}`\n\t\tcase 'op':\n\t\t\treturn `${wrap(expr.left, resolvers)} ${OP_GLYPHS[expr.op]} ${wrap(expr.right, resolvers)}`\n\t\tcase 'fn':\n\t\t\treturn `${expr.fn}(${expr.args.map((arg) => format(arg, resolvers)).join(', ')})`\n\t\tcase 'source':\n\t\t\treturn resolvers.sourceLabel(expr.source)\n\t\tcase 'weight':\n\t\t\treturn `weights(${resolvers.labelOf(expr.field)})`\n\t}\n}\n\n/**\n * Renders a CalcExpression as a human-readable one-line string for the visual expression builder's\n * live preview. `sourceLabel` resolves a calc source key to its display label (raw key when omitted).\n */\nexport const formatCalc = (\n\texpr: CalcExpression,\n\tlabelOf: (field: string) => string,\n\tsourceLabel: (key: string) => string = (key) => key\n): string => format(expr, { labelOf, sourceLabel })\n"],"mappings":";AAEA,MAAM,YAAoC;CACzC,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;AACN;AAOA,MAAM,QACL,OACA,WACA,YAA+C,CAAC,IAAI,MACxC;CACZ,MAAM,YAAY,OAAO,OAAO,SAAS;CACzC,OAAO,UAAU,SAAS,MAAM,IAAI,IAAI,IAAI,UAAU,KAAK;AAC5D;AAEA,MAAM,UAAU,MAAsB,cAAsC;CAC3E,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,KAAK,KAAK;EACzB,KAAK,OACJ,OAAO,UAAU,QAAQ,KAAK,KAAK;EACpC,KAAK,OAEJ,OAAO,IAAI,KAAK,KAAK,SAAS,WAAW,CAAC,MAAM,KAAK,CAAC;EACvD,KAAK,MACJ,OAAO,GAAG,KAAK,KAAK,MAAM,SAAS,EAAE,GAAG,UAAU,KAAK,IAAI,GAAG,KAAK,KAAK,OAAO,SAAS;EACzF,KAAK,MACJ,OAAO,GAAG,KAAK,GAAG,GAAG,KAAK,KAAK,KAAK,QAAQ,OAAO,KAAK,SAAS,CAAC,EAAE,KAAK,IAAI,EAAE;EAChF,KAAK,UACJ,OAAO,UAAU,YAAY,KAAK,MAAM;EACzC,KAAK,UACJ,OAAO,WAAW,UAAU,QAAQ,KAAK,KAAK,EAAE;CAClD;AACD;;;;;AAMA,MAAa,cACZ,MACA,SACA,eAAwC,QAAQ,QACpC,OAAO,MAAM;CAAE;CAAS;AAAY,CAAC"}
@@ -0,0 +1,17 @@
1
+ //#region src/calc/formatCalcValue.d.ts
2
+ /** The calculation field's display settings (`decimals`, `prefix`, `suffix` config fields). */
3
+ type CalcDisplayConfig = {
4
+ decimals?: unknown;
5
+ prefix?: unknown;
6
+ suffix?: unknown;
7
+ };
8
+ /**
9
+ * Formats a computed calculation value for display: fixed `decimals` when configured, wrapped in
10
+ * `prefix`/`suffix` exactly as authored (no injected spaces; authors control spacing in their
11
+ * strings). Shared by the visitor renderer and the definition's `format` so the frontend, admin
12
+ * answers view, emails, and recall all agree.
13
+ */
14
+ declare const formatCalcValue: (value: number, config?: CalcDisplayConfig) => string;
15
+ //#endregion
16
+ export { CalcDisplayConfig, formatCalcValue };
17
+ //# sourceMappingURL=formatCalcValue.d.ts.map
@@ -0,0 +1,17 @@
1
+ //#region src/calc/formatCalcValue.ts
2
+ const asDecimals = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 6 ? value : void 0;
3
+ /**
4
+ * Formats a computed calculation value for display: fixed `decimals` when configured, wrapped in
5
+ * `prefix`/`suffix` exactly as authored (no injected spaces; authors control spacing in their
6
+ * strings). Shared by the visitor renderer and the definition's `format` so the frontend, admin
7
+ * answers view, emails, and recall all agree.
8
+ */
9
+ const formatCalcValue = (value, config) => {
10
+ const decimals = asDecimals(config?.decimals);
11
+ const number = decimals !== void 0 ? value.toFixed(decimals) : String(value);
12
+ return `${typeof config?.prefix === "string" ? config.prefix : ""}${number}${typeof config?.suffix === "string" ? config.suffix : ""}`;
13
+ };
14
+ //#endregion
15
+ export { formatCalcValue };
16
+
17
+ //# sourceMappingURL=formatCalcValue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatCalcValue.js","names":[],"sources":["../../src/calc/formatCalcValue.ts"],"sourcesContent":["/** The calculation field's display settings (`decimals`, `prefix`, `suffix` config fields). */\nexport type CalcDisplayConfig = {\n\tdecimals?: unknown\n\tprefix?: unknown\n\tsuffix?: unknown\n}\n\nconst asDecimals = (value: unknown): number | undefined =>\n\ttypeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6\n\t\t? value\n\t\t: undefined\n\n/**\n * Formats a computed calculation value for display: fixed `decimals` when configured, wrapped in\n * `prefix`/`suffix` exactly as authored (no injected spaces; authors control spacing in their\n * strings). Shared by the visitor renderer and the definition's `format` so the frontend, admin\n * answers view, emails, and recall all agree.\n */\nexport const formatCalcValue = (value: number, config?: CalcDisplayConfig): string => {\n\tconst decimals = asDecimals(config?.decimals)\n\tconst number = decimals !== undefined ? value.toFixed(decimals) : String(value)\n\tconst prefix = typeof config?.prefix === 'string' ? config.prefix : ''\n\tconst suffix = typeof config?.suffix === 'string' ? config.suffix : ''\n\treturn `${prefix}${number}${suffix}`\n}\n"],"mappings":";AAOA,MAAM,cAAc,UACnB,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,IAC5E,QACA,KAAA;;;;;;;AAQJ,MAAa,mBAAmB,OAAe,WAAuC;CACrF,MAAM,WAAW,WAAW,QAAQ,QAAQ;CAC5C,MAAM,SAAS,aAAa,KAAA,IAAY,MAAM,QAAQ,QAAQ,IAAI,OAAO,KAAK;CAG9E,OAAO,GAFQ,OAAO,QAAQ,WAAW,WAAW,OAAO,SAAS,KAEjD,SADJ,OAAO,QAAQ,WAAW,WAAW,OAAO,SAAS;AAErE"}
@@ -1,8 +1,22 @@
1
1
  import { CalcExpression } from "./types.js";
2
2
 
3
3
  //#region src/calc/normalizeCalc.d.ts
4
- /** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */
5
- declare const normalizeCalc: (value: unknown) => CalcExpression | undefined;
4
+ /**
5
+ * The extension names a normalized expression may reference: registered calc source keys and custom
6
+ * function names (plugin option `calc`). Absent (the default for every un-threaded caller), only the
7
+ * built-in grammar is valid: `source` nodes, sourced weights, and custom fns all reject.
8
+ */
9
+ type CalcAllowed = {
10
+ sources?: ReadonlySet<string>;
11
+ functions?: ReadonlySet<string>;
12
+ };
13
+ /**
14
+ * Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded
15
+ * at 64), otherwise undefined. Never throws. `allowed` admits registered extension names (source
16
+ * keys, custom function names); without it only the built-in grammar passes, so an un-threaded
17
+ * caller can never validate an expression the evaluator has no values for.
18
+ */
19
+ declare const normalizeCalc: (value: unknown, allowed?: CalcAllowed) => CalcExpression | undefined;
6
20
  //#endregion
7
- export { normalizeCalc };
21
+ export { CalcAllowed, normalizeCalc };
8
22
  //# sourceMappingURL=normalizeCalc.d.ts.map
@@ -1,9 +1,8 @@
1
- import { CALC_FNS, CALC_OPS } from "./types.js";
1
+ import { CALC_OPS, isCalcFn } from "./types.js";
2
2
  //#region src/calc/normalizeCalc.ts
3
3
  const isOp = (v) => CALC_OPS.includes(v);
4
- const isFn = (v) => CALC_FNS.includes(v);
5
4
  const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6
- const normalizeNode = (value, depth) => {
5
+ const normalizeNode = (value, depth, allowed) => {
7
6
  if (depth > 64) return void 0;
8
7
  if (!isRecord(value)) return void 0;
9
8
  switch (value.type) {
@@ -26,9 +25,9 @@ const normalizeNode = (value, depth) => {
26
25
  case "op": {
27
26
  const { op, left, right } = value;
28
27
  if (!isOp(op)) return void 0;
29
- const l = normalizeNode(left, depth + 1);
28
+ const l = normalizeNode(left, depth + 1, allowed);
30
29
  if (!l) return void 0;
31
- const r = normalizeNode(right, depth + 1);
30
+ const r = normalizeNode(right, depth + 1, allowed);
32
31
  if (!r) return void 0;
33
32
  return {
34
33
  type: "op",
@@ -38,7 +37,7 @@ const normalizeNode = (value, depth) => {
38
37
  };
39
38
  }
40
39
  case "neg": {
41
- const operand = normalizeNode(value.operand, depth + 1);
40
+ const operand = normalizeNode(value.operand, depth + 1, allowed);
42
41
  if (!operand) return void 0;
43
42
  return {
44
43
  type: "neg",
@@ -47,11 +46,12 @@ const normalizeNode = (value, depth) => {
47
46
  }
48
47
  case "fn": {
49
48
  const { fn, args } = value;
50
- if (!isFn(fn)) return void 0;
49
+ if (typeof fn !== "string") return void 0;
50
+ if (!isCalcFn(fn) && !allowed?.functions?.has(fn)) return void 0;
51
51
  if (!Array.isArray(args)) return void 0;
52
52
  const normalizedArgs = [];
53
53
  for (const arg of args) {
54
- const a = normalizeNode(arg, depth + 1);
54
+ const a = normalizeNode(arg, depth + 1, allowed);
55
55
  if (!a) return void 0;
56
56
  normalizedArgs.push(a);
57
57
  }
@@ -61,26 +61,48 @@ const normalizeNode = (value, depth) => {
61
61
  args: normalizedArgs
62
62
  };
63
63
  }
64
+ case "source": {
65
+ const { source } = value;
66
+ if (typeof source !== "string") return void 0;
67
+ if (!allowed?.sources?.has(source)) return void 0;
68
+ return {
69
+ type: "source",
70
+ source
71
+ };
72
+ }
64
73
  case "weight": {
65
- const { field, weights } = value;
74
+ const { field, weights, source } = value;
66
75
  if (typeof field !== "string") return void 0;
67
- if (!isRecord(weights)) return void 0;
68
- const normalizedWeights = {};
69
- for (const [k, v] of Object.entries(weights)) {
70
- if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
71
- normalizedWeights[k] = v;
76
+ if (source !== void 0) {
77
+ if (typeof source !== "string" || !allowed?.sources?.has(source)) return void 0;
78
+ }
79
+ let normalizedWeights;
80
+ if (weights !== void 0) {
81
+ if (!isRecord(weights)) return void 0;
82
+ normalizedWeights = {};
83
+ for (const [k, v] of Object.entries(weights)) {
84
+ if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
85
+ normalizedWeights[k] = v;
86
+ }
72
87
  }
88
+ if (normalizedWeights === void 0 && source === void 0) return void 0;
73
89
  return {
74
90
  type: "weight",
75
91
  field,
76
- weights: normalizedWeights
92
+ ...normalizedWeights !== void 0 ? { weights: normalizedWeights } : {},
93
+ ...typeof source === "string" ? { source } : {}
77
94
  };
78
95
  }
79
96
  default: return;
80
97
  }
81
98
  };
82
- /** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */
83
- const normalizeCalc = (value) => normalizeNode(value, 0);
99
+ /**
100
+ * Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded
101
+ * at 64), otherwise undefined. Never throws. `allowed` admits registered extension names (source
102
+ * keys, custom function names); without it only the built-in grammar passes, so an un-threaded
103
+ * caller can never validate an expression the evaluator has no values for.
104
+ */
105
+ const normalizeCalc = (value, allowed) => normalizeNode(value, 0, allowed);
84
106
  //#endregion
85
107
  export { normalizeCalc };
86
108