---
title: "Fields: the F namespace (§7.3)"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](/reference/fields)
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](/field-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](/reference/fields).
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.select`s 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

<!-- @sample-preamble
import { F, when } from '@adonia/core'
-->

```ts
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)

```ts
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`](/reference/fields/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](/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.ts` — `hydrate(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.ts` — `belongs-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.ts` — `file`/`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.

Source: https://adonia.pages.dev/fields/index.mdx
