Skip to content

Fields: the F namespace (§7.3)

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, never items[2].title);
  • items.2.title is 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 stateKeys contain items and nothing from inside it. The descriptor walk descends through layout and stops at the first keyed node, so collectFields, defaultState, hydrateState and 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 requiredWhen fires for the rows that satisfy it, reporting at items.<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’s hydrate() when declared, otherwise the type’s defaultHydrate. Called by hydrateState(descriptor, record, { fields, ctx }) when building edit state.
  • Field.dehydrateValue(value, ctx) — the author’s dehydrate() when declared, otherwise the type’s defaultDehydrate. Called by dehydrateFill(resource, output, fillKeys, ctx), which runs between validation and applyFill (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.tshydrate(dehydrate(x)) === x for every type, plus the storage-shape assertions (Luxon instance, 0/1, numeric coercion).
  • mapping_table.spec.ts — walks the F namespace itself: every registered factory must declare a wire type, a displayType, an explicit columnType decision, and a compilable §11.1 base, and must be covered by field_cases.ts. A field type cannot be added without its mapping.
  • repeater.spec.ts — the §7.3 state boundary: sub-form as children, child keys absent from stateKeys, 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.tsbelongs-to/belongs-to-many against a recording probe: the database.exists verdicts (in scope, out of scope, wrong shape), the batched one-query-per-selection rule and its tags.1 error paths, pivot item objects, hydration out of a preloaded relation, and the pivot sync() payload.
  • file.spec.tsfile/image against an in-memory Drive disk: temp-key promotion into directory(), 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close