@aglyn/aglyn 1.0.0-beta.237 → 1.0.0-beta.239

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.
@@ -163,6 +163,17 @@ export interface FormDocument<N = AglynNodeSchema> {
163
163
  fields: FormFieldDecl[];
164
164
  /** Names the entry in `fields` that IS the marketing opt-in. */
165
165
  consentFieldName?: string;
166
+ /**
167
+ * A SIGN-UP form: submitting it is the opt-in.
168
+ *
169
+ * For a form whose one purpose is subscribing ("Get product updates",
170
+ * an email field and a Subscribe button), where a box to tick beside the
171
+ * button would ask the same question twice. Set by the merchant on the
172
+ * form, never inferred, so every other form keeps the rule that the fact
173
+ * of submission is not consent. Read by `/api/forms/submit`, which then
174
+ * records the opt-in and makes the person at least a subscriber.
175
+ */
176
+ optInOnSubmit?: boolean;
166
177
  routing?: FormRouting;
167
178
  legacyMatch?: FormLegacyMatch;
168
179
  stats?: FormStats;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/forms.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The form entity: what a form IS, separately from the shape an author drew.\n *\n * `docs/specs/reusable-forms.md` §2. Before this module a form's whole\n * identity was `formName` — the free-text caption an author typed into an\n * inspector field, copied onto each submission at write time and reconciled\n * with nothing. Renaming a form split its history; two pages sharing a label\n * were one list. A document at `hosts/{hostId}/forms/{formId}` is the identity\n * those surfaces were reading a caption in place of.\n *\n * Pure and dependency-free on purpose. This barrel reaches client bundles\n * through `app-utils/index`, so nothing here may import a Node builtin — the\n * constraint that holds `personKey` in its own module one directory over.\n * Everything that needs Firestore or `node:crypto` lives at the call sites.\n */\n\nimport type { AglynNodeSchema, NodeId } from '../foundation/definitions/components.types'\nimport { containerMembershipValue } from './container-membership'\nimport type { PlacementKind } from './compose-reusable-components'\nimport { utcMonthKey } from './utc-month'\nimport { displayNameSearchFields, nameSearchTokens } from './name-search'\n\n/**\n * The `componentId` of the node that RENDERS a form, and of the node that\n * renders one of its fields.\n *\n * Persisted in screen, layout, component and form documents — never rename.\n * Restated as constants because the walkers below, the promotion route and the\n * graft all have to name the same two ids, and a copy that drifted would read\n * as \"this page has no form\" rather than as an error.\n */\nexport const FORM_COMPONENT_ID = 'form'\nexport const FORM_FIELD_COMPONENT_ID = 'formField'\n\n/**\n * The prop on a `form` node naming the entity it is a placement OF.\n *\n * Persisted in screen documents — never rename.\n */\nexport const FORM_ID_PROP = 'formId'\n\n/**\n * The settings a saved form's own root carries for every page that places it\n * (AGL-3494): its caption, where its answers go, its button, its message and\n * what a successful submit does.\n *\n * A placement renders the form's values for each of these unless it sets its\n * own (`placementPropsOverRoot`), so a value a page's form node merely STARTED\n * with — a preset's \"Send message\", a caption typed before the form was picked\n * — would silently pin that page to it. {@link formPropsOnBind} is what keeps\n * those starting values from becoming overrides.\n */\nexport const FORM_ENTITY_OWNED_PROPS: readonly string[] = [\n 'formName',\n 'datasetId',\n 'datasetName',\n 'submitLabel',\n 'successMessage',\n 'afterSubmit',\n 'redirectScreenId',\n 'redirectUrl',\n 'revealNodeId',\n]\n\n/**\n * A form node's props as they are saved when the author PICKS a saved form for\n * it (AGL-3494): the form's own settings ({@link FORM_ENTITY_OWNED_PROPS}) are\n * dropped from the page's copy, so the placement shows the saved form's.\n *\n * Picking a form is the moment the node stops being the page's own form and\n * becomes a placement of that one: its fields are already replaced by the\n * form's, and its label and message follow. Anything the page wrote before\n * that moment was written for the node it used to be — the Contact Section\n * preset seeds \"Send message\" and a thank-you line, the Contact Form preset a\n * caption — and kept, each would read as a deliberate per-page override and\n * mask the form's value forever. A label set AFTER the form is picked is\n * written on the placement and wins, which is the per-page change the merge\n * honors.\n *\n * Only a change of form clears anything. Re-saving a placement of the same\n * form, editing any other attribute, or unpicking the form leaves the props\n * exactly as given — an existing placement keeps every value it carries.\n */\nexport function formPropsOnBind(\n previousProps: Record<string, unknown> | undefined | null,\n nextProps: Record<string, unknown>,\n): Record<string, unknown> {\n const nextFormId = nextProps[FORM_ID_PROP]\n if (typeof nextFormId !== 'string' || !nextFormId.trim()) return nextProps\n if (previousProps?.[FORM_ID_PROP] === nextFormId) return nextProps\n const bound = { ...nextProps }\n for (const key of FORM_ENTITY_OWNED_PROPS) delete bound[key]\n return bound\n}\n\n/**\n * How many forms one query for a site's catalog reads.\n *\n * ⛔ **NOT the allowance.** How many forms a site may hold is\n * `PLAN_ENTITLEMENTS[plan].formsPerHost`, enforced at creation through\n * `checkQuota` in `/api/hosts/resources`. A surface that shows a customer\n * their ceiling MUST read that entitlement — this number is larger, so\n * reading it instead overstates the cap on every plan.\n *\n * What this bounds is a READ. `hosts/{hostId}/forms` is small enough to list\n * in one page, and the listing surfaces (the submissions filter, the entity\n * picker) say so — but a bound below the allowance would make them silently\n * drop forms the customer made and can see elsewhere, which is the invisible\n * half of a wrong limit and the expensive one to discover.\n *\n * So it sits at or above every per-plan allowance, with headroom for the\n * catalogs that legitimately exceed one: a per-org contract override, and any\n * site that built past a ceiling before it was lowered. `forms.spec.ts` pins\n * that relationship rather than trusting the two numbers to be moved\n * together. A catalog past even this needs real pagination, not a larger\n * constant.\n */\nexport const FORMS_MAX_PER_HOST = 1000\n\n/** Field types a `FormField` node offers; the form declares the same set. */\nexport type FormFieldType =\n | 'text'\n | 'email'\n | 'textarea'\n | 'select'\n | 'radio'\n | 'checkbox'\n | 'rating'\n\n/**\n * What a declared field MEANS, where the meaning is one the platform reads.\n *\n * The Inbox guesses today: `submission-presenter.ts` matches reduced field\n * keys against a convention list, which is why a survey whose fields are\n * `q1`…`q9` renders \"Someone\" on every row. A declared role is the author\n * saying which field is the address instead of the platform inferring it from\n * a name that was never a contract.\n */\nexport type FormFieldRole = 'name' | 'email' | 'phone' | 'consent'\n\n/** One field of a form, as the form declares it. */\nexport interface FormFieldDecl {\n /** THE submission key — matches the node's `fieldName` prop, not `label`. */\n fieldName: string\n label?: string\n fieldType: FormFieldType\n required?: boolean\n options?: string[]\n /** Stable dataset model fieldId this value is stored under (AGL-556). */\n datasetFieldId?: string\n role?: FormFieldRole\n /**\n * The custom contact field this value is saved under (AGL-2601) — a\n * `ContactFieldDefinition.key` in the org that owns the site.\n *\n * Edited on the form's own page rather than drawn on the canvas, so it is\n * NOT read off the nodes: `carryContactFieldMappings` carries it across a\n * publish by `fieldName`, or a design change would silently unmap every\n * field. The built-in properties — name, email, phone — are what `role`\n * names; this is only ever a field the merchant defined.\n */\n contactFieldKey?: string\n}\n\n/** Where a submission goes beyond the Inbox. */\nexport interface FormRouting {\n datasetId?: string\n /** Whether a submission carrying an address also becomes a lead. */\n lead?: boolean\n}\n\n/**\n * What an adopted form claims of the history that predates it.\n *\n * Recorded at adoption from what was ACTUALLY written on the submissions —\n * the caption and the page path — because those two fields are the whole of\n * what a pre-entity submission carries. Read only by the backfill, and never\n * by a live query: the live filter is an equality on `formId`.\n */\nexport interface FormLegacyMatch {\n formName: string\n paths: string[]\n}\n\n/** The stored form document, minus the timestamps Firestore stamps. */\nexport interface FormDocument<N = AglynNodeSchema> {\n displayName: string\n slug: string\n fields: FormFieldDecl[]\n /** Names the entry in `fields` that IS the marketing opt-in. */\n consentFieldName?: string\n routing?: FormRouting\n legacyMatch?: FormLegacyMatch\n stats?: FormStats\n /**\n * When this form was RETIRED, or absent while it is in use (AGL-2671).\n *\n * A retired form is kept, never deleted: its submissions, its leads and the\n * contact timeline they built are the reason it still exists, and a form is\n * the key those rows are filed under. What retirement changes is that it\n * stops being a live lead surface — it leaves the forms list, it stops\n * being graded by `/api/health/funnel`, and `/api/forms/submit` refuses it.\n *\n * `unknown` because the stored value is not one shape: the console writes a\n * number, and a marker already in production is a Firestore `Timestamp`.\n * {@link isFormArchived} is the only thing that should read it, and it asks\n * about presence rather than about what kind of clock wrote it.\n */\n archivedAt?: unknown\n /**\n * Whether the form is retired, as a boolean the Forms list can QUERY\n * (AGL-3330). {@link archivedAt} stays the fact every other reader asks,\n * through {@link isFormArchived}; this mirrors it because Firestore cannot\n * find a document by the absence of a field, and \"in use\" is an absence\n * there. Written beside `archivedAt` by the one writer that retires a form,\n * `false` on every create — see {@link newFormListFields}.\n */\n retired?: boolean\n /** The campaigns the form is filed under — see {@link formCampaignFields}. */\n campaignIds?: string[]\n /**\n * Whether the form is in any campaign, as a boolean the Forms list can\n * QUERY (AGL-3330): \"in no campaign\" is an empty array, and Firestore can\n * neither find a document by an absent field nor ask an array for its\n * length. Written beside `campaignIds` by every writer of it, through\n * {@link formCampaignFields}.\n */\n inCampaign?: boolean\n /** The Forms list's search fields — see {@link formListFields}. */\n nameLower?: string\n nameTokens?: string[]\n nameReversed?: string\n searchTokens?: string[]\n /*\n * ── THE DESIGN ───────────────────────────────────────────────────────────\n *\n * A form is authored in the besigner, so it carries a node tree and version\n * history exactly as `AglynHostComponent` does, and for the same reason it\n * is stored the same way: `rootId` and `nodes` here are the PUBLISHED\n * snapshot, while the working draft lives compressed in\n * `hosts/{hostId}/forms/{formId}/versions/{versionId}`.\n *\n * The asymmetry is deliberate and copied rather than reinvented. Every\n * placed form has to resolve on the hot path of a published page render, so\n * the published tree stays on the parent document where one collection\n * query reaches all of them; moving it into the version docs would turn\n * that query into N+1 (AGL-679).\n */\n rootId?: NodeId\n nodes?: Record<NodeId, N>\n /**\n * Which version is published.\n *\n * The same pointer `AglynHostComponent.versionId` is, including the rule\n * that only a publisher may move it — the rules block denies an author the\n * `versionId` key for components and this document is governed the same way.\n */\n versionId?: string\n}\n\n/**\n * Whether a form has been retired — see {@link FormDocument.archivedAt}.\n *\n * PRESENCE, not a number. Absent, `null` and `0` are \"in use\"; any other\n * value is a retirement marker.\n *\n * ⛔ Deliberately NOT shaped like `isPipelineArchived`, which asks for a\n * positive number. A pipeline stores its own numeric stamp; a form's is\n * written by whatever retired it, and the marker already in production on at\n * least one form is a Firestore `Timestamp` rather than a number. The two\n * readers that predate this function — the list filter and\n * `/api/health/funnel`'s probe — both test truthiness, so a stricter rule\n * here does not tighten anything: it just disagrees with them, and the\n * disagreement surfaces as a retired form reappearing in the catalog while\n * the funnel goes on ignoring it.\n *\n * Measured, not assumed: with the numeric rule this function un-hid a form\n * the probe was still skipping, and the two counts were 5 against 4.\n *\n * ⚠️ Asked of the STORED document. A caller that has already filtered a list\n * must not re-derive this from a display flag; the field is the fact.\n */\nexport function isFormArchived(\n form: Pick<FormDocument, 'archivedAt'> | null | undefined,\n): boolean {\n return Boolean(form?.archivedAt)\n}\n\n/** The search fields the Forms list queries — see {@link formListFields}. */\nexport interface FormListSearchFields {\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n searchTokens: string[]\n}\n\n/**\n * The fields the Forms list FILTERS AND SEARCHES BY ON THE QUERY (AGL-3330).\n *\n * The list pages a site's catalog, so a filter that matched the rows already\n * read would answer \"no such form\" for one on a later page. Firestore has no\n * case-insensitive or substring match, so the name travels with the keys\n * every document named by `displayName` carries\n * (`displayNameSearchFields`): `nameLower`, `nameTokens`, `nameReversed`.\n * The Display name filter asks `nameTokens`.\n *\n * `searchTokens` is what the list's search box asks with one\n * `array-contains`: the word prefixes of the name AND of the slug (its words\n * are hyphen-joined) AND the lower-cased prefixes of the form's id, because\n * the box finds a form by any of the three. A slug usually repeats its name's\n * words, so the union mostly costs nothing; it earns its place on a form\n * renamed after its slug was minted.\n *\n * `tools/scripts/lib/site-form-list-fields.mjs` is the script-side twin, and both\n * answer `tools/scripts/lib/site-form-list-fields.fixtures.json`.\n *\n * ⚠️ Spread at EVERY write that sets `displayName` or `slug`, and at create.\n * A rename that skips it leaves the keys naming the old name, and the form is\n * then findable only by what it used to be called, while it still lists\n * normally.\n */\nexport function formListFields(form: {\n id: string\n displayName?: unknown\n slug?: unknown\n}): FormListSearchFields {\n const name = typeof form.displayName === 'string' ? form.displayName : ''\n const slugWords = (typeof form.slug === 'string' ? form.slug : '').replace(/-+/g, ' ')\n return {\n ...displayNameSearchFields(name),\n searchTokens: [\n ...new Set([\n ...nameSearchTokens(name),\n ...nameSearchTokens(slugWords),\n ...nameSearchTokens(form.id),\n ]),\n ],\n }\n}\n\n/**\n * The campaign fields a form is written with, from the campaigns it is filed\n * under (AGL-3330): the normalized ids, and `inCampaign`, the boolean the\n * Forms list's \"In a campaign\" filter asks by equality.\n *\n * ⚠️ The ONE way to write a form's `campaignIds`. A writer that sets the ids\n * without `inCampaign` leaves the form answering the filter by what it used\n * to be filed under; `apps/console/specs/form-writers-use-the-helpers.spec.ts`\n * refuses one. The campaign deletion pass removes an id with `arrayRemove`\n * and restamps `inCampaign` from the form as it then stands.\n */\nexport function formCampaignFields(selected: unknown): {\n campaignIds: string[]\n inCampaign: boolean\n} {\n const campaignIds = containerMembershipValue(Array.isArray(selected) ? selected : [])\n return { campaignIds, inCampaign: campaignIds.length > 0 }\n}\n\n/**\n * The `stats.leads` a form holds when nothing has been counted for it.\n *\n * A form that routes leads holds `0` until its first lead, so \"Leads = 0\"\n * finds the lead surfaces that have produced nothing yet. A form that does\n * not route leads holds `null`: it has no leads measurement, which is what\n * \"Leads is empty\" asks. The recount and every writer of the switch agree\n * through this and {@link formCountersFromSource}.\n */\nexport function formLeadsStatWhenUncounted(routesLeads: boolean): 0 | null {\n return routesLeads ? 0 : null\n}\n\n/**\n * What a NEW form is written with so the Forms list can query every filter\n * it offers (AGL-3330): its search keys, and an explicit value for each\n * field a query would otherwise have to find by its absence.\n *\n * - `retired: false`, the queryable mirror of an unset `archivedAt`.\n * - `routing.lead` as a boolean, `false` unless the form routes to leads, so\n * \"Lead routing is off\" is an equality rather than a missing field.\n * - `inCampaign`, from the campaigns the form is created in.\n * - `stats` with a NULL for each counter, except `leads` on a form that\n * routes leads, which starts at `0` (see {@link formLeadsStatWhenUncounted}).\n * Null, never zero, where nothing is measured: a zero would claim a\n * measurement. `/api/forms/submit` increments these, and an increment on\n * null starts from nothing, so it needs no change.\n *\n * `routing` keeps whatever else it carries (a dataset binding).\n */\nexport function newFormListFields(form: {\n id: string\n displayName?: unknown\n slug?: unknown\n routing?: FormRouting | null\n campaignIds?: unknown\n}): FormListSearchFields & {\n retired: false\n routing: FormRouting\n inCampaign: boolean\n stats: { submissions: null; leads: 0 | null; lastSubmissionAtMs: null }\n} {\n const routesLeads = form.routing?.lead === true\n return {\n ...formListFields(form),\n retired: false,\n routing: { ...(form.routing ?? {}), lead: routesLeads },\n inCampaign: formCampaignFields(form.campaignIds).inCampaign,\n stats: {\n submissions: null,\n leads: formLeadsStatWhenUncounted(routesLeads),\n lastSubmissionAtMs: null,\n },\n }\n}\n\n/*==========================================\n * THE COUNTERS, RECOUNTED FROM WHAT THEY COUNT (AGL-3330).\n *\n * `stats.submissions`, `stats.leads` and `stats.lastSubmissionAtMs` are kept\n * by increments on the submit path, which is cheap and never re-reads the\n * collection it counts. An increment cannot see a delete, a failed write or\n * a history written before it, so the stored figure drifts; the recount is\n * what puts it back, from the rows themselves:\n *\n * - submissions: the site's `formSubmissions` whose `formId` is the form;\n * - leads: the organization's leads whose `sources` name the form\n * (`form:{formId}`, {@link formLeadSource}) — PEOPLE this form brought in\n * that the workspace still holds, which is what the submit path counts\n * (a returning visitor, or a person already on the list through this\n * form, files no second lead);\n * - last submission: the newest of those submissions' `createdAt`.\n *\n * A server helper (`recountFormStats`) runs it after anything that removes a\n * row, and `tools/scripts/recount-form-stats.mjs` runs it over every form.\n * Both decide with the pure functions below; the script's twin answers\n * `tools/scripts/lib/site-form-stats-recount.fixtures.json`, as this does.\n *=========================================*/\n\n/** The source a lead filed by a form carries in `sources`. */\nexport const FORM_LEAD_SOURCE_PREFIX = 'form:'\n\n/** `form:{formId}` — the lead source a form's capture writes. */\nexport function formLeadSource(formId: string): string {\n return `${FORM_LEAD_SOURCE_PREFIX}${formId}`\n}\n\n/** The form ids a lead's `sources` name, in order, deduplicated. */\nexport function formIdsOfLeadSources(sources: unknown): string[] {\n if (!Array.isArray(sources)) return []\n const ids: string[] = []\n for (const source of sources) {\n if (typeof source !== 'string' || !source.startsWith(FORM_LEAD_SOURCE_PREFIX)) continue\n const id = source.slice(FORM_LEAD_SOURCE_PREFIX.length)\n if (id && !ids.includes(id)) ids.push(id)\n }\n return ids\n}\n\n/** The three counters the Forms list filters by. */\nexport interface FormCounterStats {\n submissions: number | null\n leads: number | null\n lastSubmissionAtMs: number | null\n}\n\n/** The counter fields, in reading order. */\nexport const FORM_COUNTER_FIELDS: readonly (keyof FormCounterStats)[] = [\n 'submissions',\n 'leads',\n 'lastSubmissionAtMs',\n]\n\n/**\n * The counters a form should hold, from the rows counted.\n *\n * Zero submissions is `null`, the list's \"none yet\", matching a new form.\n * Leads follow {@link formLeadsStatWhenUncounted} when there are none — and\n * a form that stopped routing leads keeps the leads it did file, because\n * those people are still on the list.\n */\nexport function formCountersFromSource(source: {\n submissions: number\n leads: number\n newestSubmissionAtMs: number | null\n routesLeads: boolean\n}): FormCounterStats {\n const submissions = source.submissions > 0 ? source.submissions : null\n return {\n submissions,\n leads: source.leads > 0 ? source.leads : formLeadsStatWhenUncounted(source.routesLeads),\n lastSubmissionAtMs:\n submissions !== null && typeof source.newestSubmissionAtMs === 'number'\n ? source.newestSubmissionAtMs\n : null,\n }\n}\n\n/**\n * How far the stored `lastSubmissionAtMs` may sit from the newest\n * submission's `createdAt` and still agree.\n *\n * The submit path stamps the counter from its own clock after it has written\n * the submission, whose `createdAt` is the server's commit time, and a lead\n * capture runs between the two — so a counter that is right reads a little\n * after the row. Ten minutes is far past that gap and far short of two\n * submissions a merchant would tell apart.\n */\nexport const FORM_LAST_SUBMISSION_TOLERANCE_MS = 10 * 60_000\n\n/** A stored counter as the recount compares it: a finite number, or `null`. */\nfunction storedCounter(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * The counters on which `stored` disagrees with `recounted`, in reading\n * order; empty when the form holds what its rows say.\n *\n * An ABSENT counter disagrees with a `null` one: \"is empty\" asks\n * `== null`, which a missing field does not answer.\n */\nexport function formCounterDrift(\n stored: Record<string, unknown> | null | undefined,\n recounted: FormCounterStats,\n): (keyof FormCounterStats)[] {\n const drift: (keyof FormCounterStats)[] = []\n for (const field of FORM_COUNTER_FIELDS) {\n const raw = stored?.[field]\n const value = storedCounter(raw)\n const want = recounted[field]\n if (raw === undefined || (value === null && raw !== null)) {\n drift.push(field)\n } else if (field === 'lastSubmissionAtMs' && value !== null && want !== null) {\n if (Math.abs(value - want) > FORM_LAST_SUBMISSION_TOLERANCE_MS) drift.push(field)\n } else if (value !== want) {\n drift.push(field)\n }\n }\n return drift\n}\n\n/** The dotted-path update that puts `recounted` on a form. */\nexport function formCounterPatch(recounted: FormCounterStats): Record<string, number | null> {\n return {\n 'stats.submissions': recounted.submissions,\n 'stats.leads': recounted.leads,\n 'stats.lastSubmissionAtMs': recounted.lastSubmissionAtMs,\n }\n}\n\n/**\n * One entry in a form's `versions` subcollection.\n *\n * The draft the besigner writes on every save. `nodes` arrives compressed\n * through the client converter, exactly as a component version's does, so\n * this declares the decompressed shape the hook hands back.\n */\nexport interface FormVersion<N = AglynNodeSchema> {\n formId: string\n hostId?: string\n displayName?: string\n rootId?: NodeId\n nodes?: Record<NodeId, N>\n}\n\n/**\n * Counters carried ON the form document, incremented on writes that were\n * happening anyway.\n *\n * Never derived by counting `formSubmissions`. That collection grows without\n * bound and is the one the customer is billed on; a console surface that\n * counted it on render would be the expensive-read shape this product has\n * created repeatedly. `hosts/{hostId}/overlays/{overlayId}.stats` is the same\n * pattern with the same reasoning. What an increment cannot see — a deleted\n * submission, an erased lead, a failed write — is put back by the RECOUNT\n * ({@link formCountersFromSource}), which runs after a removal and over every\n * form from `tools/scripts/recount-form-stats.mjs`; never on render.\n */\nexport interface FormStats {\n /*\n * `null` on a form created since AGL-3330, until the first submission:\n * written so the Forms list can query \"none yet\", which an absent field\n * cannot answer. A reader treats null and absent alike, as no figure.\n */\n submissions?: number | null\n /*\n * The people this form filed as leads that the workspace still holds: one\n * per lead whose `sources` name the form, however often they submitted.\n * `0` on a form that routes leads and has filed none, `null` on one that\n * has never routed any — see {@link formLeadsStatWhenUncounted}.\n */\n leads?: number | null\n lastSubmissionAtMs?: number | null\n /**\n * Form views, counted by the beacon at `/api/analytics/collect` — one per\n * rendered form on a live page, the same shape and the same cost as an\n * overlay impression.\n *\n * ⚠️ A CLIENT-SIDE COUNT, and every rate over it inherits that. A blocked\n * beacon, a browser that never runs the script and a crawler that renders\n * nothing are all views this does not hold, while `submissions` is counted\n * on the server and holds every one. So a completion rate over this can\n * legitimately exceed 100%, and it is reported rather than clamped: a\n * number capped at a round 100% looks like a measurement of a full house.\n */\n views?: number\n /** Forms a visitor typed into: one per form instance, on the first edit. */\n starts?: number\n /**\n * The same four counters, per calendar month.\n *\n * The series the detail surface draws, and — more importantly — what makes\n * a rate over `views` honest. The lifetime totals cannot be divided into\n * each other: `submissions` has counted since the form entity existed and\n * `views` only since the beacon shipped, so a lifetime completion rate\n * would divide a long history by a short one. {@link formStatsWindow} takes\n * every rate over the months that carry BOTH counters.\n *\n * Bounded by the calendar: twelve keys a year on a document with a megabyte\n * to spend. Keys are `utcMonthKey()` — the SAME function the\n * site-wide counter and the abuse ceiling are keyed by, imported rather\n * than restated, because a differently-derived month key reads zero on\n * exactly the months it disagrees about.\n */\n periods?: Record<string, FormPeriodStats>\n}\n\n/** One month of {@link FormStats}. Every field absent until first written. */\nexport interface FormPeriodStats {\n submissions?: number\n leads?: number\n views?: number\n starts?: number\n}\n\n/** The counters {@link FormPeriodStats} carries, in reading order. */\nexport type FormStatKind = 'views' | 'starts' | 'submissions' | 'leads'\n\nexport const FORM_STAT_KINDS: readonly FormStatKind[] = [\n 'views',\n 'starts',\n 'submissions',\n 'leads',\n] as const\n\n/** One month of a form's history, with every counter resolved to a number. */\nexport interface FormPeriodPoint extends Record<FormStatKind, number> {\n /** `YYYY-MM`. */\n period: string\n}\n\n/** The next month after `period`, or `null` for a key that is not one. */\nfunction nextPeriod(period: string): string | null {\n const match = /^(\\d{4})-(\\d{2})$/.exec(period)\n if (!match) return null\n const year = Number(match[1])\n const month = Number(match[2])\n if (month < 1 || month > 12) return null\n return month === 12\n ? `${year + 1}-01`\n : `${year}-${String(month + 1).padStart(2, '0')}`\n}\n\n/**\n * A form's history as a dense month series, from the first month anything was\n * recorded to the last.\n *\n * ⛔ THE SERIES NEVER STARTS BEFORE THE COUNTER DID. A month with no key is\n * two different facts — \"nothing happened\" and \"nothing was counted yet\" —\n * and they are told apart by WHERE the month falls: inside the recorded range\n * an absent key is a true zero, because the counter was live and wrote\n * nothing; before it, there is no measurement to draw and the series simply\n * does not extend there. Padding to a fixed twelve months would render the\n * form's pre-counter history as a row of confident zeros.\n *\n * Interior gaps ARE filled, at zero, so a quiet month reads as a quiet month\n * rather than closing up and making two distant months look adjacent.\n *\n * @param stats - the stored counters, or nothing.\n * @param maxPeriods - how many of the most recent months to return.\n * @returns oldest first, so a chart reads left to right. Empty when nothing\n * has ever been recorded.\n */\nexport function formPeriodSeries(\n stats: FormStats | undefined | null,\n maxPeriods = 12,\n): FormPeriodPoint[] {\n const periods = stats?.periods\n if (!periods) return []\n const keys = Object.keys(periods)\n .filter((key) => /^\\d{4}-(0[1-9]|1[0-2])$/.test(key))\n .sort()\n if (!keys.length) return []\n const series: FormPeriodPoint[] = []\n const last = keys[keys.length - 1]\n let cursor: string | null = keys[0]\n // Bounded by the span rather than by a `while (true)`: a stored key far in\n // the future would otherwise walk the calendar forever.\n for (let step = 0; cursor && step <= 1200; step += 1) {\n const month = periods[cursor] ?? {}\n series.push({\n period: cursor,\n views: Number(month.views ?? 0),\n starts: Number(month.starts ?? 0),\n submissions: Number(month.submissions ?? 0),\n leads: Number(month.leads ?? 0),\n })\n if (cursor === last) break\n cursor = nextPeriod(cursor)\n }\n return maxPeriods > 0 && series.length > maxPeriods\n ? series.slice(series.length - maxPeriods)\n : series\n}\n\n/** Two counters summed over the months where the FIRST of them was recorded. */\nexport interface FormStatsWindow {\n /** Months the window covers. Zero means no rate can be taken. */\n periods: number\n /** The counter the window is defined by, summed over those months. */\n over: number\n /** The other counter, summed over the SAME months. */\n of: number\n}\n\n/**\n * Sum `of` and `over` across exactly the months in which `over` was recorded.\n *\n * This is what stops a rate being a lie of arithmetic. `views` began being\n * counted the day the beacon shipped and `submissions` has counted since the\n * form entity existed, so dividing the lifetime totals answers \"submissions\n * ever, over views since Tuesday\" — a number that is not wrong by a little.\n *\n * A month is IN the window when it carries a non-zero `over`, not merely a\n * key: a month the beacon never reported is not a month with no views, it is\n * a month with no measurement, and including it would deflate every rate\n * taken over the window by however long the counter was dark.\n *\n * @returns `periods: 0` when nothing qualifies, which every caller must\n * render as a dash rather than as a zero rate.\n */\nexport function formStatsWindow(\n stats: FormStats | undefined | null,\n over: FormStatKind,\n of: FormStatKind,\n): FormStatsWindow {\n const window: FormStatsWindow = { periods: 0, over: 0, of: 0 }\n for (const month of Object.values(stats?.periods ?? {})) {\n const denominator = Number(month?.[over] ?? 0)\n if (!Number.isFinite(denominator) || denominator <= 0) continue\n window.periods += 1\n window.over += denominator\n const numerator = Number(month?.[of] ?? 0)\n if (Number.isFinite(numerator)) window.of += numerator\n }\n return window\n}\n\n/**\n * A span of calendar months, both ends inclusive, keyed as `YYYY-MM`.\n *\n * An absent end is OPEN rather than zero: a span that starts in March and\n * never closes covers every month from March onward. A span with neither end\n * is not a span, and {@link formStatsTotals} answers it with lifetime totals.\n */\nexport interface FormPeriodRange {\n from?: string | null\n to?: string | null\n}\n\n/**\n * The month key a moment falls in, or `null` for a moment that is not one.\n *\n * UTC, through {@link utcMonthKey}, because that is the function every\n * writer of `stats.periods` keys by. A month derived any other way reads zero\n * on exactly the months the two definitions disagree about — which is the\n * boundary month, the one a reader is most likely to be asking about.\n */\nexport function formPeriodKey(atMs: number | null | undefined): string | null {\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n return utcMonthKey(new Date(atMs))\n}\n\n/**\n * A form's four counters, summed, with what the sum covers.\n *\n * Every counter is `number | null` and the distinction is load-bearing. A\n * counter is `null` when nothing was recorded for it at all: `leads` is\n * counted only for a form whose `routing.lead` is set, so a form that does\n * not route leads has no leads measurement rather than a measured zero, and a\n * `0` in that slot states a result nobody took. A form that routes leads and\n * has filed none holds `0`, which is a measurement.\n */\nexport interface FormStatsTotals {\n views: number | null\n starts: number | null\n submissions: number | null\n leads: number | null\n /**\n * Month keys that contributed. `null` when the totals are lifetime, which\n * is what tells a caller whether the figures are confined to a span.\n */\n periods: number | null\n}\n\n/** The counters, with nothing recorded for any of them. */\nfunction emptyTotals(periods: number | null): FormStatsTotals {\n return { views: null, starts: null, submissions: null, leads: null, periods }\n}\n\n/** A stored counter as a number, or `null` where nothing was stored. */\nfunction countedStat(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Sum a form's counters — over its whole history, or over a span of months.\n *\n * ## Lifetime and windowed are different questions, and the caller picks\n *\n * The flat counters on {@link FormStats} have counted since the form entity\n * existed. Passing no range returns those, and `periods: null` says so, so a\n * surface can label the figure lifetime instead of implying it belongs to\n * whatever the surface is about.\n *\n * Passing a range sums `stats.periods` instead, over exactly the month keys\n * inside it. The two can differ by a lot in both directions: the month series\n * began later than the flat counters, so a windowed total over a form's whole\n * history can still be smaller than its lifetime total.\n *\n * ## What `null` means here, and why an in-range zero is not it\n *\n * A counter stays `null` unless some in-range month actually carries it. This\n * is the same rule {@link formStatsWindow} applies to a rate's denominator,\n * and it exists for the same reason: a month that carries `submissions` and\n * no `leads` key is not a month with zero leads, it is a month in which\n * nothing counted leads.\n *\n * ## Whole months, and a range boundary that lands inside one\n *\n * `stats.periods` is keyed by calendar month, so the finest a windowed total\n * can be is a month. A range that starts on the 20th takes that whole month,\n * including the days before it. A caller reporting a windowed figure has to\n * say the window is measured in whole months; nothing here can narrow it.\n *\n * @param stats - the stored counters, or nothing.\n * @param range - the months to confine the sum to. Omitted, or with neither\n * end, gives lifetime totals.\n */\nexport function formStatsTotals(\n stats: FormStats | undefined | null,\n range?: FormPeriodRange | null,\n): FormStatsTotals {\n const from = range?.from ?? null\n const to = range?.to ?? null\n if (!from && !to) {\n return {\n views: countedStat(stats?.views),\n starts: countedStat(stats?.starts),\n submissions: countedStat(stats?.submissions),\n leads: countedStat(stats?.leads),\n periods: null,\n }\n }\n const totals = emptyTotals(0)\n for (const [key, month] of Object.entries(stats?.periods ?? {})) {\n // The same key shape `formPeriodSeries` accepts. A stored key of another\n // shape is not a month and cannot be compared against a range as one.\n if (!/^\\d{4}-(0[1-9]|1[0-2])$/.test(key)) continue\n // Lexical comparison IS chronological for a zero-padded `YYYY-MM`.\n if (from && key < from) continue\n if (to && key > to) continue\n totals.periods = (totals.periods ?? 0) + 1\n for (const kind of FORM_STAT_KINDS) {\n const value = countedStat(month?.[kind])\n if (value === null) continue\n totals[kind] = (totals[kind] ?? 0) + value\n }\n }\n return totals\n}\n\nexport const FORM_SLUG_MAX_LENGTH = 64\nexport const FORM_DISPLAY_NAME_MAX_LENGTH = 100\n\n/**\n * A stable, url-safe handle for a form, derived from its display name.\n *\n * The slug is NOT the identity — `formId` is, and the slug is free to be\n * regenerated. It exists so a console URL and an export filename can name a\n * form in something a human recognizes without either becoming a second\n * identity the way `formName` did.\n *\n * @returns the slug, or `''` when the input reduces to nothing — a caller\n * must fall back to the document id rather than store an empty slug.\n */\nexport function normalizeFormSlug(input: unknown): string {\n return String(input ?? '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, FORM_SLUG_MAX_LENGTH)\n .replace(/-+$/g, '')\n}\n\nconst FORM_FIELD_TYPES = new Set<FormFieldType>([\n 'text',\n 'email',\n 'textarea',\n 'select',\n 'radio',\n 'checkbox',\n 'rating',\n])\n\n/**\n * Splits a `FormField` node's newline- or comma-separated choice list.\n *\n * Restated rather than imported from `libs/plugins/mui`: this module is in the\n * foundation layer and a plugin may not be a dependency of it. The two must\n * agree, and `forms.spec.ts` asserts they do against the same inputs\n * `parseFieldOptions` is specified on.\n */\nfunction parseDeclaredOptions(options: unknown): string[] {\n return String(options ?? '')\n .split(/[\\n,]/)\n .map((entry) => entry.trim())\n .filter(Boolean)\n}\n\n/** Child ids of a node in the stored map form, in the author's order. */\nfunction childIdsOf(node: AglynNodeSchema | undefined): NodeId[] {\n return Array.isArray(node?.nodes) ? (node.nodes as NodeId[]) : []\n}\n\n/**\n * Every `formField` descendant of `formNodeId`, in the order the author\n * placed them.\n *\n * Depth-first pre-order, because that IS the reading order of the rendered\n * form and the order a per-form submission list wants its columns in. A\n * breadth-first walk would interleave the fields of two adjacent groups.\n *\n * Nesting between the form and its fields is arbitrary — the `Form` runtime\n * makes a point of riding the DOM precisely so it needs no React context — so\n * the walk cannot assume fields are direct children.\n *\n * Repeated and unknown ids are skipped, which bounds a cyclic document.\n */\nexport function collectFormFieldNodeIds(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formNodeId: NodeId,\n formFieldComponentId = FORM_FIELD_COMPONENT_ID,\n): NodeId[] {\n if (!nodes?.[formNodeId]) return []\n const found: NodeId[] = []\n const seen = new Set<NodeId>([formNodeId])\n const stack: NodeId[] = [...childIdsOf(nodes[formNodeId])].reverse()\n while (stack.length) {\n const id = stack.pop() as NodeId\n if (seen.has(id) || !nodes[id]) continue\n seen.add(id)\n if (nodes[id]?.componentId === formFieldComponentId) found.push(id)\n // Pushed reversed so the first child is popped first — the walk is\n // pre-order, and a nested field must not overtake its own siblings.\n const children = childIdsOf(nodes[id])\n for (let index = children.length - 1; index >= 0; index -= 1) {\n stack.push(children[index] as NodeId)\n }\n }\n return found\n}\n\n/**\n * Reads a form's declared field list off the nodes an author already drew.\n *\n * This is the whole of what adoption has to invent, and it invents nothing:\n * `fieldName`, `fieldType`, `label`, `required`, `options` and\n * `datasetFieldId` are all already props on the `formField` nodes. A form\n * adopted from a page therefore declares exactly the form that page was\n * already submitting.\n *\n * A field with no `fieldName` is DROPPED rather than defaulted. The runtime\n * falls back to `name = fieldName || 'field'`, so several unnamed fields\n * collapse onto one submission key — declaring them would put a key in the\n * schema that does not identify a value.\n *\n * A duplicate `fieldName` keeps its FIRST occurrence, matching the submission\n * the runtime produces: `FormData` entries under one key are joined into that\n * one key, so the second node contributes no separate value.\n */\nexport function formFieldDeclsFromNodes(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formNodeId: NodeId,\n formFieldComponentId = FORM_FIELD_COMPONENT_ID,\n): FormFieldDecl[] {\n const declarations: FormFieldDecl[] = []\n const claimed = new Set<string>()\n for (const id of collectFormFieldNodeIds(nodes, formNodeId, formFieldComponentId)) {\n const props = (nodes?.[id]?.props ?? {}) as Record<string, unknown>\n const fieldName = String(props['fieldName'] ?? '').trim()\n if (!fieldName || claimed.has(fieldName)) continue\n claimed.add(fieldName)\n const rawType = String(props['fieldType'] ?? 'text') as FormFieldType\n const options = parseDeclaredOptions(props['options'])\n const label = String(props['label'] ?? '').trim()\n const datasetFieldId = String(props['datasetFieldId'] ?? '').trim()\n declarations.push({\n fieldName,\n fieldType: FORM_FIELD_TYPES.has(rawType) ? rawType : 'text',\n ...(label ? { label } : {}),\n ...(props['required'] === true ? { required: true } : {}),\n ...(options.length ? { options } : {}),\n ...(datasetFieldId ? { datasetFieldId } : {}),\n })\n }\n return declarations\n}\n\n/** One `form` node found by the discovery scan, with where it was found. */\nexport interface DiscoveredFormNode {\n /** `screen` / `layout` / `component` — what kind of document holds it. */\n sourceKind: 'screen' | 'layout' | 'component'\n sourceId: string\n sourceName?: string\n nodeId: NodeId\n /** The caption the node carries today, normalized the way the route is. */\n formName: string\n /** Already bound, when the node carries a `formId`. */\n formId?: string\n fields: FormFieldDecl[]\n}\n\n/**\n * Finds every `form` node in one document's node map.\n *\n * The corpus and the shape are the *Used by* scan's: a flat\n * `Record<NodeId, AglynNodeSchema>` per screen, layout and component\n * definition. That scan is idle until asked, for the reason its card states\n * in full — reading every screen and every layout on mount is the\n * expensive-read shape this codebase has a standing rule against — and this\n * one inherits that posture rather than re-arguing it.\n */\nexport function discoverFormNodes(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n source: { kind: DiscoveredFormNode['sourceKind']; id: string; name?: string },\n ids: { form?: string; formField?: string } = {},\n): DiscoveredFormNode[] {\n const formComponentId = ids.form ?? FORM_COMPONENT_ID\n const formFieldComponentId = ids.formField ?? FORM_FIELD_COMPONENT_ID\n const found: DiscoveredFormNode[] = []\n for (const [nodeId, node] of Object.entries(nodes ?? {})) {\n if (node?.componentId !== formComponentId) continue\n const props = (node.props ?? {}) as Record<string, unknown>\n const boundId = String(props['formId'] ?? '').trim()\n found.push({\n sourceKind: source.kind,\n sourceId: source.id,\n ...(source.name ? { sourceName: source.name } : {}),\n nodeId,\n // Mirrors the runtime default: an unnamed form submits as `Form`, so\n // that is the caption its history is filed under and the one an\n // adoption has to claim.\n formName: normalizeSubmissionFormName(props['formName']),\n ...(boundId ? { formId: boundId } : {}),\n fields: formFieldDeclsFromNodes(nodes, nodeId, formFieldComponentId),\n })\n }\n return found\n}\n\n/**\n * A form entity's published design, in the shape the graft consumes: the\n * `rootId`/`nodes` snapshot that lives on `hosts/{hostId}/forms/{formId}`.\n *\n * Structurally a component definition minus the parts a form does not have —\n * no declared props, no icon — which is why the graft can take both. It is\n * `Pick`ed off {@link FormDocument} rather than restated so the storage\n * contract stays the single description of what is written there.\n */\nexport type PlacedFormDesign<N = AglynNodeSchema> = Required<\n Pick<FormDocument<N>, 'rootId' | 'nodes'>\n>\n\n/**\n * Whether any node in this map PLACES a form entity (as opposed to merely\n * drawing an unbound form inline).\n *\n * The cost gate in front of the forms read, and cheap on purpose: one scan of\n * a map the caller already holds, against a read that is a whole collection\n * query. Most pages carry no form at all, and a page whose form nodes are all\n * unbound has nothing an entity could contribute — either way there is nothing\n * for the graft to resolve, so the query buys nothing.\n *\n * Says nothing about whether the named form EXISTS or is published; that is\n * settled by the graft, against documents this cannot see.\n */\nexport function placesFormEntity(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n): boolean {\n for (const node of Object.values(nodes ?? {})) {\n if (node?.componentId !== FORM_COMPONENT_ID) continue\n const formId = (node.props as Record<string, unknown> | undefined)?.[\n FORM_ID_PROP\n ]\n if (typeof formId === 'string' && formId.trim()) return true\n }\n return false\n}\n\n/**\n * Whether any node in this map places THIS form.\n *\n * The per-id half of {@link placesFormEntity}, and the predicate a usage scan\n * asks of a screen, a layout or a component definition. Deliberately the same\n * reader the graft resolves against: a scan that disagreed about what counts\n * as a placement would drop the caches of the wrong pages, and the pages it\n * missed would serve the old form for the whole revalidate window with nothing\n * recording that they were skipped.\n */\nexport function nodesPlaceForm(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formId: string,\n): boolean {\n if (!formId) return false\n for (const node of Object.values(nodes ?? {})) {\n if (node?.componentId !== FORM_COMPONENT_ID) continue\n const bound = (node.props as Record<string, unknown> | undefined)?.[\n FORM_ID_PROP\n ]\n if (typeof bound === 'string' && bound.trim() === formId) return true\n }\n return false\n}\n\n/**\n * The `form` node inside a form's OWN design.\n *\n * A form document's tree holds exactly one, because the document IS that\n * form — but the tree is a flat map under a synthetic canvas root, so the\n * node has to be found rather than assumed to be the root itself.\n *\n * Here rather than at each surface because every reader of a form's design\n * needs it before it can ask anything else: the form's page to run\n * `checkFormContract`, the promotion route to publish, the preview to draw,\n * {@link formDesignReboundTo} to rewrite the binding. A caller that found\n * the node its own way and one that found it this way must agree, or a\n * design reads as \"no form here\" on one surface and as bound on the next.\n */\nexport function formNodeIdIn(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n): NodeId | undefined {\n return Object.keys(nodes ?? {}).find(\n (id) => nodes?.[id]?.componentId === FORM_COMPONENT_ID,\n ) as NodeId | undefined\n}\n\n/**\n * The same design, naming `identity` instead of whichever form it named\n * before — or `null` when it already did.\n *\n * ## Why a copied design must be rewritten rather than carried\n *\n * A form's design names its own form in a prop (AGL-3024). That binding is\n * what `/api/forms/submit` stamps a submission with and what the form's own\n * submission list is an equality on, so a design that travels to a SECOND\n * form — duplicated, imported, installed from a starter — arrives naming the\n * first one, and every submission the second form collects is filed under the\n * first. Nothing about that is visible: the copy renders, the visitor\n * submits, the row lands, and the copy's own list simply never grows.\n * `checkFormContract` calls it `form-id-unbound`, and it is the one violation\n * a copy CAUSES rather than inherits.\n *\n * So every path that gives a stored design to a different form rebinds it\n * here, with the same two props the Forms page's Create stamps on a new one:\n * the id the submissions are keyed on, and the caption they are labelled\n * with in the Inbox. Leaving the caption behind is the quieter half of the\n * same bug — the copy's submissions arrive titled with the source's name.\n *\n * `null` for \"nothing to write\", so a caller holding a compressed tree can\n * skip the decode-rewrite-encode round trip entirely in the common case.\n * A design with no form node in it returns `null` too: there is no binding\n * to move, and inventing one is not this function's decision.\n *\n * Pure — the input map and its nodes are not changed.\n */\nexport function formDesignReboundTo<N extends AglynNodeSchema = AglynNodeSchema>(\n nodes: Record<NodeId, N> | undefined | null,\n identity: { formId: string; formName?: string | null },\n): Record<NodeId, N> | null {\n const formId = String(identity.formId ?? '').trim()\n if (!formId || !nodes) return null\n const requested = String(identity.formName ?? '').trim()\n const formName = requested\n ? requested.slice(0, FORM_DISPLAY_NAME_MAX_LENGTH)\n : ''\n const rebound: Record<NodeId, N> = {}\n let changed = false\n for (const entry of Object.entries(nodes)) {\n const [id, node] = entry as [NodeId, N]\n const props = (node?.props ?? {}) as Record<string, unknown>\n if (node?.componentId !== FORM_COMPONENT_ID) {\n rebound[id] = node\n continue\n }\n const bound = String(props[FORM_ID_PROP] ?? '').trim()\n if (bound === formId && (!formName || props['formName'] === formName)) {\n rebound[id] = node\n continue\n }\n changed = true\n rebound[id] = {\n ...node,\n props: {\n ...props,\n [FORM_ID_PROP]: formId,\n ...(formName ? { formName } : {}),\n },\n } as N\n }\n return changed ? rebound : null\n}\n\n/**\n * The placement kind that makes a placed form render its ENTITY'S design.\n *\n * Until this existed the entity's tree was written on every publish and read\n * by nothing: a form's fields had to be redrawn on each page that placed it,\n * and editing the form propagated nowhere. The two documents disagreed the\n * moment either changed, and the page always won.\n *\n * `replacesAuthoredChildren` is what makes that propagation real, and it is\n * the reason the resolution rule is strict. The rule, stated once:\n *\n * - Entity has a published design → the entity's fields ARE the form. Whatever\n * the page drew inside the form node is discarded, exactly as a reusable\n * instance's child list is replaced by its definition's. This is what \"edit\n * the form once\" means; a merge would render a page's stale copy of a field\n * beside the entity's current one.\n * - Entity has no published design, is archived away, or the `formId` names\n * nothing → the form node is left completely alone, inline fields included.\n * Every form built before the entity existed is in this state, so this is\n * the branch that keeps the live site rendering exactly what it renders\n * today.\n *\n * A deleted or unpublished entity therefore degrades to the page's own copy\n * rather than to an empty form — the same fail-open posture the component\n * graft takes for an unresolvable `refId`, and for the same reason: a\n * document going missing must not take a published page's content with it.\n *\n * The discard is a COMPOSE-time one, so it takes nothing away permanently:\n * the page's fields stay in its document, and clearing the binding brings\n * them straight back. That is what makes binding an existing hand-built form\n * to an entity a reversible act rather than a destructive one.\n */\nexport function placedFormPlacement<N extends AglynNodeSchema = AglynNodeSchema>(\n formsById: Record<string, PlacedFormDesign<N> | undefined> | undefined,\n): PlacementKind<N> {\n return {\n componentId: FORM_COMPONENT_ID,\n refProp: FORM_ID_PROP,\n definitionsById: formsById,\n replacesAuthoredChildren: true,\n }\n}\n\n/**\n * The caption as the submit route stores it.\n *\n * Restated here so discovery and the backfill compare the same string the\n * route wrote: `String(formName ?? 'Form').slice(0, 100)`. A differently\n * derived caption on either side would make every legacy match miss, silently\n * and in the safe direction — which is the failure that looks like success.\n */\nexport function normalizeSubmissionFormName(value: unknown): string {\n return String(value ?? '').trim()\n ? String(value).slice(0, FORM_DISPLAY_NAME_MAX_LENGTH)\n : 'Form'\n}\n\n/** The page path as the submit route stores it. */\nexport function normalizeSubmissionPath(value: unknown): string {\n return String(value ?? '').slice(0, 500)\n}\n\n/** A form as the backfill sees it: an id and what it claims of the past. */\nexport interface LegacyMatchCandidate {\n formId: string\n legacyMatch?: FormLegacyMatch | null\n}\n\n/**\n * Which adopted form, if any, a pre-entity submission belongs to.\n *\n * ⛔ **An ambiguous submission is left UNSTAMPED, always.** The two failure\n * modes are not symmetric and the asymmetry is the whole rule:\n *\n * - An unmatched row is still in the Inbox, still readable, still exportable\n * over `/v1`. It is missing from ONE form's list, the Forms page says how\n * many rows are in that state, and a later adoption can still claim it.\n * - A wrongly stamped row is filed under a form it was never sent to. It\n * leaves the Inbox's *Unassigned* view, joins a stranger's submission list,\n * and nothing on any screen says it moved. It is invisible, and invisible\n * is not recoverable.\n *\n * So the match is on the PAIR. `formName` alone is a caption two pages may\n * legitimately share — that shared caption is the defect the form entity\n * exists to fix, and using it as the migration key would carry the defect into\n * the migration. `path` is what tells two same-named forms apart, and it only\n * does so when it was distinct, so both must agree and exactly one form may\n * claim the pair.\n *\n * @returns the form id to stamp, or `null` to leave the row alone. Never a\n * best guess.\n */\nexport function matchSubmissionToForm(\n submission: { formName?: unknown; path?: unknown },\n candidates: readonly LegacyMatchCandidate[],\n): string | null {\n const formName = normalizeSubmissionFormName(submission.formName)\n const path = normalizeSubmissionPath(submission.path)\n // A submission that recorded no path cannot be disambiguated by one, and\n // the pair rule has nothing to stand on. Older rows genuinely predate the\n // field; they stay unstamped rather than falling back to the caption.\n if (!path) return null\n const matched = candidates.filter(\n (candidate) =>\n candidate.legacyMatch?.formName === formName &&\n Array.isArray(candidate.legacyMatch?.paths) &&\n candidate.legacyMatch.paths.includes(path),\n )\n return matched.length === 1 ? (matched[0] as LegacyMatchCandidate).formId : null\n}\n\n/**\n * The value of the declared consent field, as a marketing opt-in.\n *\n * ⛔ THE FACT OF SUBMISSION IS NOT AN OPT-IN, and a form that declares no\n * consent field produces no consent record — on any plan, at any time. This\n * reads ONE field, named by the form's own `consentFieldName`, and asks\n * whether the visitor ticked it.\n *\n * That is not in tension with the standing rule that consent is never\n * inferred: a checkbox the visitor ticked IS an explicit checkbox. What the\n * entity adds is a declared place to look, in place of the closed name list\n * the route has to fall back on when no form is bound.\n *\n * What counts as the tick is {@link isConsentCheckboxTicked}'s, asked with\n * the consent field's own declaration from `form.fields`.\n *\n * Returns `false`, never `undefined`: every writer downstream stores consent\n * absent-or-true and must never write `false` over an opt-in captured\n * elsewhere.\n */\nexport function readFormDeclaredConsent(\n form:\n | {\n consentFieldName?: string\n /** The STORED declaration, for the consent field's type and options. */\n fields?: ReadonlyArray<\n Pick<FormFieldDecl, 'fieldName' | 'fieldType' | 'options'>\n > | null\n }\n | null\n | undefined,\n fields: Record<string, unknown> | null | undefined,\n): boolean {\n const fieldName = String(form?.consentFieldName ?? '').trim()\n if (!fieldName || !fields) return false\n const declaration = Array.isArray(form?.fields)\n ? form.fields.find((entry) => entry?.fieldName === fieldName)\n : undefined\n return isConsentCheckboxTicked(fields[fieldName], declaration)\n}\n\n/**\n * Whether a posted value ticks a consent checkbox.\n *\n * A tick arrives in one of two shapes. A box with no text of its own posts\n * one of {@link AFFIRMATIVE_CHECKBOX_VALUES}. A Checkboxes field posts the\n * TEXT of the option that was ticked — which is what lets its option say what\n * the person agrees to — so when the field's declaration is a checkbox with\n * exactly one option, that option's text, alone, is a tick too: compared as\n * the renderer draws it, trimmed, or as a list holding only it. A field with\n * several options is a question with several answers, and none of them is\n * the opt-in by its text.\n *\n * ⛔ The declaration must come from the STORED form document, never from the\n * request: a declaration a caller could send would let any submission name\n * its own words as the opt-in.\n */\nexport function isConsentCheckboxTicked(\n value: unknown,\n declaration?: Pick<FormFieldDecl, 'fieldType' | 'options'> | null,\n): boolean {\n if (value === true) return true\n if (\n AFFIRMATIVE_CHECKBOX_VALUES.has(\n String(value ?? '')\n .trim()\n .toLowerCase(),\n )\n ) {\n return true\n }\n const option = soleCheckboxOption(declaration)\n if (!option) return false\n const posted = Array.isArray(value)\n ? value.length === 1\n ? value[0]\n : undefined\n : value\n return typeof posted === 'string' && posted.trim() === option\n}\n\n/** A checkbox declaration's one option, or `null` for any other declaration. */\nfunction soleCheckboxOption(\n declaration: Pick<FormFieldDecl, 'fieldType' | 'options'> | null | undefined,\n): string | null {\n if (declaration?.fieldType !== 'checkbox' || !Array.isArray(declaration.options)) {\n return null\n }\n const options = declaration.options\n .map((option) => String(option ?? '').trim())\n .filter(Boolean)\n return options.length === 1 ? (options[0] as string) : null\n}\n\n/** Checkbox values a browser form actually posts for a ticked box. */\nexport const AFFIRMATIVE_CHECKBOX_VALUES = new Set([\n 'true',\n 'on',\n 'yes',\n '1',\n 'checked',\n])\n\n/**\n * The field names the submit route reads an opt-in from when the form\n * declares no consent field.\n *\n * A CLOSED list rather than a substring match on \"consent\": a merchant's\n * field called `consentToTreatment` on a clinic intake form is a different\n * instrument entirely, and matching it would manufacture a marketing basis\n * out of a medical one. Compared after {@link isMarketingConsentFieldName}'s\n * normalization, so `Subscribe to newsletter` and `subscribeToNewsletter`\n * are one name.\n */\nexport const MARKETING_CONSENT_FIELD_NAMES: ReadonlySet<string> = new Set([\n 'marketingconsent',\n 'marketingoptin',\n 'emailoptin',\n 'newsletteroptin',\n 'subscribe',\n 'subscribetonewsletter',\n])\n\n/**\n * Whether a field, by its name alone, is one the submit route reads an\n * opt-in out of when no consent field is declared.\n *\n * Asked of a DESIGN by the publish check and of a PAYLOAD by the route, so\n * the two cannot disagree about which undeclared field counts as consent.\n */\nexport function isMarketingConsentFieldName(name: unknown): boolean {\n return MARKETING_CONSENT_FIELD_NAMES.has(\n String(name ?? '')\n .toLowerCase()\n .replace(/[^a-z]/g, ''),\n )\n}\n\n/**\n * The marketing consent field: the props the Forms editor's **Marketing\n * consent** preset places on a Form Field, and the ones a form generated from\n * a brief carries.\n *\n * A Checkboxes field with ONE option, unticked and not required. The option\n * says what the person agrees to, because a Checkboxes field posts the text\n * of the option that was ticked and {@link isConsentCheckboxTicked} reads that\n * text as the tick. So the option holds no comma and no line break: the\n * Options setting starts a new box at each, and a second box is an answer\n * that is not the opt-in. A site owner may reword the label and the option;\n * the form names the field as its `consentFieldName` for a tick to count.\n */\nexport const MARKETING_CONSENT_FORM_FIELD: Readonly<{\n fieldName: string\n label: string\n fieldType: FormFieldType\n options: string\n required: boolean\n}> = {\n fieldName: 'marketingConsent',\n label: 'Marketing emails',\n fieldType: 'checkbox',\n options: 'Email me news and offers',\n required: false,\n}\n"],"names":["containerMembershipValue","utcMonthKey","displayNameSearchFields","nameSearchTokens","FORM_COMPONENT_ID","FORM_FIELD_COMPONENT_ID","FORM_ID_PROP","FORM_ENTITY_OWNED_PROPS","formPropsOnBind","previousProps","nextProps","nextFormId","trim","bound","key","FORMS_MAX_PER_HOST","isFormArchived","form","Boolean","archivedAt","formListFields","name","displayName","slugWords","slug","replace","searchTokens","Set","id","formCampaignFields","selected","campaignIds","Array","isArray","inCampaign","length","formLeadsStatWhenUncounted","routesLeads","newFormListFields","routing","lead","retired","stats","submissions","leads","lastSubmissionAtMs","FORM_LEAD_SOURCE_PREFIX","formLeadSource","formId","formIdsOfLeadSources","sources","ids","source","startsWith","slice","includes","push","FORM_COUNTER_FIELDS","formCountersFromSource","newestSubmissionAtMs","FORM_LAST_SUBMISSION_TOLERANCE_MS","storedCounter","value","Number","isFinite","formCounterDrift","stored","recounted","drift","field","raw","want","undefined","Math","abs","formCounterPatch","FORM_STAT_KINDS","nextPeriod","period","match","exec","year","month","String","padStart","formPeriodSeries","maxPeriods","periods","keys","Object","filter","test","sort","series","last","cursor","step","views","starts","formStatsWindow","over","of","window","values","denominator","numerator","formPeriodKey","atMs","Date","emptyTotals","countedStat","formStatsTotals","range","from","to","totals","entries","kind","FORM_SLUG_MAX_LENGTH","FORM_DISPLAY_NAME_MAX_LENGTH","normalizeFormSlug","input","toLowerCase","FORM_FIELD_TYPES","parseDeclaredOptions","options","split","map","entry","childIdsOf","node","nodes","collectFormFieldNodeIds","formNodeId","formFieldComponentId","found","seen","stack","reverse","pop","has","add","componentId","children","index","formFieldDeclsFromNodes","declarations","claimed","props","fieldName","rawType","label","datasetFieldId","fieldType","required","discoverFormNodes","formComponentId","formField","nodeId","boundId","sourceKind","sourceId","sourceName","formName","normalizeSubmissionFormName","fields","placesFormEntity","nodesPlaceForm","formNodeIdIn","find","formDesignReboundTo","identity","requested","rebound","changed","placedFormPlacement","formsById","refProp","definitionsById","replacesAuthoredChildren","normalizeSubmissionPath","matchSubmissionToForm","submission","candidates","path","matched","candidate","legacyMatch","paths","readFormDeclaredConsent","consentFieldName","declaration","isConsentCheckboxTicked","AFFIRMATIVE_CHECKBOX_VALUES","option","soleCheckboxOption","posted","MARKETING_CONSENT_FIELD_NAMES","isMarketingConsentFieldName","MARKETING_CONSENT_FORM_FIELD"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC;AAGD,SAASA,wBAAwB,QAAQ,4BAAwB;AAEjE,SAASC,WAAW,QAAQ,iBAAa;AACzC,SAASC,uBAAuB,EAAEC,gBAAgB,QAAQ,mBAAe;AAEzE;;;;;;;;CAQC,GACD,OAAO,MAAMC,oBAAoB,OAAM;AACvC,OAAO,MAAMC,0BAA0B,YAAW;AAElD;;;;CAIC,GACD,OAAO,MAAMC,eAAe,SAAQ;AAEpC;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,0BAA6C;IACxD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,gBACdC,aAAyD,EACzDC,SAAkC;IAElC,MAAMC,aAAaD,SAAS,CAACJ,aAAa;IAC1C,IAAI,OAAOK,eAAe,YAAY,CAACA,WAAWC,IAAI,IAAI,OAAOF;IACjE,IAAID,CAAAA,iCAAAA,aAAe,CAACH,aAAa,MAAKK,YAAY,OAAOD;IACzD,MAAMG,QAAQ,aAAKH;IACnB,KAAK,MAAMI,OAAOP,wBAAyB,OAAOM,KAAK,CAACC,IAAI;IAC5D,OAAOD;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,MAAME,qBAAqB,KAAI;AA+ItC;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASC,eACdC,IAAyD;IAEzD,OAAOC,QAAQD,wBAAAA,KAAME,UAAU;AACjC;AAUA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,eAAeH,IAI9B;IACC,MAAMI,OAAO,OAAOJ,KAAKK,WAAW,KAAK,WAAWL,KAAKK,WAAW,GAAG;IACvE,MAAMC,YAAY,AAAC,CAAA,OAAON,KAAKO,IAAI,KAAK,WAAWP,KAAKO,IAAI,GAAG,EAAC,EAAGC,OAAO,CAAC,OAAO;IAClF,OAAO,aACFvB,wBAAwBmB;QAC3BK,cAAc;eACT,IAAIC,IAAI;mBACNxB,iBAAiBkB;mBACjBlB,iBAAiBoB;mBACjBpB,iBAAiBc,KAAKW,EAAE;aAC5B;SACF;;AAEL;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,mBAAmBC,QAAiB;IAIlD,MAAMC,cAAc/B,yBAAyBgC,MAAMC,OAAO,CAACH,YAAYA,WAAW,EAAE;IACpF,OAAO;QAAEC;QAAaG,YAAYH,YAAYI,MAAM,GAAG;IAAE;AAC3D;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,2BAA2BC,WAAoB;IAC7D,OAAOA,cAAc,IAAI;AAC3B;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,kBAAkBrB,IAMjC;QAUkBA;QAJGA;IAApB,MAAMoB,cAAcpB,EAAAA,iBAAAA,KAAKsB,OAAO,qBAAZtB,eAAcuB,IAAI,MAAK;IAC3C,OAAO,aACFpB,eAAeH;QAClBwB,SAAS;QACTF,SAAS,cAAMtB,gBAAAA,KAAKsB,OAAO,YAAZtB,gBAAgB,CAAC;YAAIuB,MAAMH;;QAC1CH,YAAYL,mBAAmBZ,KAAKc,WAAW,EAAEG,UAAU;QAC3DQ,OAAO;YACLC,aAAa;YACbC,OAAOR,2BAA2BC;YAClCQ,oBAAoB;QACtB;;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;;;2CAqB2C,GAE3C,4DAA4D,GAC5D,OAAO,MAAMC,0BAA0B,QAAO;AAE9C,+DAA+D,GAC/D,OAAO,SAASC,eAAeC,MAAc;IAC3C,OAAO,GAAGF,0BAA0BE,QAAQ;AAC9C;AAEA,kEAAkE,GAClE,OAAO,SAASC,qBAAqBC,OAAgB;IACnD,IAAI,CAAClB,MAAMC,OAAO,CAACiB,UAAU,OAAO,EAAE;IACtC,MAAMC,MAAgB,EAAE;IACxB,KAAK,MAAMC,UAAUF,QAAS;QAC5B,IAAI,OAAOE,WAAW,YAAY,CAACA,OAAOC,UAAU,CAACP,0BAA0B;QAC/E,MAAMlB,KAAKwB,OAAOE,KAAK,CAACR,wBAAwBX,MAAM;QACtD,IAAIP,MAAM,CAACuB,IAAII,QAAQ,CAAC3B,KAAKuB,IAAIK,IAAI,CAAC5B;IACxC;IACA,OAAOuB;AACT;AASA,0CAA0C,GAC1C,OAAO,MAAMM,sBAA2D;IACtE;IACA;IACA;CACD,CAAA;AAED;;;;;;;CAOC,GACD,OAAO,SAASC,uBAAuBN,MAKtC;IACC,MAAMT,cAAcS,OAAOT,WAAW,GAAG,IAAIS,OAAOT,WAAW,GAAG;IAClE,OAAO;QACLA;QACAC,OAAOQ,OAAOR,KAAK,GAAG,IAAIQ,OAAOR,KAAK,GAAGR,2BAA2BgB,OAAOf,WAAW;QACtFQ,oBACEF,gBAAgB,QAAQ,OAAOS,OAAOO,oBAAoB,KAAK,WAC3DP,OAAOO,oBAAoB,GAC3B;IACR;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMC,oCAAoC,KAAK,MAAM;AAE5D,6EAA6E,GAC7E,SAASC,cAAcC,KAAc;IACnC,OAAO,OAAOA,UAAU,YAAYC,OAAOC,QAAQ,CAACF,SAASA,QAAQ;AACvE;AAEA;;;;;;CAMC,GACD,OAAO,SAASG,iBACdC,MAAkD,EAClDC,SAA2B;IAE3B,MAAMC,QAAoC,EAAE;IAC5C,KAAK,MAAMC,SAASZ,oBAAqB;QACvC,MAAMa,MAAMJ,0BAAAA,MAAQ,CAACG,MAAM;QAC3B,MAAMP,QAAQD,cAAcS;QAC5B,MAAMC,OAAOJ,SAAS,CAACE,MAAM;QAC7B,IAAIC,QAAQE,aAAcV,UAAU,QAAQQ,QAAQ,MAAO;YACzDF,MAAMZ,IAAI,CAACa;QACb,OAAO,IAAIA,UAAU,wBAAwBP,UAAU,QAAQS,SAAS,MAAM;YAC5E,IAAIE,KAAKC,GAAG,CAACZ,QAAQS,QAAQX,mCAAmCQ,MAAMZ,IAAI,CAACa;QAC7E,OAAO,IAAIP,UAAUS,MAAM;YACzBH,MAAMZ,IAAI,CAACa;QACb;IACF;IACA,OAAOD;AACT;AAEA,4DAA4D,GAC5D,OAAO,SAASO,iBAAiBR,SAA2B;IAC1D,OAAO;QACL,qBAAqBA,UAAUxB,WAAW;QAC1C,eAAewB,UAAUvB,KAAK;QAC9B,4BAA4BuB,UAAUtB,kBAAkB;IAC1D;AACF;AA0FA,OAAO,MAAM+B,kBAA2C;IACtD;IACA;IACA;IACA;CACD,CAAS;AAQV,wEAAwE,GACxE,SAASC,WAAWC,MAAc;IAChC,MAAMC,QAAQ,oBAAoBC,IAAI,CAACF;IACvC,IAAI,CAACC,OAAO,OAAO;IACnB,MAAME,OAAOlB,OAAOgB,KAAK,CAAC,EAAE;IAC5B,MAAMG,QAAQnB,OAAOgB,KAAK,CAAC,EAAE;IAC7B,IAAIG,QAAQ,KAAKA,QAAQ,IAAI,OAAO;IACpC,OAAOA,UAAU,KACb,GAAGD,OAAO,EAAE,GAAG,CAAC,GAChB,GAAGA,KAAK,CAAC,EAAEE,OAAOD,QAAQ,GAAGE,QAAQ,CAAC,GAAG,MAAM;AACrD;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,iBACd3C,KAAmC,EACnC4C,aAAa,EAAE;IAEf,MAAMC,UAAU7C,yBAAAA,MAAO6C,OAAO;IAC9B,IAAI,CAACA,SAAS,OAAO,EAAE;IACvB,MAAMC,OAAOC,OAAOD,IAAI,CAACD,SACtBG,MAAM,CAAC,CAAC5E,MAAQ,0BAA0B6E,IAAI,CAAC7E,MAC/C8E,IAAI;IACP,IAAI,CAACJ,KAAKrD,MAAM,EAAE,OAAO,EAAE;IAC3B,MAAM0D,SAA4B,EAAE;IACpC,MAAMC,OAAON,IAAI,CAACA,KAAKrD,MAAM,GAAG,EAAE;IAClC,IAAI4D,SAAwBP,IAAI,CAAC,EAAE;IACnC,2EAA2E;IAC3E,wDAAwD;IACxD,IAAK,IAAIQ,OAAO,GAAGD,UAAUC,QAAQ,MAAMA,QAAQ,EAAG;YACtCT,iBAGEL,cACCA,eACKA,oBACNA;QANhB,MAAMA,SAAQK,kBAAAA,OAAO,CAACQ,OAAO,YAAfR,kBAAmB,CAAC;QAClCM,OAAOrC,IAAI,CAAC;YACVsB,QAAQiB;YACRE,OAAOlC,QAAOmB,eAAAA,MAAMe,KAAK,YAAXf,eAAe;YAC7BgB,QAAQnC,QAAOmB,gBAAAA,MAAMgB,MAAM,YAAZhB,gBAAgB;YAC/BvC,aAAaoB,QAAOmB,qBAAAA,MAAMvC,WAAW,YAAjBuC,qBAAqB;YACzCtC,OAAOmB,QAAOmB,eAAAA,MAAMtC,KAAK,YAAXsC,eAAe;QAC/B;QACA,IAAIa,WAAWD,MAAM;QACrBC,SAASlB,WAAWkB;IACtB;IACA,OAAOT,aAAa,KAAKO,OAAO1D,MAAM,GAAGmD,aACrCO,OAAOvC,KAAK,CAACuC,OAAO1D,MAAM,GAAGmD,cAC7BO;AACN;AAYA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASM,gBACdzD,KAAmC,EACnC0D,IAAkB,EAClBC,EAAgB;;IAEhB,MAAMC,SAA0B;QAAEf,SAAS;QAAGa,MAAM;QAAGC,IAAI;IAAE;IAC7D,KAAK,MAAMnB,SAASO,OAAOc,MAAM,SAAC7D,yBAAAA,MAAO6C,OAAO,mBAAI,CAAC,GAAI;;QACvD,MAAMiB,cAAczC,gBAAOmB,yBAAAA,KAAO,CAACkB,KAAK,oBAAI;QAC5C,IAAI,CAACrC,OAAOC,QAAQ,CAACwC,gBAAgBA,eAAe,GAAG;QACvDF,OAAOf,OAAO,IAAI;QAClBe,OAAOF,IAAI,IAAII;QACf,MAAMC,YAAY1C,gBAAOmB,yBAAAA,KAAO,CAACmB,GAAG,oBAAI;QACxC,IAAItC,OAAOC,QAAQ,CAACyC,YAAYH,OAAOD,EAAE,IAAII;IAC/C;IACA,OAAOH;AACT;AAcA;;;;;;;CAOC,GACD,OAAO,SAASI,cAAcC,IAA+B;IAC3D,IAAI,OAAOA,SAAS,YAAY,CAAC5C,OAAOC,QAAQ,CAAC2C,OAAO,OAAO;IAC/D,OAAO1G,YAAY,IAAI2G,KAAKD;AAC9B;AAwBA,yDAAyD,GACzD,SAASE,YAAYtB,OAAsB;IACzC,OAAO;QAAEU,OAAO;QAAMC,QAAQ;QAAMvD,aAAa;QAAMC,OAAO;QAAM2C;IAAQ;AAC9E;AAEA,sEAAsE,GACtE,SAASuB,YAAYhD,KAAc;IACjC,OAAO,OAAOA,UAAU,YAAYC,OAAOC,QAAQ,CAACF,SAASA,QAAQ;AACvE;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCC,GACD,OAAO,SAASiD,gBACdrE,KAAmC,EACnCsE,KAA8B;;IAE9B,MAAMC,eAAOD,yBAAAA,MAAOC,IAAI,mBAAI;IAC5B,MAAMC,cAAKF,yBAAAA,MAAOE,EAAE,oBAAI;IACxB,IAAI,CAACD,QAAQ,CAACC,IAAI;QAChB,OAAO;YACLjB,OAAOa,YAAYpE,yBAAAA,MAAOuD,KAAK;YAC/BC,QAAQY,YAAYpE,yBAAAA,MAAOwD,MAAM;YACjCvD,aAAamE,YAAYpE,yBAAAA,MAAOC,WAAW;YAC3CC,OAAOkE,YAAYpE,yBAAAA,MAAOE,KAAK;YAC/B2C,SAAS;QACX;IACF;IACA,MAAM4B,SAASN,YAAY;IAC3B,KAAK,MAAM,CAAC/F,KAAKoE,MAAM,IAAIO,OAAO2B,OAAO,UAAC1E,yBAAAA,MAAO6C,OAAO,oBAAI,CAAC,GAAI;YAO7C4B;QANlB,yEAAyE;QACzE,sEAAsE;QACtE,IAAI,CAAC,0BAA0BxB,IAAI,CAAC7E,MAAM;QAC1C,mEAAmE;QACnE,IAAImG,QAAQnG,MAAMmG,MAAM;QACxB,IAAIC,MAAMpG,MAAMoG,IAAI;QACpBC,OAAO5B,OAAO,GAAG,EAAC4B,kBAAAA,OAAO5B,OAAO,YAAd4B,kBAAkB,KAAK;QACzC,KAAK,MAAME,QAAQzC,gBAAiB;gBAGlBuC;YAFhB,MAAMrD,QAAQgD,YAAY5B,yBAAAA,KAAO,CAACmC,KAAK;YACvC,IAAIvD,UAAU,MAAM;YACpBqD,MAAM,CAACE,KAAK,GAAG,EAACF,eAAAA,MAAM,CAACE,KAAK,YAAZF,eAAgB,KAAKrD;QACvC;IACF;IACA,OAAOqD;AACT;AAEA,OAAO,MAAMG,uBAAuB,GAAE;AACtC,OAAO,MAAMC,+BAA+B,IAAG;AAE/C;;;;;;;;;;CAUC,GACD,OAAO,SAASC,kBAAkBC,KAAc;IAC9C,OAAOtC,OAAOsC,gBAAAA,QAAS,IACpB7G,IAAI,GACJ8G,WAAW,GACXjG,OAAO,CAAC,eAAe,KACvBA,OAAO,CAAC,YAAY,IACpB6B,KAAK,CAAC,GAAGgE,sBACT7F,OAAO,CAAC,QAAQ;AACrB;AAEA,MAAMkG,mBAAmB,IAAIhG,IAAmB;IAC9C;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;;;;CAOC,GACD,SAASiG,qBAAqBC,OAAgB;IAC5C,OAAO1C,OAAO0C,kBAAAA,UAAW,IACtBC,KAAK,CAAC,SACNC,GAAG,CAAC,CAACC,QAAUA,MAAMpH,IAAI,IACzB8E,MAAM,CAACxE;AACZ;AAEA,uEAAuE,GACvE,SAAS+G,WAAWC,IAAiC;IACnD,OAAOlG,MAAMC,OAAO,CAACiG,wBAAAA,KAAMC,KAAK,IAAKD,KAAKC,KAAK,GAAgB,EAAE;AACnE;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,wBACdD,KAAqE,EACrEE,UAAkB,EAClBC,uBAAuBjI,uBAAuB;IAE9C,IAAI,EAAC8H,yBAAAA,KAAO,CAACE,WAAW,GAAE,OAAO,EAAE;IACnC,MAAME,QAAkB,EAAE;IAC1B,MAAMC,OAAO,IAAI7G,IAAY;QAAC0G;KAAW;IACzC,MAAMI,QAAkB;WAAIR,WAAWE,KAAK,CAACE,WAAW;KAAE,CAACK,OAAO;IAClE,MAAOD,MAAMtG,MAAM,CAAE;YAIfgG;QAHJ,MAAMvG,KAAK6G,MAAME,GAAG;QACpB,IAAIH,KAAKI,GAAG,CAAChH,OAAO,CAACuG,KAAK,CAACvG,GAAG,EAAE;QAChC4G,KAAKK,GAAG,CAACjH;QACT,IAAIuG,EAAAA,YAAAA,KAAK,CAACvG,GAAG,qBAATuG,UAAWW,WAAW,MAAKR,sBAAsBC,MAAM/E,IAAI,CAAC5B;QAChE,mEAAmE;QACnE,oEAAoE;QACpE,MAAMmH,WAAWd,WAAWE,KAAK,CAACvG,GAAG;QACrC,IAAK,IAAIoH,QAAQD,SAAS5G,MAAM,GAAG,GAAG6G,SAAS,GAAGA,SAAS,EAAG;YAC5DP,MAAMjF,IAAI,CAACuF,QAAQ,CAACC,MAAM;QAC5B;IACF;IACA,OAAOT;AACT;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASU,wBACdd,KAAqE,EACrEE,UAAkB,EAClBC,uBAAuBjI,uBAAuB;IAE9C,MAAM6I,eAAgC,EAAE;IACxC,MAAMC,UAAU,IAAIxH;IACpB,KAAK,MAAMC,MAAMwG,wBAAwBD,OAAOE,YAAYC,sBAAuB;kBAExDc,kBAGFA,kBAEFA,cACSA;YAPfjB;QAAf,MAAMiB,gBAASjB,0BAAAA,YAAAA,KAAO,CAACvG,GAAG,qBAAXuG,UAAaiB,KAAK,mBAAI,CAAC;QACtC,MAAMC,YAAYlE,QAAOiE,mBAAAA,KAAK,CAAC,YAAY,YAAlBA,mBAAsB,IAAIxI,IAAI;QACvD,IAAI,CAACyI,aAAaF,QAAQP,GAAG,CAACS,YAAY;QAC1CF,QAAQN,GAAG,CAACQ;QACZ,MAAMC,UAAUnE,QAAOiE,mBAAAA,KAAK,CAAC,YAAY,YAAlBA,mBAAsB;QAC7C,MAAMvB,UAAUD,qBAAqBwB,KAAK,CAAC,UAAU;QACrD,MAAMG,QAAQpE,QAAOiE,eAAAA,KAAK,CAAC,QAAQ,YAAdA,eAAkB,IAAIxI,IAAI;QAC/C,MAAM4I,iBAAiBrE,QAAOiE,wBAAAA,KAAK,CAAC,iBAAiB,YAAvBA,wBAA2B,IAAIxI,IAAI;QACjEsI,aAAa1F,IAAI,CAAC;YAChB6F;YACAI,WAAW9B,iBAAiBiB,GAAG,CAACU,WAAWA,UAAU;WACjDC,QAAQ;YAAEA;QAAM,IAAI,CAAC,GACrBH,KAAK,CAAC,WAAW,KAAK,OAAO;YAAEM,UAAU;QAAK,IAAI,CAAC,GACnD7B,QAAQ1F,MAAM,GAAG;YAAE0F;QAAQ,IAAI,CAAC,GAChC2B,iBAAiB;YAAEA;QAAe,IAAI,CAAC;IAE/C;IACA,OAAON;AACT;AAgBA;;;;;;;;;CASC,GACD,OAAO,SAASS,kBACdxB,KAAqE,EACrE/E,MAA6E,EAC7ED,MAA6C,CAAC,CAAC;QAEvBA,WACKA;IAD7B,MAAMyG,mBAAkBzG,YAAAA,IAAIlC,IAAI,YAARkC,YAAY/C;IACpC,MAAMkI,wBAAuBnF,iBAAAA,IAAI0G,SAAS,YAAb1G,iBAAiB9C;IAC9C,MAAMkI,QAA8B,EAAE;IACtC,KAAK,MAAM,CAACuB,QAAQ5B,KAAK,IAAIzC,OAAO2B,OAAO,CAACe,gBAAAA,QAAS,CAAC,GAAI;YAEzCD,aACQkB;QAFvB,IAAIlB,CAAAA,wBAAAA,KAAMY,WAAW,MAAKc,iBAAiB;QAC3C,MAAMR,SAASlB,cAAAA,KAAKkB,KAAK,YAAVlB,cAAc,CAAC;QAC9B,MAAM6B,UAAU5E,QAAOiE,gBAAAA,KAAK,CAAC,SAAS,YAAfA,gBAAmB,IAAIxI,IAAI;QAClD2H,MAAM/E,IAAI,CAAC;YACTwG,YAAY5G,OAAOiE,IAAI;YACvB4C,UAAU7G,OAAOxB,EAAE;WACfwB,OAAO/B,IAAI,GAAG;YAAE6I,YAAY9G,OAAO/B,IAAI;QAAC,IAAI,CAAC;YACjDyI;YACA,qEAAqE;YACrE,gEAAgE;YAChE,yBAAyB;YACzBK,UAAUC,4BAA4BhB,KAAK,CAAC,WAAW;WACnDW,UAAU;YAAE/G,QAAQ+G;QAAQ,IAAI,CAAC;YACrCM,QAAQpB,wBAAwBd,OAAO2B,QAAQxB;;IAEnD;IACA,OAAOC;AACT;AAeA;;;;;;;;;;;;CAYC,GACD,OAAO,SAAS+B,iBACdnC,KAAqE;IAErE,KAAK,MAAMD,QAAQzC,OAAOc,MAAM,CAAC4B,gBAAAA,QAAS,CAAC,GAAI;YAE7BD;QADhB,IAAIA,CAAAA,wBAAAA,KAAMY,WAAW,MAAK1I,mBAAmB;QAC7C,MAAM4C,UAAUkF,cAAAA,KAAKkB,KAAK,qBAAX,AAAClB,WAAoD,CAClE5H,aACD;QACD,IAAI,OAAO0C,WAAW,YAAYA,OAAOpC,IAAI,IAAI,OAAO;IAC1D;IACA,OAAO;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,SAAS2J,eACdpC,KAAqE,EACrEnF,MAAc;IAEd,IAAI,CAACA,QAAQ,OAAO;IACpB,KAAK,MAAMkF,QAAQzC,OAAOc,MAAM,CAAC4B,gBAAAA,QAAS,CAAC,GAAI;YAE9BD;QADf,IAAIA,CAAAA,wBAAAA,KAAMY,WAAW,MAAK1I,mBAAmB;QAC7C,MAAMS,SAASqH,cAAAA,KAAKkB,KAAK,qBAAX,AAAClB,WAAoD,CACjE5H,aACD;QACD,IAAI,OAAOO,UAAU,YAAYA,MAAMD,IAAI,OAAOoC,QAAQ,OAAO;IACnE;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASwH,aACdrC,KAAqE;IAErE,OAAO1C,OAAOD,IAAI,CAAC2C,gBAAAA,QAAS,CAAC,GAAGsC,IAAI,CAClC,CAAC7I;YAAOuG;eAAAA,CAAAA,0BAAAA,YAAAA,KAAO,CAACvG,GAAG,qBAAXuG,UAAaW,WAAW,MAAK1I;;AAEzC;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASsK,oBACdvC,KAA2C,EAC3CwC,QAAsD;QAEhCA,kBAEGA;IAFzB,MAAM3H,SAASmC,QAAOwF,mBAAAA,SAAS3H,MAAM,YAAf2H,mBAAmB,IAAI/J,IAAI;IACjD,IAAI,CAACoC,UAAU,CAACmF,OAAO,OAAO;IAC9B,MAAMyC,YAAYzF,QAAOwF,qBAAAA,SAASR,QAAQ,YAAjBQ,qBAAqB,IAAI/J,IAAI;IACtD,MAAMuJ,WAAWS,YACbA,UAAUtH,KAAK,CAAC,GAAGiE,gCACnB;IACJ,MAAMsD,UAA6B,CAAC;IACpC,IAAIC,UAAU;IACd,KAAK,MAAM9C,SAASvC,OAAO2B,OAAO,CAACe,OAAQ;kBAOpBiB;QANrB,MAAM,CAACxH,IAAIsG,KAAK,GAAGF;QACnB,MAAMoB,gBAASlB,wBAAAA,KAAMkB,KAAK,mBAAI,CAAC;QAC/B,IAAIlB,CAAAA,wBAAAA,KAAMY,WAAW,MAAK1I,mBAAmB;YAC3CyK,OAAO,CAACjJ,GAAG,GAAGsG;YACd;QACF;QACA,MAAMrH,QAAQsE,QAAOiE,sBAAAA,KAAK,CAAC9I,aAAa,YAAnB8I,sBAAuB,IAAIxI,IAAI;QACpD,IAAIC,UAAUmC,UAAW,CAAA,CAACmH,YAAYf,KAAK,CAAC,WAAW,KAAKe,QAAO,GAAI;YACrEU,OAAO,CAACjJ,GAAG,GAAGsG;YACd;QACF;QACA4C,UAAU;QACVD,OAAO,CAACjJ,GAAG,GAAG,aACTsG;YACHkB,OAAO,aACFA;gBACH,CAAC9I,aAAa,EAAE0C;eACZmH,WAAW;gBAAEA;YAAS,IAAI,CAAC;;IAGrC;IACA,OAAOW,UAAUD,UAAU;AAC7B;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GACD,OAAO,SAASE,oBACdC,SAAsE;IAEtE,OAAO;QACLlC,aAAa1I;QACb6K,SAAS3K;QACT4K,iBAAiBF;QACjBG,0BAA0B;IAC5B;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASf,4BAA4BtG,KAAc;IACxD,OAAOqB,OAAOrB,gBAAAA,QAAS,IAAIlD,IAAI,KAC3BuE,OAAOrB,OAAOR,KAAK,CAAC,GAAGiE,gCACvB;AACN;AAEA,iDAAiD,GACjD,OAAO,SAAS6D,wBAAwBtH,KAAc;IACpD,OAAOqB,OAAOrB,gBAAAA,QAAS,IAAIR,KAAK,CAAC,GAAG;AACtC;AAQA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAAS+H,sBACdC,UAAkD,EAClDC,UAA2C;IAE3C,MAAMpB,WAAWC,4BAA4BkB,WAAWnB,QAAQ;IAChE,MAAMqB,OAAOJ,wBAAwBE,WAAWE,IAAI;IACpD,yEAAyE;IACzE,0EAA0E;IAC1E,sEAAsE;IACtE,IAAI,CAACA,MAAM,OAAO;IAClB,MAAMC,UAAUF,WAAW7F,MAAM,CAC/B,CAACgG;YACCA,wBACcA;eADdA,EAAAA,yBAAAA,UAAUC,WAAW,qBAArBD,uBAAuBvB,QAAQ,MAAKA,YACpCnI,MAAMC,OAAO,EAACyJ,0BAAAA,UAAUC,WAAW,qBAArBD,wBAAuBE,KAAK,KAC1CF,UAAUC,WAAW,CAACC,KAAK,CAACrI,QAAQ,CAACiI;;IAEzC,OAAOC,QAAQtJ,MAAM,KAAK,IAAI,AAACsJ,OAAO,CAAC,EAAE,CAA0BzI,MAAM,GAAG;AAC9E;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAAS6I,wBACd5K,IASa,EACboJ,MAAkD;;IAElD,MAAMhB,YAAYlE,eAAOlE,wBAAAA,KAAM6K,gBAAgB,mBAAI,IAAIlL,IAAI;IAC3D,IAAI,CAACyI,aAAa,CAACgB,QAAQ,OAAO;IAClC,MAAM0B,cAAc/J,MAAMC,OAAO,CAAChB,wBAAAA,KAAMoJ,MAAM,IAC1CpJ,KAAKoJ,MAAM,CAACI,IAAI,CAAC,CAACzC,QAAUA,CAAAA,yBAAAA,MAAOqB,SAAS,MAAKA,aACjD7E;IACJ,OAAOwH,wBAAwB3B,MAAM,CAAChB,UAAU,EAAE0C;AACpD;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBACdlI,KAAc,EACdiI,WAAiE;IAEjE,IAAIjI,UAAU,MAAM,OAAO;IAC3B,IACEmI,4BAA4BrD,GAAG,CAC7BzD,OAAOrB,gBAAAA,QAAS,IACblD,IAAI,GACJ8G,WAAW,KAEhB;QACA,OAAO;IACT;IACA,MAAMwE,SAASC,mBAAmBJ;IAClC,IAAI,CAACG,QAAQ,OAAO;IACpB,MAAME,SAASpK,MAAMC,OAAO,CAAC6B,SACzBA,MAAM3B,MAAM,KAAK,IACf2B,KAAK,CAAC,EAAE,GACRU,YACFV;IACJ,OAAO,OAAOsI,WAAW,YAAYA,OAAOxL,IAAI,OAAOsL;AACzD;AAEA,8EAA8E,GAC9E,SAASC,mBACPJ,WAA4E;IAE5E,IAAIA,CAAAA,+BAAAA,YAAatC,SAAS,MAAK,cAAc,CAACzH,MAAMC,OAAO,CAAC8J,YAAYlE,OAAO,GAAG;QAChF,OAAO;IACT;IACA,MAAMA,UAAUkE,YAAYlE,OAAO,CAChCE,GAAG,CAAC,CAACmE,SAAW/G,OAAO+G,iBAAAA,SAAU,IAAItL,IAAI,IACzC8E,MAAM,CAACxE;IACV,OAAO2G,QAAQ1F,MAAM,KAAK,IAAK0F,OAAO,CAAC,EAAE,GAAc;AACzD;AAEA,oEAAoE,GACpE,OAAO,MAAMoE,8BAA8B,IAAItK,IAAI;IACjD;IACA;IACA;IACA;IACA;CACD,EAAC;AAEF;;;;;;;;;;CAUC,GACD,OAAO,MAAM0K,gCAAqD,IAAI1K,IAAI;IACxE;IACA;IACA;IACA;IACA;IACA;CACD,EAAC;AAEF;;;;;;CAMC,GACD,OAAO,SAAS2K,4BAA4BjL,IAAa;IACvD,OAAOgL,8BAA8BzD,GAAG,CACtCzD,OAAO9D,eAAAA,OAAQ,IACZqG,WAAW,GACXjG,OAAO,CAAC,WAAW;AAE1B;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,MAAM8K,+BAMR;IACHlD,WAAW;IACXE,OAAO;IACPE,WAAW;IACX5B,SAAS;IACT6B,UAAU;AACZ,EAAC"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/forms.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The form entity: what a form IS, separately from the shape an author drew.\n *\n * `docs/specs/reusable-forms.md` §2. Before this module a form's whole\n * identity was `formName` — the free-text caption an author typed into an\n * inspector field, copied onto each submission at write time and reconciled\n * with nothing. Renaming a form split its history; two pages sharing a label\n * were one list. A document at `hosts/{hostId}/forms/{formId}` is the identity\n * those surfaces were reading a caption in place of.\n *\n * Pure and dependency-free on purpose. This barrel reaches client bundles\n * through `app-utils/index`, so nothing here may import a Node builtin — the\n * constraint that holds `personKey` in its own module one directory over.\n * Everything that needs Firestore or `node:crypto` lives at the call sites.\n */\n\nimport type { AglynNodeSchema, NodeId } from '../foundation/definitions/components.types'\nimport { containerMembershipValue } from './container-membership'\nimport type { PlacementKind } from './compose-reusable-components'\nimport { utcMonthKey } from './utc-month'\nimport { displayNameSearchFields, nameSearchTokens } from './name-search'\n\n/**\n * The `componentId` of the node that RENDERS a form, and of the node that\n * renders one of its fields.\n *\n * Persisted in screen, layout, component and form documents — never rename.\n * Restated as constants because the walkers below, the promotion route and the\n * graft all have to name the same two ids, and a copy that drifted would read\n * as \"this page has no form\" rather than as an error.\n */\nexport const FORM_COMPONENT_ID = 'form'\nexport const FORM_FIELD_COMPONENT_ID = 'formField'\n\n/**\n * The prop on a `form` node naming the entity it is a placement OF.\n *\n * Persisted in screen documents — never rename.\n */\nexport const FORM_ID_PROP = 'formId'\n\n/**\n * The settings a saved form's own root carries for every page that places it\n * (AGL-3494): its caption, where its answers go, its button, its message and\n * what a successful submit does.\n *\n * A placement renders the form's values for each of these unless it sets its\n * own (`placementPropsOverRoot`), so a value a page's form node merely STARTED\n * with — a preset's \"Send message\", a caption typed before the form was picked\n * — would silently pin that page to it. {@link formPropsOnBind} is what keeps\n * those starting values from becoming overrides.\n */\nexport const FORM_ENTITY_OWNED_PROPS: readonly string[] = [\n 'formName',\n 'datasetId',\n 'datasetName',\n 'submitLabel',\n 'successMessage',\n 'afterSubmit',\n 'redirectScreenId',\n 'redirectUrl',\n 'revealNodeId',\n]\n\n/**\n * A form node's props as they are saved when the author PICKS a saved form for\n * it (AGL-3494): the form's own settings ({@link FORM_ENTITY_OWNED_PROPS}) are\n * dropped from the page's copy, so the placement shows the saved form's.\n *\n * Picking a form is the moment the node stops being the page's own form and\n * becomes a placement of that one: its fields are already replaced by the\n * form's, and its label and message follow. Anything the page wrote before\n * that moment was written for the node it used to be — the Contact Section\n * preset seeds \"Send message\" and a thank-you line, the Contact Form preset a\n * caption — and kept, each would read as a deliberate per-page override and\n * mask the form's value forever. A label set AFTER the form is picked is\n * written on the placement and wins, which is the per-page change the merge\n * honors.\n *\n * Only a change of form clears anything. Re-saving a placement of the same\n * form, editing any other attribute, or unpicking the form leaves the props\n * exactly as given — an existing placement keeps every value it carries.\n */\nexport function formPropsOnBind(\n previousProps: Record<string, unknown> | undefined | null,\n nextProps: Record<string, unknown>,\n): Record<string, unknown> {\n const nextFormId = nextProps[FORM_ID_PROP]\n if (typeof nextFormId !== 'string' || !nextFormId.trim()) return nextProps\n if (previousProps?.[FORM_ID_PROP] === nextFormId) return nextProps\n const bound = { ...nextProps }\n for (const key of FORM_ENTITY_OWNED_PROPS) delete bound[key]\n return bound\n}\n\n/**\n * How many forms one query for a site's catalog reads.\n *\n * ⛔ **NOT the allowance.** How many forms a site may hold is\n * `PLAN_ENTITLEMENTS[plan].formsPerHost`, enforced at creation through\n * `checkQuota` in `/api/hosts/resources`. A surface that shows a customer\n * their ceiling MUST read that entitlement — this number is larger, so\n * reading it instead overstates the cap on every plan.\n *\n * What this bounds is a READ. `hosts/{hostId}/forms` is small enough to list\n * in one page, and the listing surfaces (the submissions filter, the entity\n * picker) say so — but a bound below the allowance would make them silently\n * drop forms the customer made and can see elsewhere, which is the invisible\n * half of a wrong limit and the expensive one to discover.\n *\n * So it sits at or above every per-plan allowance, with headroom for the\n * catalogs that legitimately exceed one: a per-org contract override, and any\n * site that built past a ceiling before it was lowered. `forms.spec.ts` pins\n * that relationship rather than trusting the two numbers to be moved\n * together. A catalog past even this needs real pagination, not a larger\n * constant.\n */\nexport const FORMS_MAX_PER_HOST = 1000\n\n/** Field types a `FormField` node offers; the form declares the same set. */\nexport type FormFieldType =\n | 'text'\n | 'email'\n | 'textarea'\n | 'select'\n | 'radio'\n | 'checkbox'\n | 'rating'\n\n/**\n * What a declared field MEANS, where the meaning is one the platform reads.\n *\n * The Inbox guesses today: `submission-presenter.ts` matches reduced field\n * keys against a convention list, which is why a survey whose fields are\n * `q1`…`q9` renders \"Someone\" on every row. A declared role is the author\n * saying which field is the address instead of the platform inferring it from\n * a name that was never a contract.\n */\nexport type FormFieldRole = 'name' | 'email' | 'phone' | 'consent'\n\n/** One field of a form, as the form declares it. */\nexport interface FormFieldDecl {\n /** THE submission key — matches the node's `fieldName` prop, not `label`. */\n fieldName: string\n label?: string\n fieldType: FormFieldType\n required?: boolean\n options?: string[]\n /** Stable dataset model fieldId this value is stored under (AGL-556). */\n datasetFieldId?: string\n role?: FormFieldRole\n /**\n * The custom contact field this value is saved under (AGL-2601) — a\n * `ContactFieldDefinition.key` in the org that owns the site.\n *\n * Edited on the form's own page rather than drawn on the canvas, so it is\n * NOT read off the nodes: `carryContactFieldMappings` carries it across a\n * publish by `fieldName`, or a design change would silently unmap every\n * field. The built-in properties — name, email, phone — are what `role`\n * names; this is only ever a field the merchant defined.\n */\n contactFieldKey?: string\n}\n\n/** Where a submission goes beyond the Inbox. */\nexport interface FormRouting {\n datasetId?: string\n /** Whether a submission carrying an address also becomes a lead. */\n lead?: boolean\n}\n\n/**\n * What an adopted form claims of the history that predates it.\n *\n * Recorded at adoption from what was ACTUALLY written on the submissions —\n * the caption and the page path — because those two fields are the whole of\n * what a pre-entity submission carries. Read only by the backfill, and never\n * by a live query: the live filter is an equality on `formId`.\n */\nexport interface FormLegacyMatch {\n formName: string\n paths: string[]\n}\n\n/** The stored form document, minus the timestamps Firestore stamps. */\nexport interface FormDocument<N = AglynNodeSchema> {\n displayName: string\n slug: string\n fields: FormFieldDecl[]\n /** Names the entry in `fields` that IS the marketing opt-in. */\n consentFieldName?: string\n /**\n * A SIGN-UP form: submitting it is the opt-in.\n *\n * For a form whose one purpose is subscribing (\"Get product updates\",\n * an email field and a Subscribe button), where a box to tick beside the\n * button would ask the same question twice. Set by the merchant on the\n * form, never inferred, so every other form keeps the rule that the fact\n * of submission is not consent. Read by `/api/forms/submit`, which then\n * records the opt-in and makes the person at least a subscriber.\n */\n optInOnSubmit?: boolean\n routing?: FormRouting\n legacyMatch?: FormLegacyMatch\n stats?: FormStats\n /**\n * When this form was RETIRED, or absent while it is in use (AGL-2671).\n *\n * A retired form is kept, never deleted: its submissions, its leads and the\n * contact timeline they built are the reason it still exists, and a form is\n * the key those rows are filed under. What retirement changes is that it\n * stops being a live lead surface — it leaves the forms list, it stops\n * being graded by `/api/health/funnel`, and `/api/forms/submit` refuses it.\n *\n * `unknown` because the stored value is not one shape: the console writes a\n * number, and a marker already in production is a Firestore `Timestamp`.\n * {@link isFormArchived} is the only thing that should read it, and it asks\n * about presence rather than about what kind of clock wrote it.\n */\n archivedAt?: unknown\n /**\n * Whether the form is retired, as a boolean the Forms list can QUERY\n * (AGL-3330). {@link archivedAt} stays the fact every other reader asks,\n * through {@link isFormArchived}; this mirrors it because Firestore cannot\n * find a document by the absence of a field, and \"in use\" is an absence\n * there. Written beside `archivedAt` by the one writer that retires a form,\n * `false` on every create — see {@link newFormListFields}.\n */\n retired?: boolean\n /** The campaigns the form is filed under — see {@link formCampaignFields}. */\n campaignIds?: string[]\n /**\n * Whether the form is in any campaign, as a boolean the Forms list can\n * QUERY (AGL-3330): \"in no campaign\" is an empty array, and Firestore can\n * neither find a document by an absent field nor ask an array for its\n * length. Written beside `campaignIds` by every writer of it, through\n * {@link formCampaignFields}.\n */\n inCampaign?: boolean\n /** The Forms list's search fields — see {@link formListFields}. */\n nameLower?: string\n nameTokens?: string[]\n nameReversed?: string\n searchTokens?: string[]\n /*\n * ── THE DESIGN ───────────────────────────────────────────────────────────\n *\n * A form is authored in the besigner, so it carries a node tree and version\n * history exactly as `AglynHostComponent` does, and for the same reason it\n * is stored the same way: `rootId` and `nodes` here are the PUBLISHED\n * snapshot, while the working draft lives compressed in\n * `hosts/{hostId}/forms/{formId}/versions/{versionId}`.\n *\n * The asymmetry is deliberate and copied rather than reinvented. Every\n * placed form has to resolve on the hot path of a published page render, so\n * the published tree stays on the parent document where one collection\n * query reaches all of them; moving it into the version docs would turn\n * that query into N+1 (AGL-679).\n */\n rootId?: NodeId\n nodes?: Record<NodeId, N>\n /**\n * Which version is published.\n *\n * The same pointer `AglynHostComponent.versionId` is, including the rule\n * that only a publisher may move it — the rules block denies an author the\n * `versionId` key for components and this document is governed the same way.\n */\n versionId?: string\n}\n\n/**\n * Whether a form has been retired — see {@link FormDocument.archivedAt}.\n *\n * PRESENCE, not a number. Absent, `null` and `0` are \"in use\"; any other\n * value is a retirement marker.\n *\n * ⛔ Deliberately NOT shaped like `isPipelineArchived`, which asks for a\n * positive number. A pipeline stores its own numeric stamp; a form's is\n * written by whatever retired it, and the marker already in production on at\n * least one form is a Firestore `Timestamp` rather than a number. The two\n * readers that predate this function — the list filter and\n * `/api/health/funnel`'s probe — both test truthiness, so a stricter rule\n * here does not tighten anything: it just disagrees with them, and the\n * disagreement surfaces as a retired form reappearing in the catalog while\n * the funnel goes on ignoring it.\n *\n * Measured, not assumed: with the numeric rule this function un-hid a form\n * the probe was still skipping, and the two counts were 5 against 4.\n *\n * ⚠️ Asked of the STORED document. A caller that has already filtered a list\n * must not re-derive this from a display flag; the field is the fact.\n */\nexport function isFormArchived(\n form: Pick<FormDocument, 'archivedAt'> | null | undefined,\n): boolean {\n return Boolean(form?.archivedAt)\n}\n\n/** The search fields the Forms list queries — see {@link formListFields}. */\nexport interface FormListSearchFields {\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n searchTokens: string[]\n}\n\n/**\n * The fields the Forms list FILTERS AND SEARCHES BY ON THE QUERY (AGL-3330).\n *\n * The list pages a site's catalog, so a filter that matched the rows already\n * read would answer \"no such form\" for one on a later page. Firestore has no\n * case-insensitive or substring match, so the name travels with the keys\n * every document named by `displayName` carries\n * (`displayNameSearchFields`): `nameLower`, `nameTokens`, `nameReversed`.\n * The Display name filter asks `nameTokens`.\n *\n * `searchTokens` is what the list's search box asks with one\n * `array-contains`: the word prefixes of the name AND of the slug (its words\n * are hyphen-joined) AND the lower-cased prefixes of the form's id, because\n * the box finds a form by any of the three. A slug usually repeats its name's\n * words, so the union mostly costs nothing; it earns its place on a form\n * renamed after its slug was minted.\n *\n * `tools/scripts/lib/site-form-list-fields.mjs` is the script-side twin, and both\n * answer `tools/scripts/lib/site-form-list-fields.fixtures.json`.\n *\n * ⚠️ Spread at EVERY write that sets `displayName` or `slug`, and at create.\n * A rename that skips it leaves the keys naming the old name, and the form is\n * then findable only by what it used to be called, while it still lists\n * normally.\n */\nexport function formListFields(form: {\n id: string\n displayName?: unknown\n slug?: unknown\n}): FormListSearchFields {\n const name = typeof form.displayName === 'string' ? form.displayName : ''\n const slugWords = (typeof form.slug === 'string' ? form.slug : '').replace(/-+/g, ' ')\n return {\n ...displayNameSearchFields(name),\n searchTokens: [\n ...new Set([\n ...nameSearchTokens(name),\n ...nameSearchTokens(slugWords),\n ...nameSearchTokens(form.id),\n ]),\n ],\n }\n}\n\n/**\n * The campaign fields a form is written with, from the campaigns it is filed\n * under (AGL-3330): the normalized ids, and `inCampaign`, the boolean the\n * Forms list's \"In a campaign\" filter asks by equality.\n *\n * ⚠️ The ONE way to write a form's `campaignIds`. A writer that sets the ids\n * without `inCampaign` leaves the form answering the filter by what it used\n * to be filed under; `apps/console/specs/form-writers-use-the-helpers.spec.ts`\n * refuses one. The campaign deletion pass removes an id with `arrayRemove`\n * and restamps `inCampaign` from the form as it then stands.\n */\nexport function formCampaignFields(selected: unknown): {\n campaignIds: string[]\n inCampaign: boolean\n} {\n const campaignIds = containerMembershipValue(Array.isArray(selected) ? selected : [])\n return { campaignIds, inCampaign: campaignIds.length > 0 }\n}\n\n/**\n * The `stats.leads` a form holds when nothing has been counted for it.\n *\n * A form that routes leads holds `0` until its first lead, so \"Leads = 0\"\n * finds the lead surfaces that have produced nothing yet. A form that does\n * not route leads holds `null`: it has no leads measurement, which is what\n * \"Leads is empty\" asks. The recount and every writer of the switch agree\n * through this and {@link formCountersFromSource}.\n */\nexport function formLeadsStatWhenUncounted(routesLeads: boolean): 0 | null {\n return routesLeads ? 0 : null\n}\n\n/**\n * What a NEW form is written with so the Forms list can query every filter\n * it offers (AGL-3330): its search keys, and an explicit value for each\n * field a query would otherwise have to find by its absence.\n *\n * - `retired: false`, the queryable mirror of an unset `archivedAt`.\n * - `routing.lead` as a boolean, `false` unless the form routes to leads, so\n * \"Lead routing is off\" is an equality rather than a missing field.\n * - `inCampaign`, from the campaigns the form is created in.\n * - `stats` with a NULL for each counter, except `leads` on a form that\n * routes leads, which starts at `0` (see {@link formLeadsStatWhenUncounted}).\n * Null, never zero, where nothing is measured: a zero would claim a\n * measurement. `/api/forms/submit` increments these, and an increment on\n * null starts from nothing, so it needs no change.\n *\n * `routing` keeps whatever else it carries (a dataset binding).\n */\nexport function newFormListFields(form: {\n id: string\n displayName?: unknown\n slug?: unknown\n routing?: FormRouting | null\n campaignIds?: unknown\n}): FormListSearchFields & {\n retired: false\n routing: FormRouting\n inCampaign: boolean\n stats: { submissions: null; leads: 0 | null; lastSubmissionAtMs: null }\n} {\n const routesLeads = form.routing?.lead === true\n return {\n ...formListFields(form),\n retired: false,\n routing: { ...(form.routing ?? {}), lead: routesLeads },\n inCampaign: formCampaignFields(form.campaignIds).inCampaign,\n stats: {\n submissions: null,\n leads: formLeadsStatWhenUncounted(routesLeads),\n lastSubmissionAtMs: null,\n },\n }\n}\n\n/*==========================================\n * THE COUNTERS, RECOUNTED FROM WHAT THEY COUNT (AGL-3330).\n *\n * `stats.submissions`, `stats.leads` and `stats.lastSubmissionAtMs` are kept\n * by increments on the submit path, which is cheap and never re-reads the\n * collection it counts. An increment cannot see a delete, a failed write or\n * a history written before it, so the stored figure drifts; the recount is\n * what puts it back, from the rows themselves:\n *\n * - submissions: the site's `formSubmissions` whose `formId` is the form;\n * - leads: the organization's leads whose `sources` name the form\n * (`form:{formId}`, {@link formLeadSource}) — PEOPLE this form brought in\n * that the workspace still holds, which is what the submit path counts\n * (a returning visitor, or a person already on the list through this\n * form, files no second lead);\n * - last submission: the newest of those submissions' `createdAt`.\n *\n * A server helper (`recountFormStats`) runs it after anything that removes a\n * row, and `tools/scripts/recount-form-stats.mjs` runs it over every form.\n * Both decide with the pure functions below; the script's twin answers\n * `tools/scripts/lib/site-form-stats-recount.fixtures.json`, as this does.\n *=========================================*/\n\n/** The source a lead filed by a form carries in `sources`. */\nexport const FORM_LEAD_SOURCE_PREFIX = 'form:'\n\n/** `form:{formId}` — the lead source a form's capture writes. */\nexport function formLeadSource(formId: string): string {\n return `${FORM_LEAD_SOURCE_PREFIX}${formId}`\n}\n\n/** The form ids a lead's `sources` name, in order, deduplicated. */\nexport function formIdsOfLeadSources(sources: unknown): string[] {\n if (!Array.isArray(sources)) return []\n const ids: string[] = []\n for (const source of sources) {\n if (typeof source !== 'string' || !source.startsWith(FORM_LEAD_SOURCE_PREFIX)) continue\n const id = source.slice(FORM_LEAD_SOURCE_PREFIX.length)\n if (id && !ids.includes(id)) ids.push(id)\n }\n return ids\n}\n\n/** The three counters the Forms list filters by. */\nexport interface FormCounterStats {\n submissions: number | null\n leads: number | null\n lastSubmissionAtMs: number | null\n}\n\n/** The counter fields, in reading order. */\nexport const FORM_COUNTER_FIELDS: readonly (keyof FormCounterStats)[] = [\n 'submissions',\n 'leads',\n 'lastSubmissionAtMs',\n]\n\n/**\n * The counters a form should hold, from the rows counted.\n *\n * Zero submissions is `null`, the list's \"none yet\", matching a new form.\n * Leads follow {@link formLeadsStatWhenUncounted} when there are none — and\n * a form that stopped routing leads keeps the leads it did file, because\n * those people are still on the list.\n */\nexport function formCountersFromSource(source: {\n submissions: number\n leads: number\n newestSubmissionAtMs: number | null\n routesLeads: boolean\n}): FormCounterStats {\n const submissions = source.submissions > 0 ? source.submissions : null\n return {\n submissions,\n leads: source.leads > 0 ? source.leads : formLeadsStatWhenUncounted(source.routesLeads),\n lastSubmissionAtMs:\n submissions !== null && typeof source.newestSubmissionAtMs === 'number'\n ? source.newestSubmissionAtMs\n : null,\n }\n}\n\n/**\n * How far the stored `lastSubmissionAtMs` may sit from the newest\n * submission's `createdAt` and still agree.\n *\n * The submit path stamps the counter from its own clock after it has written\n * the submission, whose `createdAt` is the server's commit time, and a lead\n * capture runs between the two — so a counter that is right reads a little\n * after the row. Ten minutes is far past that gap and far short of two\n * submissions a merchant would tell apart.\n */\nexport const FORM_LAST_SUBMISSION_TOLERANCE_MS = 10 * 60_000\n\n/** A stored counter as the recount compares it: a finite number, or `null`. */\nfunction storedCounter(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * The counters on which `stored` disagrees with `recounted`, in reading\n * order; empty when the form holds what its rows say.\n *\n * An ABSENT counter disagrees with a `null` one: \"is empty\" asks\n * `== null`, which a missing field does not answer.\n */\nexport function formCounterDrift(\n stored: Record<string, unknown> | null | undefined,\n recounted: FormCounterStats,\n): (keyof FormCounterStats)[] {\n const drift: (keyof FormCounterStats)[] = []\n for (const field of FORM_COUNTER_FIELDS) {\n const raw = stored?.[field]\n const value = storedCounter(raw)\n const want = recounted[field]\n if (raw === undefined || (value === null && raw !== null)) {\n drift.push(field)\n } else if (field === 'lastSubmissionAtMs' && value !== null && want !== null) {\n if (Math.abs(value - want) > FORM_LAST_SUBMISSION_TOLERANCE_MS) drift.push(field)\n } else if (value !== want) {\n drift.push(field)\n }\n }\n return drift\n}\n\n/** The dotted-path update that puts `recounted` on a form. */\nexport function formCounterPatch(recounted: FormCounterStats): Record<string, number | null> {\n return {\n 'stats.submissions': recounted.submissions,\n 'stats.leads': recounted.leads,\n 'stats.lastSubmissionAtMs': recounted.lastSubmissionAtMs,\n }\n}\n\n/**\n * One entry in a form's `versions` subcollection.\n *\n * The draft the besigner writes on every save. `nodes` arrives compressed\n * through the client converter, exactly as a component version's does, so\n * this declares the decompressed shape the hook hands back.\n */\nexport interface FormVersion<N = AglynNodeSchema> {\n formId: string\n hostId?: string\n displayName?: string\n rootId?: NodeId\n nodes?: Record<NodeId, N>\n}\n\n/**\n * Counters carried ON the form document, incremented on writes that were\n * happening anyway.\n *\n * Never derived by counting `formSubmissions`. That collection grows without\n * bound and is the one the customer is billed on; a console surface that\n * counted it on render would be the expensive-read shape this product has\n * created repeatedly. `hosts/{hostId}/overlays/{overlayId}.stats` is the same\n * pattern with the same reasoning. What an increment cannot see — a deleted\n * submission, an erased lead, a failed write — is put back by the RECOUNT\n * ({@link formCountersFromSource}), which runs after a removal and over every\n * form from `tools/scripts/recount-form-stats.mjs`; never on render.\n */\nexport interface FormStats {\n /*\n * `null` on a form created since AGL-3330, until the first submission:\n * written so the Forms list can query \"none yet\", which an absent field\n * cannot answer. A reader treats null and absent alike, as no figure.\n */\n submissions?: number | null\n /*\n * The people this form filed as leads that the workspace still holds: one\n * per lead whose `sources` name the form, however often they submitted.\n * `0` on a form that routes leads and has filed none, `null` on one that\n * has never routed any — see {@link formLeadsStatWhenUncounted}.\n */\n leads?: number | null\n lastSubmissionAtMs?: number | null\n /**\n * Form views, counted by the beacon at `/api/analytics/collect` — one per\n * rendered form on a live page, the same shape and the same cost as an\n * overlay impression.\n *\n * ⚠️ A CLIENT-SIDE COUNT, and every rate over it inherits that. A blocked\n * beacon, a browser that never runs the script and a crawler that renders\n * nothing are all views this does not hold, while `submissions` is counted\n * on the server and holds every one. So a completion rate over this can\n * legitimately exceed 100%, and it is reported rather than clamped: a\n * number capped at a round 100% looks like a measurement of a full house.\n */\n views?: number\n /** Forms a visitor typed into: one per form instance, on the first edit. */\n starts?: number\n /**\n * The same four counters, per calendar month.\n *\n * The series the detail surface draws, and — more importantly — what makes\n * a rate over `views` honest. The lifetime totals cannot be divided into\n * each other: `submissions` has counted since the form entity existed and\n * `views` only since the beacon shipped, so a lifetime completion rate\n * would divide a long history by a short one. {@link formStatsWindow} takes\n * every rate over the months that carry BOTH counters.\n *\n * Bounded by the calendar: twelve keys a year on a document with a megabyte\n * to spend. Keys are `utcMonthKey()` — the SAME function the\n * site-wide counter and the abuse ceiling are keyed by, imported rather\n * than restated, because a differently-derived month key reads zero on\n * exactly the months it disagrees about.\n */\n periods?: Record<string, FormPeriodStats>\n}\n\n/** One month of {@link FormStats}. Every field absent until first written. */\nexport interface FormPeriodStats {\n submissions?: number\n leads?: number\n views?: number\n starts?: number\n}\n\n/** The counters {@link FormPeriodStats} carries, in reading order. */\nexport type FormStatKind = 'views' | 'starts' | 'submissions' | 'leads'\n\nexport const FORM_STAT_KINDS: readonly FormStatKind[] = [\n 'views',\n 'starts',\n 'submissions',\n 'leads',\n] as const\n\n/** One month of a form's history, with every counter resolved to a number. */\nexport interface FormPeriodPoint extends Record<FormStatKind, number> {\n /** `YYYY-MM`. */\n period: string\n}\n\n/** The next month after `period`, or `null` for a key that is not one. */\nfunction nextPeriod(period: string): string | null {\n const match = /^(\\d{4})-(\\d{2})$/.exec(period)\n if (!match) return null\n const year = Number(match[1])\n const month = Number(match[2])\n if (month < 1 || month > 12) return null\n return month === 12\n ? `${year + 1}-01`\n : `${year}-${String(month + 1).padStart(2, '0')}`\n}\n\n/**\n * A form's history as a dense month series, from the first month anything was\n * recorded to the last.\n *\n * ⛔ THE SERIES NEVER STARTS BEFORE THE COUNTER DID. A month with no key is\n * two different facts — \"nothing happened\" and \"nothing was counted yet\" —\n * and they are told apart by WHERE the month falls: inside the recorded range\n * an absent key is a true zero, because the counter was live and wrote\n * nothing; before it, there is no measurement to draw and the series simply\n * does not extend there. Padding to a fixed twelve months would render the\n * form's pre-counter history as a row of confident zeros.\n *\n * Interior gaps ARE filled, at zero, so a quiet month reads as a quiet month\n * rather than closing up and making two distant months look adjacent.\n *\n * @param stats - the stored counters, or nothing.\n * @param maxPeriods - how many of the most recent months to return.\n * @returns oldest first, so a chart reads left to right. Empty when nothing\n * has ever been recorded.\n */\nexport function formPeriodSeries(\n stats: FormStats | undefined | null,\n maxPeriods = 12,\n): FormPeriodPoint[] {\n const periods = stats?.periods\n if (!periods) return []\n const keys = Object.keys(periods)\n .filter((key) => /^\\d{4}-(0[1-9]|1[0-2])$/.test(key))\n .sort()\n if (!keys.length) return []\n const series: FormPeriodPoint[] = []\n const last = keys[keys.length - 1]\n let cursor: string | null = keys[0]\n // Bounded by the span rather than by a `while (true)`: a stored key far in\n // the future would otherwise walk the calendar forever.\n for (let step = 0; cursor && step <= 1200; step += 1) {\n const month = periods[cursor] ?? {}\n series.push({\n period: cursor,\n views: Number(month.views ?? 0),\n starts: Number(month.starts ?? 0),\n submissions: Number(month.submissions ?? 0),\n leads: Number(month.leads ?? 0),\n })\n if (cursor === last) break\n cursor = nextPeriod(cursor)\n }\n return maxPeriods > 0 && series.length > maxPeriods\n ? series.slice(series.length - maxPeriods)\n : series\n}\n\n/** Two counters summed over the months where the FIRST of them was recorded. */\nexport interface FormStatsWindow {\n /** Months the window covers. Zero means no rate can be taken. */\n periods: number\n /** The counter the window is defined by, summed over those months. */\n over: number\n /** The other counter, summed over the SAME months. */\n of: number\n}\n\n/**\n * Sum `of` and `over` across exactly the months in which `over` was recorded.\n *\n * This is what stops a rate being a lie of arithmetic. `views` began being\n * counted the day the beacon shipped and `submissions` has counted since the\n * form entity existed, so dividing the lifetime totals answers \"submissions\n * ever, over views since Tuesday\" — a number that is not wrong by a little.\n *\n * A month is IN the window when it carries a non-zero `over`, not merely a\n * key: a month the beacon never reported is not a month with no views, it is\n * a month with no measurement, and including it would deflate every rate\n * taken over the window by however long the counter was dark.\n *\n * @returns `periods: 0` when nothing qualifies, which every caller must\n * render as a dash rather than as a zero rate.\n */\nexport function formStatsWindow(\n stats: FormStats | undefined | null,\n over: FormStatKind,\n of: FormStatKind,\n): FormStatsWindow {\n const window: FormStatsWindow = { periods: 0, over: 0, of: 0 }\n for (const month of Object.values(stats?.periods ?? {})) {\n const denominator = Number(month?.[over] ?? 0)\n if (!Number.isFinite(denominator) || denominator <= 0) continue\n window.periods += 1\n window.over += denominator\n const numerator = Number(month?.[of] ?? 0)\n if (Number.isFinite(numerator)) window.of += numerator\n }\n return window\n}\n\n/**\n * A span of calendar months, both ends inclusive, keyed as `YYYY-MM`.\n *\n * An absent end is OPEN rather than zero: a span that starts in March and\n * never closes covers every month from March onward. A span with neither end\n * is not a span, and {@link formStatsTotals} answers it with lifetime totals.\n */\nexport interface FormPeriodRange {\n from?: string | null\n to?: string | null\n}\n\n/**\n * The month key a moment falls in, or `null` for a moment that is not one.\n *\n * UTC, through {@link utcMonthKey}, because that is the function every\n * writer of `stats.periods` keys by. A month derived any other way reads zero\n * on exactly the months the two definitions disagree about — which is the\n * boundary month, the one a reader is most likely to be asking about.\n */\nexport function formPeriodKey(atMs: number | null | undefined): string | null {\n if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return null\n return utcMonthKey(new Date(atMs))\n}\n\n/**\n * A form's four counters, summed, with what the sum covers.\n *\n * Every counter is `number | null` and the distinction is load-bearing. A\n * counter is `null` when nothing was recorded for it at all: `leads` is\n * counted only for a form whose `routing.lead` is set, so a form that does\n * not route leads has no leads measurement rather than a measured zero, and a\n * `0` in that slot states a result nobody took. A form that routes leads and\n * has filed none holds `0`, which is a measurement.\n */\nexport interface FormStatsTotals {\n views: number | null\n starts: number | null\n submissions: number | null\n leads: number | null\n /**\n * Month keys that contributed. `null` when the totals are lifetime, which\n * is what tells a caller whether the figures are confined to a span.\n */\n periods: number | null\n}\n\n/** The counters, with nothing recorded for any of them. */\nfunction emptyTotals(periods: number | null): FormStatsTotals {\n return { views: null, starts: null, submissions: null, leads: null, periods }\n}\n\n/** A stored counter as a number, or `null` where nothing was stored. */\nfunction countedStat(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Sum a form's counters — over its whole history, or over a span of months.\n *\n * ## Lifetime and windowed are different questions, and the caller picks\n *\n * The flat counters on {@link FormStats} have counted since the form entity\n * existed. Passing no range returns those, and `periods: null` says so, so a\n * surface can label the figure lifetime instead of implying it belongs to\n * whatever the surface is about.\n *\n * Passing a range sums `stats.periods` instead, over exactly the month keys\n * inside it. The two can differ by a lot in both directions: the month series\n * began later than the flat counters, so a windowed total over a form's whole\n * history can still be smaller than its lifetime total.\n *\n * ## What `null` means here, and why an in-range zero is not it\n *\n * A counter stays `null` unless some in-range month actually carries it. This\n * is the same rule {@link formStatsWindow} applies to a rate's denominator,\n * and it exists for the same reason: a month that carries `submissions` and\n * no `leads` key is not a month with zero leads, it is a month in which\n * nothing counted leads.\n *\n * ## Whole months, and a range boundary that lands inside one\n *\n * `stats.periods` is keyed by calendar month, so the finest a windowed total\n * can be is a month. A range that starts on the 20th takes that whole month,\n * including the days before it. A caller reporting a windowed figure has to\n * say the window is measured in whole months; nothing here can narrow it.\n *\n * @param stats - the stored counters, or nothing.\n * @param range - the months to confine the sum to. Omitted, or with neither\n * end, gives lifetime totals.\n */\nexport function formStatsTotals(\n stats: FormStats | undefined | null,\n range?: FormPeriodRange | null,\n): FormStatsTotals {\n const from = range?.from ?? null\n const to = range?.to ?? null\n if (!from && !to) {\n return {\n views: countedStat(stats?.views),\n starts: countedStat(stats?.starts),\n submissions: countedStat(stats?.submissions),\n leads: countedStat(stats?.leads),\n periods: null,\n }\n }\n const totals = emptyTotals(0)\n for (const [key, month] of Object.entries(stats?.periods ?? {})) {\n // The same key shape `formPeriodSeries` accepts. A stored key of another\n // shape is not a month and cannot be compared against a range as one.\n if (!/^\\d{4}-(0[1-9]|1[0-2])$/.test(key)) continue\n // Lexical comparison IS chronological for a zero-padded `YYYY-MM`.\n if (from && key < from) continue\n if (to && key > to) continue\n totals.periods = (totals.periods ?? 0) + 1\n for (const kind of FORM_STAT_KINDS) {\n const value = countedStat(month?.[kind])\n if (value === null) continue\n totals[kind] = (totals[kind] ?? 0) + value\n }\n }\n return totals\n}\n\nexport const FORM_SLUG_MAX_LENGTH = 64\nexport const FORM_DISPLAY_NAME_MAX_LENGTH = 100\n\n/**\n * A stable, url-safe handle for a form, derived from its display name.\n *\n * The slug is NOT the identity — `formId` is, and the slug is free to be\n * regenerated. It exists so a console URL and an export filename can name a\n * form in something a human recognizes without either becoming a second\n * identity the way `formName` did.\n *\n * @returns the slug, or `''` when the input reduces to nothing — a caller\n * must fall back to the document id rather than store an empty slug.\n */\nexport function normalizeFormSlug(input: unknown): string {\n return String(input ?? '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, FORM_SLUG_MAX_LENGTH)\n .replace(/-+$/g, '')\n}\n\nconst FORM_FIELD_TYPES = new Set<FormFieldType>([\n 'text',\n 'email',\n 'textarea',\n 'select',\n 'radio',\n 'checkbox',\n 'rating',\n])\n\n/**\n * Splits a `FormField` node's newline- or comma-separated choice list.\n *\n * Restated rather than imported from `libs/plugins/mui`: this module is in the\n * foundation layer and a plugin may not be a dependency of it. The two must\n * agree, and `forms.spec.ts` asserts they do against the same inputs\n * `parseFieldOptions` is specified on.\n */\nfunction parseDeclaredOptions(options: unknown): string[] {\n return String(options ?? '')\n .split(/[\\n,]/)\n .map((entry) => entry.trim())\n .filter(Boolean)\n}\n\n/** Child ids of a node in the stored map form, in the author's order. */\nfunction childIdsOf(node: AglynNodeSchema | undefined): NodeId[] {\n return Array.isArray(node?.nodes) ? (node.nodes as NodeId[]) : []\n}\n\n/**\n * Every `formField` descendant of `formNodeId`, in the order the author\n * placed them.\n *\n * Depth-first pre-order, because that IS the reading order of the rendered\n * form and the order a per-form submission list wants its columns in. A\n * breadth-first walk would interleave the fields of two adjacent groups.\n *\n * Nesting between the form and its fields is arbitrary — the `Form` runtime\n * makes a point of riding the DOM precisely so it needs no React context — so\n * the walk cannot assume fields are direct children.\n *\n * Repeated and unknown ids are skipped, which bounds a cyclic document.\n */\nexport function collectFormFieldNodeIds(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formNodeId: NodeId,\n formFieldComponentId = FORM_FIELD_COMPONENT_ID,\n): NodeId[] {\n if (!nodes?.[formNodeId]) return []\n const found: NodeId[] = []\n const seen = new Set<NodeId>([formNodeId])\n const stack: NodeId[] = [...childIdsOf(nodes[formNodeId])].reverse()\n while (stack.length) {\n const id = stack.pop() as NodeId\n if (seen.has(id) || !nodes[id]) continue\n seen.add(id)\n if (nodes[id]?.componentId === formFieldComponentId) found.push(id)\n // Pushed reversed so the first child is popped first — the walk is\n // pre-order, and a nested field must not overtake its own siblings.\n const children = childIdsOf(nodes[id])\n for (let index = children.length - 1; index >= 0; index -= 1) {\n stack.push(children[index] as NodeId)\n }\n }\n return found\n}\n\n/**\n * Reads a form's declared field list off the nodes an author already drew.\n *\n * This is the whole of what adoption has to invent, and it invents nothing:\n * `fieldName`, `fieldType`, `label`, `required`, `options` and\n * `datasetFieldId` are all already props on the `formField` nodes. A form\n * adopted from a page therefore declares exactly the form that page was\n * already submitting.\n *\n * A field with no `fieldName` is DROPPED rather than defaulted. The runtime\n * falls back to `name = fieldName || 'field'`, so several unnamed fields\n * collapse onto one submission key — declaring them would put a key in the\n * schema that does not identify a value.\n *\n * A duplicate `fieldName` keeps its FIRST occurrence, matching the submission\n * the runtime produces: `FormData` entries under one key are joined into that\n * one key, so the second node contributes no separate value.\n */\nexport function formFieldDeclsFromNodes(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formNodeId: NodeId,\n formFieldComponentId = FORM_FIELD_COMPONENT_ID,\n): FormFieldDecl[] {\n const declarations: FormFieldDecl[] = []\n const claimed = new Set<string>()\n for (const id of collectFormFieldNodeIds(nodes, formNodeId, formFieldComponentId)) {\n const props = (nodes?.[id]?.props ?? {}) as Record<string, unknown>\n const fieldName = String(props['fieldName'] ?? '').trim()\n if (!fieldName || claimed.has(fieldName)) continue\n claimed.add(fieldName)\n const rawType = String(props['fieldType'] ?? 'text') as FormFieldType\n const options = parseDeclaredOptions(props['options'])\n const label = String(props['label'] ?? '').trim()\n const datasetFieldId = String(props['datasetFieldId'] ?? '').trim()\n declarations.push({\n fieldName,\n fieldType: FORM_FIELD_TYPES.has(rawType) ? rawType : 'text',\n ...(label ? { label } : {}),\n ...(props['required'] === true ? { required: true } : {}),\n ...(options.length ? { options } : {}),\n ...(datasetFieldId ? { datasetFieldId } : {}),\n })\n }\n return declarations\n}\n\n/** One `form` node found by the discovery scan, with where it was found. */\nexport interface DiscoveredFormNode {\n /** `screen` / `layout` / `component` — what kind of document holds it. */\n sourceKind: 'screen' | 'layout' | 'component'\n sourceId: string\n sourceName?: string\n nodeId: NodeId\n /** The caption the node carries today, normalized the way the route is. */\n formName: string\n /** Already bound, when the node carries a `formId`. */\n formId?: string\n fields: FormFieldDecl[]\n}\n\n/**\n * Finds every `form` node in one document's node map.\n *\n * The corpus and the shape are the *Used by* scan's: a flat\n * `Record<NodeId, AglynNodeSchema>` per screen, layout and component\n * definition. That scan is idle until asked, for the reason its card states\n * in full — reading every screen and every layout on mount is the\n * expensive-read shape this codebase has a standing rule against — and this\n * one inherits that posture rather than re-arguing it.\n */\nexport function discoverFormNodes(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n source: { kind: DiscoveredFormNode['sourceKind']; id: string; name?: string },\n ids: { form?: string; formField?: string } = {},\n): DiscoveredFormNode[] {\n const formComponentId = ids.form ?? FORM_COMPONENT_ID\n const formFieldComponentId = ids.formField ?? FORM_FIELD_COMPONENT_ID\n const found: DiscoveredFormNode[] = []\n for (const [nodeId, node] of Object.entries(nodes ?? {})) {\n if (node?.componentId !== formComponentId) continue\n const props = (node.props ?? {}) as Record<string, unknown>\n const boundId = String(props['formId'] ?? '').trim()\n found.push({\n sourceKind: source.kind,\n sourceId: source.id,\n ...(source.name ? { sourceName: source.name } : {}),\n nodeId,\n // Mirrors the runtime default: an unnamed form submits as `Form`, so\n // that is the caption its history is filed under and the one an\n // adoption has to claim.\n formName: normalizeSubmissionFormName(props['formName']),\n ...(boundId ? { formId: boundId } : {}),\n fields: formFieldDeclsFromNodes(nodes, nodeId, formFieldComponentId),\n })\n }\n return found\n}\n\n/**\n * A form entity's published design, in the shape the graft consumes: the\n * `rootId`/`nodes` snapshot that lives on `hosts/{hostId}/forms/{formId}`.\n *\n * Structurally a component definition minus the parts a form does not have —\n * no declared props, no icon — which is why the graft can take both. It is\n * `Pick`ed off {@link FormDocument} rather than restated so the storage\n * contract stays the single description of what is written there.\n */\nexport type PlacedFormDesign<N = AglynNodeSchema> = Required<\n Pick<FormDocument<N>, 'rootId' | 'nodes'>\n>\n\n/**\n * Whether any node in this map PLACES a form entity (as opposed to merely\n * drawing an unbound form inline).\n *\n * The cost gate in front of the forms read, and cheap on purpose: one scan of\n * a map the caller already holds, against a read that is a whole collection\n * query. Most pages carry no form at all, and a page whose form nodes are all\n * unbound has nothing an entity could contribute — either way there is nothing\n * for the graft to resolve, so the query buys nothing.\n *\n * Says nothing about whether the named form EXISTS or is published; that is\n * settled by the graft, against documents this cannot see.\n */\nexport function placesFormEntity(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n): boolean {\n for (const node of Object.values(nodes ?? {})) {\n if (node?.componentId !== FORM_COMPONENT_ID) continue\n const formId = (node.props as Record<string, unknown> | undefined)?.[\n FORM_ID_PROP\n ]\n if (typeof formId === 'string' && formId.trim()) return true\n }\n return false\n}\n\n/**\n * Whether any node in this map places THIS form.\n *\n * The per-id half of {@link placesFormEntity}, and the predicate a usage scan\n * asks of a screen, a layout or a component definition. Deliberately the same\n * reader the graft resolves against: a scan that disagreed about what counts\n * as a placement would drop the caches of the wrong pages, and the pages it\n * missed would serve the old form for the whole revalidate window with nothing\n * recording that they were skipped.\n */\nexport function nodesPlaceForm(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formId: string,\n): boolean {\n if (!formId) return false\n for (const node of Object.values(nodes ?? {})) {\n if (node?.componentId !== FORM_COMPONENT_ID) continue\n const bound = (node.props as Record<string, unknown> | undefined)?.[\n FORM_ID_PROP\n ]\n if (typeof bound === 'string' && bound.trim() === formId) return true\n }\n return false\n}\n\n/**\n * The `form` node inside a form's OWN design.\n *\n * A form document's tree holds exactly one, because the document IS that\n * form — but the tree is a flat map under a synthetic canvas root, so the\n * node has to be found rather than assumed to be the root itself.\n *\n * Here rather than at each surface because every reader of a form's design\n * needs it before it can ask anything else: the form's page to run\n * `checkFormContract`, the promotion route to publish, the preview to draw,\n * {@link formDesignReboundTo} to rewrite the binding. A caller that found\n * the node its own way and one that found it this way must agree, or a\n * design reads as \"no form here\" on one surface and as bound on the next.\n */\nexport function formNodeIdIn(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n): NodeId | undefined {\n return Object.keys(nodes ?? {}).find(\n (id) => nodes?.[id]?.componentId === FORM_COMPONENT_ID,\n ) as NodeId | undefined\n}\n\n/**\n * The same design, naming `identity` instead of whichever form it named\n * before — or `null` when it already did.\n *\n * ## Why a copied design must be rewritten rather than carried\n *\n * A form's design names its own form in a prop (AGL-3024). That binding is\n * what `/api/forms/submit` stamps a submission with and what the form's own\n * submission list is an equality on, so a design that travels to a SECOND\n * form — duplicated, imported, installed from a starter — arrives naming the\n * first one, and every submission the second form collects is filed under the\n * first. Nothing about that is visible: the copy renders, the visitor\n * submits, the row lands, and the copy's own list simply never grows.\n * `checkFormContract` calls it `form-id-unbound`, and it is the one violation\n * a copy CAUSES rather than inherits.\n *\n * So every path that gives a stored design to a different form rebinds it\n * here, with the same two props the Forms page's Create stamps on a new one:\n * the id the submissions are keyed on, and the caption they are labelled\n * with in the Inbox. Leaving the caption behind is the quieter half of the\n * same bug — the copy's submissions arrive titled with the source's name.\n *\n * `null` for \"nothing to write\", so a caller holding a compressed tree can\n * skip the decode-rewrite-encode round trip entirely in the common case.\n * A design with no form node in it returns `null` too: there is no binding\n * to move, and inventing one is not this function's decision.\n *\n * Pure — the input map and its nodes are not changed.\n */\nexport function formDesignReboundTo<N extends AglynNodeSchema = AglynNodeSchema>(\n nodes: Record<NodeId, N> | undefined | null,\n identity: { formId: string; formName?: string | null },\n): Record<NodeId, N> | null {\n const formId = String(identity.formId ?? '').trim()\n if (!formId || !nodes) return null\n const requested = String(identity.formName ?? '').trim()\n const formName = requested\n ? requested.slice(0, FORM_DISPLAY_NAME_MAX_LENGTH)\n : ''\n const rebound: Record<NodeId, N> = {}\n let changed = false\n for (const entry of Object.entries(nodes)) {\n const [id, node] = entry as [NodeId, N]\n const props = (node?.props ?? {}) as Record<string, unknown>\n if (node?.componentId !== FORM_COMPONENT_ID) {\n rebound[id] = node\n continue\n }\n const bound = String(props[FORM_ID_PROP] ?? '').trim()\n if (bound === formId && (!formName || props['formName'] === formName)) {\n rebound[id] = node\n continue\n }\n changed = true\n rebound[id] = {\n ...node,\n props: {\n ...props,\n [FORM_ID_PROP]: formId,\n ...(formName ? { formName } : {}),\n },\n } as N\n }\n return changed ? rebound : null\n}\n\n/**\n * The placement kind that makes a placed form render its ENTITY'S design.\n *\n * Until this existed the entity's tree was written on every publish and read\n * by nothing: a form's fields had to be redrawn on each page that placed it,\n * and editing the form propagated nowhere. The two documents disagreed the\n * moment either changed, and the page always won.\n *\n * `replacesAuthoredChildren` is what makes that propagation real, and it is\n * the reason the resolution rule is strict. The rule, stated once:\n *\n * - Entity has a published design → the entity's fields ARE the form. Whatever\n * the page drew inside the form node is discarded, exactly as a reusable\n * instance's child list is replaced by its definition's. This is what \"edit\n * the form once\" means; a merge would render a page's stale copy of a field\n * beside the entity's current one.\n * - Entity has no published design, is archived away, or the `formId` names\n * nothing → the form node is left completely alone, inline fields included.\n * Every form built before the entity existed is in this state, so this is\n * the branch that keeps the live site rendering exactly what it renders\n * today.\n *\n * A deleted or unpublished entity therefore degrades to the page's own copy\n * rather than to an empty form — the same fail-open posture the component\n * graft takes for an unresolvable `refId`, and for the same reason: a\n * document going missing must not take a published page's content with it.\n *\n * The discard is a COMPOSE-time one, so it takes nothing away permanently:\n * the page's fields stay in its document, and clearing the binding brings\n * them straight back. That is what makes binding an existing hand-built form\n * to an entity a reversible act rather than a destructive one.\n */\nexport function placedFormPlacement<N extends AglynNodeSchema = AglynNodeSchema>(\n formsById: Record<string, PlacedFormDesign<N> | undefined> | undefined,\n): PlacementKind<N> {\n return {\n componentId: FORM_COMPONENT_ID,\n refProp: FORM_ID_PROP,\n definitionsById: formsById,\n replacesAuthoredChildren: true,\n }\n}\n\n/**\n * The caption as the submit route stores it.\n *\n * Restated here so discovery and the backfill compare the same string the\n * route wrote: `String(formName ?? 'Form').slice(0, 100)`. A differently\n * derived caption on either side would make every legacy match miss, silently\n * and in the safe direction — which is the failure that looks like success.\n */\nexport function normalizeSubmissionFormName(value: unknown): string {\n return String(value ?? '').trim()\n ? String(value).slice(0, FORM_DISPLAY_NAME_MAX_LENGTH)\n : 'Form'\n}\n\n/** The page path as the submit route stores it. */\nexport function normalizeSubmissionPath(value: unknown): string {\n return String(value ?? '').slice(0, 500)\n}\n\n/** A form as the backfill sees it: an id and what it claims of the past. */\nexport interface LegacyMatchCandidate {\n formId: string\n legacyMatch?: FormLegacyMatch | null\n}\n\n/**\n * Which adopted form, if any, a pre-entity submission belongs to.\n *\n * ⛔ **An ambiguous submission is left UNSTAMPED, always.** The two failure\n * modes are not symmetric and the asymmetry is the whole rule:\n *\n * - An unmatched row is still in the Inbox, still readable, still exportable\n * over `/v1`. It is missing from ONE form's list, the Forms page says how\n * many rows are in that state, and a later adoption can still claim it.\n * - A wrongly stamped row is filed under a form it was never sent to. It\n * leaves the Inbox's *Unassigned* view, joins a stranger's submission list,\n * and nothing on any screen says it moved. It is invisible, and invisible\n * is not recoverable.\n *\n * So the match is on the PAIR. `formName` alone is a caption two pages may\n * legitimately share — that shared caption is the defect the form entity\n * exists to fix, and using it as the migration key would carry the defect into\n * the migration. `path` is what tells two same-named forms apart, and it only\n * does so when it was distinct, so both must agree and exactly one form may\n * claim the pair.\n *\n * @returns the form id to stamp, or `null` to leave the row alone. Never a\n * best guess.\n */\nexport function matchSubmissionToForm(\n submission: { formName?: unknown; path?: unknown },\n candidates: readonly LegacyMatchCandidate[],\n): string | null {\n const formName = normalizeSubmissionFormName(submission.formName)\n const path = normalizeSubmissionPath(submission.path)\n // A submission that recorded no path cannot be disambiguated by one, and\n // the pair rule has nothing to stand on. Older rows genuinely predate the\n // field; they stay unstamped rather than falling back to the caption.\n if (!path) return null\n const matched = candidates.filter(\n (candidate) =>\n candidate.legacyMatch?.formName === formName &&\n Array.isArray(candidate.legacyMatch?.paths) &&\n candidate.legacyMatch.paths.includes(path),\n )\n return matched.length === 1 ? (matched[0] as LegacyMatchCandidate).formId : null\n}\n\n/**\n * The value of the declared consent field, as a marketing opt-in.\n *\n * ⛔ THE FACT OF SUBMISSION IS NOT AN OPT-IN, and a form that declares no\n * consent field produces no consent record — on any plan, at any time. This\n * reads ONE field, named by the form's own `consentFieldName`, and asks\n * whether the visitor ticked it.\n *\n * That is not in tension with the standing rule that consent is never\n * inferred: a checkbox the visitor ticked IS an explicit checkbox. What the\n * entity adds is a declared place to look, in place of the closed name list\n * the route has to fall back on when no form is bound.\n *\n * What counts as the tick is {@link isConsentCheckboxTicked}'s, asked with\n * the consent field's own declaration from `form.fields`.\n *\n * Returns `false`, never `undefined`: every writer downstream stores consent\n * absent-or-true and must never write `false` over an opt-in captured\n * elsewhere.\n */\nexport function readFormDeclaredConsent(\n form:\n | {\n consentFieldName?: string\n /** The STORED declaration, for the consent field's type and options. */\n fields?: ReadonlyArray<\n Pick<FormFieldDecl, 'fieldName' | 'fieldType' | 'options'>\n > | null\n }\n | null\n | undefined,\n fields: Record<string, unknown> | null | undefined,\n): boolean {\n const fieldName = String(form?.consentFieldName ?? '').trim()\n if (!fieldName || !fields) return false\n const declaration = Array.isArray(form?.fields)\n ? form.fields.find((entry) => entry?.fieldName === fieldName)\n : undefined\n return isConsentCheckboxTicked(fields[fieldName], declaration)\n}\n\n/**\n * Whether a posted value ticks a consent checkbox.\n *\n * A tick arrives in one of two shapes. A box with no text of its own posts\n * one of {@link AFFIRMATIVE_CHECKBOX_VALUES}. A Checkboxes field posts the\n * TEXT of the option that was ticked — which is what lets its option say what\n * the person agrees to — so when the field's declaration is a checkbox with\n * exactly one option, that option's text, alone, is a tick too: compared as\n * the renderer draws it, trimmed, or as a list holding only it. A field with\n * several options is a question with several answers, and none of them is\n * the opt-in by its text.\n *\n * ⛔ The declaration must come from the STORED form document, never from the\n * request: a declaration a caller could send would let any submission name\n * its own words as the opt-in.\n */\nexport function isConsentCheckboxTicked(\n value: unknown,\n declaration?: Pick<FormFieldDecl, 'fieldType' | 'options'> | null,\n): boolean {\n if (value === true) return true\n if (\n AFFIRMATIVE_CHECKBOX_VALUES.has(\n String(value ?? '')\n .trim()\n .toLowerCase(),\n )\n ) {\n return true\n }\n const option = soleCheckboxOption(declaration)\n if (!option) return false\n const posted = Array.isArray(value)\n ? value.length === 1\n ? value[0]\n : undefined\n : value\n return typeof posted === 'string' && posted.trim() === option\n}\n\n/** A checkbox declaration's one option, or `null` for any other declaration. */\nfunction soleCheckboxOption(\n declaration: Pick<FormFieldDecl, 'fieldType' | 'options'> | null | undefined,\n): string | null {\n if (declaration?.fieldType !== 'checkbox' || !Array.isArray(declaration.options)) {\n return null\n }\n const options = declaration.options\n .map((option) => String(option ?? '').trim())\n .filter(Boolean)\n return options.length === 1 ? (options[0] as string) : null\n}\n\n/** Checkbox values a browser form actually posts for a ticked box. */\nexport const AFFIRMATIVE_CHECKBOX_VALUES = new Set([\n 'true',\n 'on',\n 'yes',\n '1',\n 'checked',\n])\n\n/**\n * The field names the submit route reads an opt-in from when the form\n * declares no consent field.\n *\n * A CLOSED list rather than a substring match on \"consent\": a merchant's\n * field called `consentToTreatment` on a clinic intake form is a different\n * instrument entirely, and matching it would manufacture a marketing basis\n * out of a medical one. Compared after {@link isMarketingConsentFieldName}'s\n * normalization, so `Subscribe to newsletter` and `subscribeToNewsletter`\n * are one name.\n */\nexport const MARKETING_CONSENT_FIELD_NAMES: ReadonlySet<string> = new Set([\n 'marketingconsent',\n 'marketingoptin',\n 'emailoptin',\n 'newsletteroptin',\n 'subscribe',\n 'subscribetonewsletter',\n])\n\n/**\n * Whether a field, by its name alone, is one the submit route reads an\n * opt-in out of when no consent field is declared.\n *\n * Asked of a DESIGN by the publish check and of a PAYLOAD by the route, so\n * the two cannot disagree about which undeclared field counts as consent.\n */\nexport function isMarketingConsentFieldName(name: unknown): boolean {\n return MARKETING_CONSENT_FIELD_NAMES.has(\n String(name ?? '')\n .toLowerCase()\n .replace(/[^a-z]/g, ''),\n )\n}\n\n/**\n * The marketing consent field: the props the Forms editor's **Marketing\n * consent** preset places on a Form Field, and the ones a form generated from\n * a brief carries.\n *\n * A Checkboxes field with ONE option, unticked and not required. The option\n * says what the person agrees to, because a Checkboxes field posts the text\n * of the option that was ticked and {@link isConsentCheckboxTicked} reads that\n * text as the tick. So the option holds no comma and no line break: the\n * Options setting starts a new box at each, and a second box is an answer\n * that is not the opt-in. A site owner may reword the label and the option;\n * the form names the field as its `consentFieldName` for a tick to count.\n */\nexport const MARKETING_CONSENT_FORM_FIELD: Readonly<{\n fieldName: string\n label: string\n fieldType: FormFieldType\n options: string\n required: boolean\n}> = {\n fieldName: 'marketingConsent',\n label: 'Marketing emails',\n fieldType: 'checkbox',\n options: 'Email me news and offers',\n required: false,\n}\n"],"names":["containerMembershipValue","utcMonthKey","displayNameSearchFields","nameSearchTokens","FORM_COMPONENT_ID","FORM_FIELD_COMPONENT_ID","FORM_ID_PROP","FORM_ENTITY_OWNED_PROPS","formPropsOnBind","previousProps","nextProps","nextFormId","trim","bound","key","FORMS_MAX_PER_HOST","isFormArchived","form","Boolean","archivedAt","formListFields","name","displayName","slugWords","slug","replace","searchTokens","Set","id","formCampaignFields","selected","campaignIds","Array","isArray","inCampaign","length","formLeadsStatWhenUncounted","routesLeads","newFormListFields","routing","lead","retired","stats","submissions","leads","lastSubmissionAtMs","FORM_LEAD_SOURCE_PREFIX","formLeadSource","formId","formIdsOfLeadSources","sources","ids","source","startsWith","slice","includes","push","FORM_COUNTER_FIELDS","formCountersFromSource","newestSubmissionAtMs","FORM_LAST_SUBMISSION_TOLERANCE_MS","storedCounter","value","Number","isFinite","formCounterDrift","stored","recounted","drift","field","raw","want","undefined","Math","abs","formCounterPatch","FORM_STAT_KINDS","nextPeriod","period","match","exec","year","month","String","padStart","formPeriodSeries","maxPeriods","periods","keys","Object","filter","test","sort","series","last","cursor","step","views","starts","formStatsWindow","over","of","window","values","denominator","numerator","formPeriodKey","atMs","Date","emptyTotals","countedStat","formStatsTotals","range","from","to","totals","entries","kind","FORM_SLUG_MAX_LENGTH","FORM_DISPLAY_NAME_MAX_LENGTH","normalizeFormSlug","input","toLowerCase","FORM_FIELD_TYPES","parseDeclaredOptions","options","split","map","entry","childIdsOf","node","nodes","collectFormFieldNodeIds","formNodeId","formFieldComponentId","found","seen","stack","reverse","pop","has","add","componentId","children","index","formFieldDeclsFromNodes","declarations","claimed","props","fieldName","rawType","label","datasetFieldId","fieldType","required","discoverFormNodes","formComponentId","formField","nodeId","boundId","sourceKind","sourceId","sourceName","formName","normalizeSubmissionFormName","fields","placesFormEntity","nodesPlaceForm","formNodeIdIn","find","formDesignReboundTo","identity","requested","rebound","changed","placedFormPlacement","formsById","refProp","definitionsById","replacesAuthoredChildren","normalizeSubmissionPath","matchSubmissionToForm","submission","candidates","path","matched","candidate","legacyMatch","paths","readFormDeclaredConsent","consentFieldName","declaration","isConsentCheckboxTicked","AFFIRMATIVE_CHECKBOX_VALUES","option","soleCheckboxOption","posted","MARKETING_CONSENT_FIELD_NAMES","isMarketingConsentFieldName","MARKETING_CONSENT_FORM_FIELD"],"mappings":"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"}