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
effectrules are not merged yet (rule patch keys wouldn't match element paths) — cross-field reactions inside an element belong in computeds andcorrectfor now. - The flat registry deliberately excludes member defs (two lists could collide), so
typedFields/useTypedFieldcover root fields; element access types by path SHAPE (`${string}[${string}].${string}`). - Server
parsetakes 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.