State-bearing schema nodes. Every factory takes the model attribute (dotted
for JSON columns) and returns a fresh instance carrying the full Field DSL
— chrome, projection, state, validation, reactivity (see
packages/core/src/schema/field.ts) — plus its own typed props.
Per-type detail is generated: the fields reference carries one page per type key — its own builder methods, its detail projection, its table cell and its §11.1 Vine base — written from the field classes themselves. This page is the remainder: the mechanics every type inherits and the decisions a generator cannot state.
Client half: field and display components — one registered component per type key, driven by the same committed descriptor snapshots this page’s suites write.
The field set
Every registered type key with its builder, own DSL, Vine base and detail/cell projections is in the reference. Two cross-cutting rules live here instead, because they are decisions rather than declarations:
badge-entry / badge apply when the field’s declared options carry
color; otherwise text-entry / text. The decision is per instance, so
two F.selects in one schema can project differently.
decimal(places) is a MAXIMUM precision ([0, places]), not an exact count.
step is an input affordance and is deliberately not validated — rejecting
an off-step value would turn a UI nicety into a submission error the client
never warned about.
Options
F.select('status').options({ draft: 'Draft', published: 'Published' })
F.select('status').options([
{ value: 'draft', label: 'Draft', color: 'gray' },
{ value: 'published', label: 'Published', color: 'green', icon: 'check' },
{ value: 'archived', label: 'Archived', color: 'amber', disabled: true },
])A choice field that declares no static options but does declare
dependentOptions() falls back to a shape-only Vine base; membership against
the resolved list is a batched pre-validation step (the batched relation-check contract).
F.repeater — the state boundary (§7.3)
F.repeater('items', [
F.text('title').required().minLength(3),
F.number('qty').min(1).required(),
F.text('note').visibleWhen(when('./title').isTruthy()),
])
.min(1)
.max(20)
.reorderable()
.collapsible()
.itemLabel('title')
.table()
.defaultItems(1)The DSL and the Vine mapping are in
repeater. What follows is the part that
is not per-type: what a state boundary means for keys, validation and
reactivity.
State is an array of item objects, keyed by the child fields’ own flat
keys. min/max bound the row count; defaultItems(n) seeds n blank
rows (each child key present, holding its default or null); with no
defaultItems, the create-mode default is [], never null.
Keys are the child’s, paths are the runtime’s
Protocol §2 rule 2 says a key is never namespaced by LAYOUT. A repeater is the other thing — a state boundary — so the rule needs stating in full:
- the sub-form rides as the node’s
children, with the children keeping their own flat keys (title, neveritems[2].title); items.2.titleis a runtime PATH into the array value — what Vine’s error reporter emits and what the client’s row cursor builds — and never a node key;- the form’s
stateKeyscontainitemsand nothing from inside it. The descriptor walk descends through layout and stops at the first keyed node, socollectFields,defaultState,hydrateStateand the validator’s top level all agree by construction.
A child key may therefore collide with a form key (title in both) with no
ambiguity: they live in different state maps.
Validation and error mapping
The item object is built by buildFieldObjectSchema — the same function
that compiles the resource validator — from each child’s own
buildValidation. Every field type consequently works inside a repeater,
including another repeater, and the §11.2 machinery (prune, bucketed
vine.group, native requiredWhen) applies per item rather than per
column.
Errors need no path rewriting (the nested-validation contract, decision 4). Vine emits
items.2.title and items.0.children.1.name already dot-joined, and
toInputErrorsBag is a pure first-error-wins fold over them. Array-level
violations (min/max) report at the array key itself. Unknown keys inside
an item are silently stripped, exactly as in a plain object.
Row-scoped reactivity
Inside an item, ./-prefixed var paths resolve against the ROW and every
other path against the root form state (the repeater-scoping contract). There is no
relative-first fallback and no ../: an absolute path already is the escape
to the parent form.
The consequences are per row, not per column:
- a child hidden in row 1 is pruned from row 1 and untouched in row 0;
- a child
requiredWhenfires for the rows that satisfy it, reporting atitems.<i>.<key>; - the fill set of the array is the union of the per-item fill sets, so a
child disabled, virtual or read-only in one row is dropped from that row
only.
dehydrateFill(resource, output, fillKeys, ctx, state, mode)takes the R1 state and the mode for exactly this reason.
Persistence
A repeater writes its array to ONE attribute — a JSON column, or whatever
the author’s dehydrate() maps it to. Persisting rows through a relation is
F.hasMany (§7.3), a Phase-3 row.
itemLabel(key) rides as props.itemLabel and the client relabels rows
live. itemLabel(fn) is server-side only — descriptors are data (protocol
§2 rule 1) — and is read through Repeater.labelFor(item, index).
The client half is RepeaterInput: add/remove/reorder, collapsible rows, the
compact table layout, min/max enforced on the buttons, and the nested
error paths routed onto the right row. See
field components.
The date family
Form state is always a JSON string (protocol §7), never a DateTime:
| Type | Form state | Example |
|---|---|---|
date |
YYYY-MM-DD civil date in the field timezone |
2024-03-05 |
datetime |
UTC ISO-8601 instant | 2024-03-05T09:20:30.000Z |
time |
HH:mm:ss civil time in the field timezone |
13:45:00 |
date and time are civil values, so the field timezone is what fixes them
to an instant on the way into the model. datetime is absolute, so it rides
the wire in UTC and is re-expressed in the field timezone on the way in —
Lucid’s prepareDateTimeColumn formats a DateTime in its own zone.
timezone(zone) defaults to the app timezone (TZ, else the host’s resolved
IANA zone). displayTimezone(zone) is a client rendering prop and never
affects storage.
Luxon is an optional peer dependency. Reading a DateTime off a record
is duck-typed (isLuxonDateTime), so it works across pnpm peer-variant
splits and without Luxon of our own. Writing needs a real constructor, so
vendor/luxon.ts resolves it lazily; when it is absent, dehydration passes
the ISO string through, which prepareDateTimeColumn accepts verbatim.
hydrate / dehydrate
Each field type owns the conversion between its wire shape and its storage shape, and the pipeline never branches on concrete field classes:
Field.hydrateValue(record, ctx)— the author’shydrate()when declared, otherwise the type’sdefaultHydrate. Called byhydrateState(descriptor, record, { fields, ctx })when building edit state.Field.dehydrateValue(value, ctx)— the author’sdehydrate()when declared, otherwise the type’sdefaultDehydrate. Called bydehydrateFill(resource, output, fillKeys, ctx), which runs between validation andapplyFill(outside the write transaction, since a user mapper may await I/O).
Built-in conversions: Luxon DateTime ⇄ ISO string for the date family,
string ⇄ number for number/slider (pg and mysql2 return DECIMAL as a
string), and 0/1 ⇄ boolean for sqlite-backed checkbox/toggle via
asInteger(). A toggle reads 0/1/'true' back as a boolean whether or
not asInteger() was declared; the flag only affects what is WRITTEN.
Custom subclasses override defaultHydrate/defaultDehydrate, never
hydrateValue/dehydrateValue — the author’s own hydrate()/dehydrate()
must always win.
Reactivity
Every field carries live(), visibleWhen(), requiredWhen(),
disabledWhen(), sets() and dependentOptions(). Conditions are written
with the when() DSL (when('status').is('published'),
when.all([...])), compile to the JsonLogic subset of the reactivity contract, and are
mirrored in the compiled validator — the client evaluation is cosmetic.
See docs/reactivity/server.md for the grammar, the sets transform
registry and what the validator does with each rule shape;
docs/form-engine.md for the client half.
Tests
packages/core/tests/fields/:
field_cases.ts— the conformance table, one row per type, shared by all four suites below.descriptor_snapshot.spec.ts— descriptor snapshot per field × mode, plus the projection and JSON-safety assertions.validation.spec.ts— the §11.1 mapping row by row (Vine class + exact accept/reject verdicts), the shared modifiers, and a whole-schema pass through the real validator compiler.round_trip.spec.ts—hydrate(dehydrate(x)) === xfor every type, plus the storage-shape assertions (Luxon instance,0/1, numeric coercion).mapping_table.spec.ts— walks theFnamespace itself: every registered factory must declare a wiretype, adisplayType, an explicitcolumnTypedecision, and a compilable §11.1 base, and must be covered byfield_cases.ts. A field type cannot be added without its mapping.repeater.spec.ts— the §7.3 state boundary: sub-form aschildren, child keys absent fromstateKeys, nested and nested-nested error paths (items.2.title,items.0.children.1.name),min/max, per-row visibility/requiredness/disabledness, reorder round-trip, and key collision between a form key and a child key.relations.spec.ts—belongs-to/belongs-to-manyagainst a recording probe: thedatabase.existsverdicts (in scope, out of scope, wrong shape), the batched one-query-per-selection rule and itstags.1error paths, pivot item objects, hydration out of a preloaded relation, and the pivotsync()payload.file.spec.ts—file/imageagainst an in-memory Drive disk: temp-key promotion intodirectory(),preserveFilename(), the §19 provenance refusal, and deferred supersession on replace or clear.
Rows that opt a suite out
Three field types are not a pure function of their own declaration: a
relation’s §11.1 base needs a resolvable target to probe, belongs-to-many
hydrates out of a preloaded relation rather than out of the attribute it
names, and a file promotes its object through a Drive disk. A row that
cannot supply the collaborator sets skips.validation / skips.roundTrip
with a SuiteOptOut (requires, reason, coveredBy) instead of inventing
fixtures that pass for the wrong reason — an exists rule compiled without a
target is inert, so an accept verdict would claim a check that never ran.
The suite then reports the row as skipped with that reason, and
mapping_table.spec.ts holds the door shut: the named coveredBy file must
exist, must be a *.spec.ts the runner loads, and must carry a matching
@covers <case>:<suite> tag; a suite must be either driven with fixtures or
opted out, never both and never neither. Structural facts — type,
displayType, columnType, the Vine base class, the descriptor snapshot —
are asserted for every row regardless.