form-graph

Server parsing

form.parse(raw, ext) is the server entry point, and it is the same pipeline the client walks: boundary input schemas → resolve → strict output validation. Pure — no store, no shared state.

const result = form.parse(rawBody, ext);

if (!result.success) {
  // result.errors: Record<string, FieldError>
  // FieldError = { message, code, issues: SchemaIssue[] }
  // every issue survives, with path relative to the field —
  // issues[1].path === [2, 'id'] means row 2's id failed.
  return badRequest(result.errors);
}

result.data;         // the discriminated union, strict-validated
result.computedKeys; // which keys were derived, not user input
result.notes;        // substitution notes (see below)

What to send: store.getIntent() is the wire format

The client payload is the store's own intent record — store.getIntent() — sent as JSON. parse reads it exactly the way the store reads it: a scoped address (steps@flux) wins for the field it names, a bare key serves any field, a list's membership rides as its own entry (runs: ['s0','a1b2']) with element values under path keys (runs[s0].epochs). Every entry is treated as boundary input server-side — lenient input schemas run regardless of how the client stored it.

// client
await fetch('/api/submit', { body: JSON.stringify(store.getIntent()) });

// server — the identical pipeline, from the identical record
const result = form.parse(raw, ext);

Plain key-addressed raw ({ prompt, steps } from any external caller) parses too — the scoped-address form is what makes the client's own submit lossless, including per-branch memory the user isn't currently looking at.

SvelteKit form action

// +page.server.ts
export const actions = {
  default: async ({ request }) => {
    const raw = JSON.parse(String((await request.formData()).get('state')));
    const result = form.parse(raw, ext);
    if (!result.success) return fail(400, { errors: result.errors });
    return { data: result.data };
  },
};

The live demo makes this same parse() call — the site is statically hosted, so it runs in the page instead of a form action.

Substitution notes

When a correct policy (the def's correct property) replaces a value — a retired model swapped for the default, a quantity clamped to a limit — the field can attach a note with a machine-readable reason. Notes ride on the parse result (on failures too), so the server can log, bill, or refuse based on why a value changed rather than diffing blindly.

for (const note of result.notes ?? []) {
  // { key, reason: 'locked_default' | 'ecosystem_mismatch' | ..., detail }
}

Partial parse

form.parsePartial(raw, ext) returns every valid field plus the errors — for cost estimation and other best-effort reads where one bad field shouldn't void the rest.

Extracting types

Handlers downstream of parse shouldn't re-declare what the graph already knows. Four utility types extract it from the graph value itself:

import type { InferData, InferState, InferArm, InferLooseData } from 'form-graph';

type Data  = InferData<typeof form>;   // the parsed discriminated union (result.data)
type State = InferState<typeof form>;  // the resolved state snapshot

// One arm of the union, selected by a discriminator value. Subset-matching,
// so it works when a branch pair groups several keys into one arm:
type UpscaleData = InferArm<typeof form, 'mode', 'upscale'>;

// Every field optional and unknown — the shape of raw/stored input:
type Loose = InferLooseData<typeof form>;

InferArm distributes over a union of discriminator values, so InferArm<G, 'mode', 'a' | 'b'> is the union of both arms. Type a per-branch handler as a function of its arm and the compiler holds the wire contract for you.

Introspection

Because branches are plain code, enumeration is execution: enumerateBranches walks the reachable branch space, hasField / whereFieldExists answer "does this field exist under these pins", and fieldMeta resolves one field's metadata under pinned values — all without a store.