form-graph

Collections

list() is the second combinator (the first is branch): N independent sub-records of one shape — invoice lines, training runs, screening questions — each resolved through its own member graph, with its own branches, its own computeds, its own errors, its own persistence.

const runGraph = defineGraph()
  .field('engine', ENGINE)
  .use(branch('engine', [...arms] as const))
  .computed('lineTotal', ({ hours, rate }) => hours * rate);

const form = defineGraph()
  .field('client', CLIENT)
  .use(list('runs', runGraph, { min: 1, max: 5 }))
  // the assembled elements are ordinary upstream state — aggregate freely
  .computed('total', ({ runs }) => runs.reduce((sum, r) => sum + r.lineTotal, 0));

Any graph can hold a list — root or member, so lists nest. The member graph is an ordinary defineGraph; nothing in it knows it will be repeated.

Element paths

Each element's fields live at dotted paths — runs[a1b2].engine — flat in the resolution, which is the whole trick: subscriptions, per-field diffing, scoped validate, setError and storage are all path-aware with no new machinery. set writes one element without touching siblings:

store.set({ 'runs[a1b2].engine': 'musubi' });
store.getField('runs[a1b2].lineTotal');     // that element's computed
store.setError('runs[a1b2].sku', { message: 'Unknown SKU' });

Ids are identities, not indices — reordering never rewrites an element's address, and persistence follows the id. A fresh list seeds min elements with DETERMINISTIC ids (s0…), so an untouched membership never needs persisting and a stored element's edits bind across sessions; ids minted by add() are random and land in durable intent with the op that created them.

The ops

const runs = store.list('runs');
runs.add(seed?)      // -> { success: true, id }
runs.remove(id)      // sweeps the element's intent subtree and external errors
runs.duplicate(id)   // copies USER-WRITTEN entries; adopted defaults re-derive
runs.move(id, index)

Bounds refuse — { success: false, reason: 'min' | 'max' | 'unknown_id' } — so the store never holds an invalid membership. A boundary value outside the bounds (tampered storage, raw server input) is corrected with a list_bounds note, never obeyed. Every op is an ordinary write: rules can react, storage saves with it.

Validation and data

An element's error keys by its full path (runs[a1b2].epochs) and blames no sibling. validate('runs') judges the whole list — every element, nested lists included — which is what makes a list a wizard step's gate. Parsed data carries the assembled member DATA in membership order, member wire dispositions applied per element; the ids never reach the wire:

result.data.runs
// [ { engine: 'kohya', epochs: 5, lineTotal: 960 },
//   { engine: 'musubi', epochs: 9, lineTotal: 25 } ]

Rendering: the isolation contract

The bindings subscribe to the MEMBERSHIP entry for the shell and per-element for rows — see the React (useList / <ListElement>) and Svelte (list / elementPath) pages. The contract, pinned by render-count tests on both frameworks:

  • editing element X's field wakes X's subscribers only — no sibling, no shell;
  • add / remove / duplicate wake the shell only — no surviving element;
  • reorder wakes the shell only.

Worked examples: the invoice builder (ops, per-row branches, cross-element totals, external errors) and the wizard (a list as a step gate).

Current limits

  • Member-graph effect rules are not merged yet (rule patch keys wouldn't match element paths) — cross-field reactions inside an element belong in computeds and correct for now.
  • The flat registry deliberately excludes member defs (two lists could collide), so typedFields / useTypedField cover root fields; element access types by path SHAPE (`${string}[${string}].${string}`).
  • Server parse takes intent-shaped records — parse(store.getIntent()) round-trips lists losslessly (membership entry + path-keyed values; see Server parsing). Array-of-objects ingestion (parse({ runs: [{…}] })) from a non-form-graph caller is a planned follow-up.