| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- 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<string, unknown> {
- const out: Record<string, unknown> = {};
- 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<string, unknown> = {};
- 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<string, unknown> {
- const out: Record<string, unknown> = {};
- 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<string, unknown> {
- if (!isPlainObject(json)) return initialValue(schema);
- return applyObject(schema.fields, json);
- }
- function applyObject(fields: FormFieldSchema[], json: Record<string, unknown>): Record<string, unknown> {
- const out: Record<string, unknown> = {};
- const byName = new Map<string, FormFieldSchema>();
- 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<string, unknown> = {};
- for (const [key, entry] of Object.entries(value)) out[key] = applyField(valueField, entry);
- return out;
- }
- }
- }
- export function coerceForValidate(schema: FormSchema, value: Record<string, unknown>): Record<string, unknown> {
- return coerceObject(schema.fields, value);
- }
- function coerceObject(fields: FormFieldSchema[], value: Record<string, unknown>): Record<string, unknown> {
- const out: Record<string, unknown> = {};
- const byName = new Map<string, FormFieldSchema>();
- 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<string, unknown> = {};
- 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<string, unknown>, 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<string | number, unknown>;
- 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<string, unknown>, path: readonly (string | number)[], next: unknown): void {
- if (path.length === 0) return;
- let current: Record<string | number, unknown> = 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<string | number, unknown>;
- } 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<string | number, unknown> {
- return (typeof nextKey === "number" ? [] : {}) as unknown as Record<string | number, unknown>;
- }
- function branchMatches(branch: FormBranch, value: Record<string, unknown>): 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<FormBranch["primitive"]>, 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<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value);
- }
|