import type { FormBranch, FormFieldSchema, FormSchema } from "./form-schema.js"; /** * Form value model (E3). The working value of a form is a plain JSON object. * Load: `applyValue` coerces a parsed def into the working shape while keeping * unknown fields (lossless — saving must not drop data). Create: `initialValue` * builds a blank def. Save: `coerceForValidate` produces the JSON handed to * `contentSchemas[kind].safeParse` — optional fields emptied by the user are * dropped, `any`-typed strings are parsed back to numbers/booleans. * * Roundtrip guarantee: for a valid def, `coerceForValidate(applyValue(def))` * is JSON-identical to `def` when nothing was edited. */ const DROP: unique symbol = Symbol("drop"); /** A missing form field is omitted from the saved def (documented E3 rule: * optional fields emptied by the form stay absent). */ type CoerceResult = unknown | typeof DROP; export function initialValue(schema: FormSchema): Record { const out: Record = {}; for (const field of schema.fields) { if (!field.optional) out[field.name] = initialFieldValue(field); } return out; } export function initialItemValue(element: FormFieldSchema): unknown { return initialFieldValue(element); } function initialFieldValue(field: FormFieldSchema): unknown { switch (field.type) { case "string": case "ref": case "any": return ""; case "enum": { const values = field.enumValues ?? []; return values[0] ?? ""; } case "number": return 0; case "boolean": return false; case "array": return []; case "object": { const out: Record = {}; for (const f of field.fields ?? []) { if (!f.optional) out[f.name] = initialFieldValue(f); } return out; } case "union": { const first = field.branches?.[0]; return first !== undefined ? branchInitial(field, first) : {}; } case "record": return {}; } } /** Initial JSON for a union branch (used when the branch selector changes). */ export function branchInitial(field: FormFieldSchema, branch: FormBranch): Record { const out: Record = {}; if (field.discriminator !== undefined) { out[field.discriminator] = branch.discriminatorValues?.[0] ?? branch.id; } for (const f of branch.fields ?? []) { if (!f.optional) out[f.name] = initialFieldValue(f); } return out; } /** The standalone value for a branch: a literal string for primitive branches, * the branch object for object branches. */ export function unionBranchValue(field: FormFieldSchema, branch: FormBranch): unknown { if (branch.primitive !== undefined) { if (branch.primitive.type === "enum") return branch.primitive.enumValues?.[0] ?? ""; if (branch.primitive.type === "number") return 0; if (branch.primitive.type === "boolean") return false; return ""; } if (branch.nested !== undefined) return initialFieldValue(branch.nested); return branchInitial(field, branch); } /** Which union branch a value currently selects (first matching branch; for * discriminated unions the discriminator field's value wins). */ export function detectUnionBranch(field: FormFieldSchema, value: unknown): FormBranch | null { const branches = field.branches ?? []; if (branches.length === 0) return null; if (field.discriminator !== undefined) { if (isPlainObject(value)) { const discriminator = value[field.discriminator]; if (typeof discriminator === "string") { for (const branch of branches) { if ((branch.discriminatorValues ?? [branch.id]).includes(discriminator)) return branch; } } } return branches[0] ?? null; } if (!isPlainObject(value)) { for (const branch of branches) { if (branch.primitive !== undefined && primitiveMatches(branch.primitive, value)) return branch; } return branches[0] ?? null; } for (const branch of branches) { if (branchMatches(branch, value)) return branch; } return branches[0] ?? null; } export function applyValue(schema: FormSchema, json: unknown): Record { if (!isPlainObject(json)) return initialValue(schema); return applyObject(schema.fields, json); } function applyObject(fields: FormFieldSchema[], json: Record): Record { const out: Record = {}; const byName = new Map(); for (const f of fields) byName.set(f.name, f); for (const [key, value] of Object.entries(json)) { const field = byName.get(key); if (field === undefined) { out[key] = value; } else { out[key] = applyField(field, value); } } for (const field of fields) { if (!field.optional && !(field.name in out)) out[field.name] = initialFieldValue(field); } return out; } function applyField(field: FormFieldSchema, value: unknown): unknown { switch (field.type) { case "string": case "ref": if (value === null && field.nullable === true) return null; return coerceString(value); case "enum": return value; case "number": return coerceNumber(value); case "boolean": return coerceBoolean(value); case "any": return value; case "array": { if (!Array.isArray(value)) return []; const element = field.element; if (element === undefined) return value; return value.map((item) => applyField(element, item)); } case "object": { if (!isPlainObject(value)) return initialFieldValue(field); return applyObject(field.fields ?? [], value); } case "union": { const branch = detectUnionBranch(field, value); if (branch === null) { const first = field.branches?.[0]; return first !== undefined ? unionBranchValue(field, first) : value; } if (branch.primitive !== undefined) return value; if (branch.nested !== undefined) return applyField(branch.nested, value); if (!isPlainObject(value)) return branchInitial(field, branch); return applyObject(branch.fields ?? [], value); } case "record": { if (!isPlainObject(value)) return {}; const valueField = field.recordValue; if (valueField === undefined) return value; const out: Record = {}; for (const [key, entry] of Object.entries(value)) out[key] = applyField(valueField, entry); return out; } } } export function coerceForValidate(schema: FormSchema, value: Record): Record { return coerceObject(schema.fields, value); } function coerceObject(fields: FormFieldSchema[], value: Record): Record { const out: Record = {}; const byName = new Map(); for (const f of fields) byName.set(f.name, f); for (const [key, raw] of Object.entries(value)) { const field = byName.get(key); if (field === undefined) { out[key] = raw; continue; } const coerced = coerceField(field, raw); if (coerced !== DROP) out[key] = coerced; } return out; } function coerceField(field: FormFieldSchema, raw: unknown): CoerceResult { if (raw === undefined) return field.optional ? DROP : undefined; switch (field.type) { case "string": case "ref": if (raw === "") return field.optional ? DROP : raw; if (raw === null) return field.nullable === true ? null : field.optional ? DROP : ""; if (typeof raw === "string") return raw; return String(raw); case "enum": if (raw === "") return field.optional ? DROP : raw; return typeof raw === "string" ? raw : (field.enumValues?.[0] ?? ""); case "number": { if (raw === null) return field.optional ? DROP : raw; if (typeof raw === "number") return Number.isFinite(raw) ? raw : field.optional ? DROP : raw; if (typeof raw === "string") { const trimmed = raw.trim(); if (trimmed === "") return field.optional ? DROP : raw; const n = Number(trimmed); return Number.isFinite(n) ? n : raw; } if (typeof raw === "boolean") return raw ? 1 : 0; return raw; } case "boolean": if (raw === "true") return true; if (raw === "false") return false; return Boolean(raw); case "any": return coerceAny(raw); case "array": { if (!Array.isArray(raw)) return []; const element = field.element; if (element === undefined) return raw; const out: unknown[] = []; for (const item of raw) { const coerced = coerceField(element, item); if (coerced !== DROP) out.push(coerced); } return out; } case "object": return isPlainObject(raw) ? coerceObject(field.fields ?? [], raw) : {}; case "union": { const branch = detectUnionBranch(field, raw); if (branch === null) return raw; if (branch.primitive !== undefined) { if (typeof raw === "string") return raw; return unionBranchValue(field, branch); } if (branch.nested !== undefined) return coerceField(branch.nested, raw); if (!isPlainObject(raw)) return branchInitial(field, branch); return coerceObject(branch.fields ?? [], raw); } case "record": { if (!isPlainObject(raw)) return {}; const valueField = field.recordValue; if (valueField === undefined) return raw; const out: Record = {}; for (const [key, entry] of Object.entries(raw)) { const coerced = coerceField(valueField, entry); if (coerced !== DROP) out[key] = coerced; } return out; } } } function coerceAny(value: unknown): unknown { if (typeof value !== "string") return value; const trimmed = value.trim(); if (trimmed === "") return value; // Only coerce when the conversion round-trips EXACTLY — "007", "1.50", "1e3" // and friends stay strings (a save must never silently rewrite untouched // any-field values; "007" → 7 is data corruption). if (/^-?\d+(\.\d+)?$/.test(trimmed)) { const n = Number(trimmed); if (Number.isFinite(n) && String(n) === trimmed) return n; } if (trimmed === "true") return true; if (trimmed === "false") return false; return value; } /** Read a value at a path (arrays index by number). */ export function getPath(value: Record, path: readonly (string | number)[]): unknown { let current: unknown = value; for (const key of path) { if (typeof current !== "object" || current === null) return undefined; const container = current as Record; const next = container[key]; if (next === undefined) return undefined; current = next; } return current; } /** Mutate the working value at a path, creating intermediate objects/arrays. */ export function setPath(value: Record, path: readonly (string | number)[], next: unknown): void { if (path.length === 0) return; let current: Record = value; for (let i = 0; i < path.length - 1; i++) { const key = path[i]; if (key === undefined) return; const existing = current[key]; if (typeof existing === "object" && existing !== null) { current = existing as Record; } else { const fresh = containerFor(path[i + 1]); current[key] = fresh; current = fresh; } } const last = path[path.length - 1]; if (last !== undefined) current[last] = next; } /** A path segment that indexes into an array needs an array container; an * object field name needs an object. */ function containerFor(nextKey: string | number | undefined): Record { return (typeof nextKey === "number" ? [] : {}) as unknown as Record; } function branchMatches(branch: FormBranch, value: Record): boolean { if (branch.nested !== undefined) return detectUnionBranch(branch.nested, value) !== null; const fields = branch.fields ?? []; if (fields.length === 0) return Object.keys(value).length === 0; for (const f of fields) { if (f.optional) continue; const entry = value[f.name]; if (entry === undefined) return false; if (f.type === "enum") { const values = f.enumValues ?? []; if (typeof entry !== "string" || !values.includes(entry)) return false; } } return true; } function primitiveMatches(primitive: NonNullable, value: unknown): boolean { if (primitive.type === "enum") { const values = primitive.enumValues ?? []; return typeof value === "string" && values.includes(value); } if (primitive.type === "number") return typeof value === "number"; if (primitive.type === "boolean") return typeof value === "boolean"; return typeof value === "string"; } function coerceString(value: unknown): string { if (typeof value === "string") return value; if (value === null || value === undefined) return ""; return String(value); } function coerceNumber(value: unknown): number { if (typeof value === "number") return Number.isFinite(value) ? value : 0; if (typeof value === "string") { const n = Number(value.trim()); return Number.isFinite(n) ? n : 0; } if (typeof value === "boolean") return value ? 1 : 0; return 0; } function coerceBoolean(value: unknown): boolean { if (typeof value === "boolean") return value; if (value === "true") return true; if (value === "false") return false; return Boolean(value); } export function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }