Adonia compiles each field’s Vine schema, mirrors reactive rules server-side,
caches safe validators, and enforces the persistence allowlist. Vine 4 has no
built-in database rules, so Adonia supplies query-backed unique and exists
rules and batches relation checks before validation. This page documents the
current implementation.
Everything lives in packages/core/src/validation/: validator_compiler.ts
(compilation and the mirroring pipeline), field_meta.ts (the R1–R5
decisions), fill.ts (§19), sets.ts (R6), json_logic.ts (the rule
evaluator). The per-field hand-off is
packages/core/src/schema/validation_spec.ts.
The client half is the form engine; the rule vocabulary and its fixtures are reactivity, server side.
Compiling a validator
const validator = compileValidator(resource, 'edit', ctx, record)compileValidator(resource, mode, ctx?, record?) takes a resource instance
(resources are per-request, §6.1), a mode, and optionally the request context
and the record under edit. It is a pure function — it never reads a cache — so
offline tooling, the conformance fixtures and the testing helpers get a
validator compiled from the schema in front of them. The HTTP path calls
cachedValidator in the same module instead, memoized per §11.3 on
(resource generation, slug, mode, resolved ability map); the tenant is
deliberately not in the key, because by contract tenant and record ids
travel in validator meta rather than in the schema.
ctx is what makes the compile authorization-aware: collectFieldMetas applies
the compile-time canSee / requiresAbility() projection (mirroring rule R2),
so a field the submitting user may not see is absent from the validator and
from the fill set. record is forwarded to record-dependent predicates in
edit.
ValidatorMode is 'create' | 'edit' — there is no third value. The mode
reaches every field as ValidationBuildOptions.mode and narrows the fill set
through readonlyOn (below).
CompiledValidator is immutable and safe to reuse across requests:
| Member | What it answers |
|---|---|
mode |
the mode it was compiled for |
fields |
the FieldMeta[] the compilation was derived from |
stateFor(data) |
the R1 evaluation state: declared defaults merged under the submission |
prune(data) |
a copy of data with every hidden key deleted (R3) |
validate(data) |
the full pipeline: prune → Vine → sets overlay. Throws E_VALIDATION_ERROR |
keysFor(state) |
the exact validator key set (R2+R3) |
requiredFor(state) |
the exact required key set (R4) |
fillFor(state) |
the exact fill key set (§7.4/§19) |
derivedFor(state) |
the target → value map of sets derivations (R6) |
The whole submit, as the resource controller runs it:
async function save(): Promise<void> {
const validator = compileValidator(resource, 'edit', ctx, record)
const output = await validator.validate(body)
const state = validator.stateFor(body)
const fillKeys = validator.fillFor(state)
const attributes = await dehydrateFill(resource, output, fillKeys, ctx, state, 'edit')
applyFill(record, attributes, fillKeys)
}validate throws; stateFor/fillFor do not. dehydrateFill runs outside the
transaction because a user dehydrate() mapper may await I/O, and applyFill
runs inside it, right after the §12 re-authorization.
The compiler never branches on field classes
There is exactly one rule holding §11 together, and it is a rule about code,
not about validation: the compiler does not know what a text-input is.
Field.buildValidation(options) is the whole hand-off:
- the base is
rules(vine)when the author declared one, otherwise the field type’s ownvineSchema(options); applyValidationModifiersthen applies the cross-cutting modifiers in the order Vine requires —nullable()→optional(), and nothing else.
vineSchema is protected abstract, so a field type cannot exist without a
§11.1 mapping, and applyValidationModifiers is shared, so optionality and
nullability behave identically for every type. The compiler only ever decides
whether a key is optional and, when the answer is “it depends”, wraps the key
in the §11.2 group pair; the base comes from below.
What that buys: a custom field type gets validation, mirroring, fill and error
paths for free. Implement vineSchema and the field participates in prune,
bucketed groups, requiredWhen, the fill set and dotted error paths without
touching the compiler. It is also why a repeater works at all — Repeater’s
own vineSchema calls buildFieldObjectSchema, the same function that
compiles the resource, so an item sub-form validates exactly as a form does,
recursively, with no nesting-aware branch anywhere.
packages/core/tests/fields/mapping_table.spec.ts enforces the other half: it
walks the F namespace itself and fails any registered factory that has no
wire type, no display projection, no explicit column decision, or a §11.1 base
that does not compile.
§11.1 — what each field type contributes
Read off the vineSchema overrides in packages/core/src/schema/fields/. The
modifier column lists what the base reads from the field’s own props; the
shared nullable/optional/requiredWhen layer is applied on top of all of
them.
Factory (wire type) |
Vine base | Reads |
|---|---|---|
F.text (text-input), F.textarea (textarea) |
vine.string().trim() |
minLength, maxLength, mask → regex |
F.number (number) |
vine.number() |
min, max, decimal → decimal([0, places]) |
F.slider (slider) |
vine.number(), or vine.array(bounded).fixedLength(2) when range() |
min, max, decimal |
F.select (select) |
vine.enum(values), or vine.array(vine.enum(values)) when multiple() |
declared options |
F.radio (radio) |
vine.enum(values) |
declared options |
F.checkboxList (checkbox-list) |
vine.array(vine.enum(values)) |
minSelected/maxSelected → array minLength/maxLength |
F.checkbox (checkbox), F.toggle (toggle) |
vine.boolean() |
— |
F.date, F.datetime, F.time |
vine.date({ formats }) at the subtype’s granularity |
after, before, each carrying an explicit compare format |
F.color (color) |
vine.string().regex(…) |
notation: hex, oklch, or either |
F.hidden (hidden) |
vine.string(), or vine.number() / vine.boolean() |
valueType() |
F.repeater (repeater) |
vine.array(<item object>) |
min, max |
F.keyValue (key-value) |
vine.record(vine.string()) |
minPairs/maxPairs |
F.json (json) |
vine.any() |
— narrow it with rules() |
F.code (code) |
vine.string() — no trim() |
minLength, maxLength |
F.markdown (markdown) |
vine.string() — no trim() |
minLength, maxLength |
F.richText (rich-text) |
vine.string() — no trim() |
minLength, maxLength |
Three of those omissions are deliberate and worth naming. code and markdown
do not trim because trailing whitespace is significant in both (two trailing
spaces are a markdown line break). richText does not trim because the value is
markup and trimming would make the sanitizer’s output differ from its input for
reasons unrelated to safety. And decimal(places) compiles to decimal([0, places]), not the scalar form: vine.number().decimal(2) demands exactly two
decimals, which would reject 10 for a price.
A choice field that declares no static options but does declare
dependentOptions() falls back to a shape-only base — its option set only
exists once the server resolves it for the submitted state, so membership is
checked by a batched pre-validation step (the batched relation-check contract) rather than an enum.
Field-by-field props are documented in the F namespace reference.
The field-level DSL
required()
required() sets props.required = true, which the compiler reads as “not
optional”. required({ field, matcher }) is sugar for
requiredWhen(field, matcher) — the same declarative rule, mirrored
server-side, not a static flag.
A required field is not unconditionally required: isRequiredFor returns
false when the field is hidden or disabled for the submitted state (R4/R5).
That is a correctness requirement, not a leniency — demanding a value for an
input the UI could never present would reject submissions nobody can fix.
nullable()
Allows null as a submitted value. It is recorded twice on purpose: on the
field (isNullable, consumed by buildValidation) and in props.nullable
(which rides the wire). Distinct from optional: nullable is about the value,
optional about the key.
unique()
F.text('slug').unique()
F.text('email').unique({ table: 'users', column: 'email_address', ignoreSelf: false })unique(opts) records { ignoreSelf: true, ...opts } on the field, readable as
field.uniqueSpec. ignoreSelf defaults to true, which is what makes an
edit that leaves the value untouched pass: the probe adds
whereNot(primaryKey, currentId) in edit mode, taking the id from validator
meta rather than from the input (the edit uniqueness contract). table and column default
to the resource model’s table and the field attribute’s column, and an active
tenant scope (§5.3) folds into the same query.
Per the database-rule contract the probe is a first-party vine.createRule rule
named database.unique, closing over the Lucid query builder — Vine 4.4 has no
.unique() of its own; the spec sentence claiming otherwise is the database-rule contract.
The measured SQL is SELECT id FROM posts WHERE slug = ? AND tenant_id = ? LIMIT 1, plus AND id != ? in edit.
In this build unique() is declaration-only. The spec is recorded on the
field and nothing reads it yet: no vineSchema consumes uniqueSpec, so no
database.unique rule is emitted and no query runs. Declare it — the shape is
frozen and the DSL will not change — but rely on a database unique index for
the guarantee until the rule lands.
minLength() / maxLength()
Both write into the props bag (props.minLength / props.maxLength) and are
read by the field type’s vineSchema. Two consequences follow. They only bind
on types whose base reads them — the string family, plus code, markdown and
richText; on F.number the bounds are min/max instead. And because they
live in props, they also ride the wire and become rendering hints on the
control.
rules()
F.text('slug')
.required()
.rules((v) => v.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).maxLength(160))rules(fn) hands you the vine root and replaces the field type’s entire
base. Everything vineSchema would have produced is gone: the trim(), the
enum membership, the date parse formats and after/before bounds, the numeric
min/max/decimal, and — the easy one to miss — the minLength/maxLength
you declared through the DSL, because those are props that only vineSchema
reads. They still ride the wire as rendering hints, so the control will look
constrained while the server no longer enforces it. Restate every constraint you
still want inside the callback.
What survives is everything applied around the base:
nullable()→.nullable(),- the compiler’s optionality decision (from
required()and R4), - and the entire §11.2 pipeline: prune, bucketed groups, the complementary group
pair that conditional requiredness compiles to, the fill set,
setsre-derivation, dotted error paths.
So a rules() override changes the shape check and nothing else.
F.json('meta')
.nullable()
.rules((v) =>
v.object({
canonicalUrl: v.string().maxLength(2048),
noindex: v.boolean(),
})
)This is the intended use of F.json, whose own base is vine.any().
One friction point is worth knowing before you reach for .nullable() or
.optional() inside a rules() callback: under exactOptionalPropertyTypes
— which this repo and tsconfig.base.json both enable — Vine 4’s
NullableModifier<T> and OptionalModifier<T> do not satisfy SchemaTypes
(their isOptional/allowNull widen to boolean | undefined, and
ConstructableSchema declares both as optional properties), so a modified
member inside a nested v.object({ … }) fails to typecheck — which is why the
members above are declared bare. That is why applyValidationModifiers narrows
through a small structural interface instead of chaining directly; it returns
SchemaTypes, so it is also the escape hatch when the callback itself must
return a modified schema.
Mirroring (§11.2)
Client evaluation is cosmetic. Every visibleWhen, requiredWhen and
disabledWhen is re-evaluated server-side against the submitted state, on every
submit, with no trusted flags on the wire. A bypassed UI cannot change what is
validated or what is written.
The state the rules see is the R1 evaluation state: declared field defaults merged under the submitted data, so the submission wins. One pass, no fixpoint — rule outcomes never feed back into the state, which is precisely what lets the client and the server agree.
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
schema(s: SchemaBuilder): SchemaComponent[] {
return [
s.section('Publication').components([
F.select('status')
.options({ draft: 'Draft', review: 'In review', published: 'Published' })
.required(),
// Conditional requiredness → a complementary `vine.group` pair, always.
F.datetime('publishedAt').requiredWhen(when('status').is('published')),
// Visibility → a bucketed `vine.group`; hidden means absent, not empty.
F.textarea('subtitle').nullable().visibleWhen(when('status').in(['review', 'published'])),
// A composite rule is no different: the same pair, one JsonLogic predicate.
F.number('readingMinutes')
.nullable()
.requiredWhen(when.all([when('status').is('published'), when('featured').isTruthy()])),
// R5: a disabled input is never required and never filled.
F.text('editorNote').requiredWhen(when('status').is('review')).disabledWhen(when('status').is('draft')),
]),
]
}
}The compiled shape
Two distinct compilations come out of that schema, and which one a field gets depends only on whether its rule is conditional — never on the rule’s shape, never on the field’s type.
import vine from '@vinejs/vine'
import type { FieldContext, SchemaTypes } from '@vinejs/vine/types'
import { applyValidationModifiers } from '@adonia/core'
/** How a group predicate reads the R1 state the validator carries in meta. */
const statusIn = (field: FieldContext, values: readonly string[]): boolean => {
const state = field.meta['adonia.state'] as Record<string, unknown> | undefined
return values.includes(String(state?.['status'] ?? ''))
}
/** The compiler's `build.required(meta)` and `build.optional(meta)`. */
const required = (base: SchemaTypes): SchemaTypes =>
applyValidationModifiers(base, { mode: 'create', optional: false, nullable: false })
const optional = (base: SchemaTypes): SchemaTypes =>
applyValidationModifiers(base, { mode: 'create', optional: true, nullable: false })
const compiled = vine
.object({
// 1. unconditional
status: vine.enum(['draft', 'review', 'published']),
})
// 2. conditional requiredness → a complementary pair inside ONE group
.merge(
vine
.group([
vine.group.if((_data, field) => statusIn(field, ['published']), {
publishedAt: required(vine.date()),
}),
vine.group.if((_data, field) => !statusIn(field, ['published']), {
publishedAt: optional(vine.date()),
}),
])
.otherwise(() => {})
)
// 3. one single-conditional group per DISTINCT visibility rule
.merge(
vine
.group([
vine.group.if((_data, field) => statusIn(field, ['review', 'published']), {
subtitle: optional(vine.string().trim()),
}),
])
.otherwise(() => {})
)Each piece is forced by measured Vine behaviour (the conditional-validation contract):
- The prune step comes first.
vine.groupis closed-world: a key present while its group’s condition is false trips a root-levelunionGrouperror rather than being dropped. So hidden keys are deleted server-side before Vine runs. Prune + group + no-opotherwisetogether deliver “hidden ⇒ neither validated nor present in the output”. - Groups are bucketed by the exact visibility rule. Conditionals inside one
group are first-match-wins alternatives, so two
group.ifentries sharing a condition silently drop the loser’s keys — measured data loss. One single-conditional group per distinct rule, always. .otherwise(() => {})on every group. “No condition matched” is the normal hidden case, not an error.- Predicates read the R1 state from validator meta, under the key
adonia.state, not from Vine’s own data view — which has already been pruned and would disagree about a rule referencing a hidden key. disabledWhenfolds into the requiredness rule. A statically disabled field is never required; otherwise the emitted rule isand(requiredWhen, not(disabledWhen)). It is a conjunction rather than a second sibling condition so the compiled schema andisRequiredForcannot drift.
Every conditionally required field takes that pair — simple equality
included. Vine’s own requiredWhen(field, '=', value) fits the simple shape and
is still deliberately unused: it resolves the sibling through field.parent,
i.e. the pruned request body, with no defaults merged and hidden keys already
deleted, and inside a repeater it looks the sibling up in the ITEM rather than
the form. Every other §11.2 decision reads the R1 state, so the two disagreed
whenever the condition field was defaulted, hidden, or lived in the parent form
of a repeater row — and always in the unsafe direction: requiredFor() said
required while Vine accepted the submission. One rule evaluated in one place is
worth more than a native fast path.
Inside a repeater the same machinery runs one level down, per item, with the
item as the ./ row scope. Visibility is a property of the row, not of the
column: a child hidden in item 1 disappears from item 1 and stays in item 0.
Hidden means gone
A field hidden by the submitted state is excluded from both the validator
and the fill set. Not “validated as optional”, not “written as null” —
absent. keysFor(state) will not list it, fillFor(state) will not list it,
prune(data) deletes it, and a forged value for it never reaches the model.
Mass assignment (§19)
The fill set is the exact list of model attributes one submission may write, and
it is derived from the compiled schema for that submission’s (mode, visibility-state) — not from a static allowlist.
import { compileFillKeys } from '@adonia/core'
const fillKeys = compileFillKeys(resource, 'edit', { status: 'draft' }, ctx, record)fillableKeys(metas, state, mode?, scope?) is the predicate, and
compileFillKeys / CompiledValidator.fillFor both call it so the two surfaces
cannot drift. A key is fillable when all four hold, each verified against
the field’s own meta:
| Condition | Source |
|---|---|
not virtual() |
meta.virtual — field.isVirtual or props.virtual |
| not read-only for this mode | meta.readonlyOn.includes(mode) — from readonlyOn('edit') |
| visible for the state (R3) | isVisibleFor — visibleWhen against the R1 state |
| not disabled for the state (R5) | isDisabledFor — static disabled(), else disabledWhen |
The readonlyOn narrowing only applies when a mode is passed; the unit-test
and offline shapes omit it deliberately.
Then three functions do the writing, in order:
applyFill(record, payload, fillKeys)— the only write path from validated input to the model. It iteratesfillKeys, never the payload, so a forged attribute is not merely rejected but never read. A fill key absent from the payload leaves the attribute untouched (create: the model default; edit: the prior value).dehydrateFill(resource, payload, fillKeys, ctx, state?, mode?, record?)— runs each fill-set value through its field type’sdehydrate(§7.2), so the model receives storage shapes: a LuxonDateTimeinstead of an ISO string, a number instead of a numeric string, a sqlite0/1instead of a boolean. Mappers may be async, so conversion is sequential and awaited. Inside a repeater the §19 guard re-runs per row before children are converted, so a child disabled in one row is dropped from that row only, and the author’s owndehydrate()on the repeater runs last, over the already-cleaned array.compileFillKeys— the standalone derivation, for code that needs the set without a validator.
Two more exclusions are worth stating because they are not obvious. A field
gated by canSee / requiresAbility() never reaches FieldMeta at all when a
ctx is supplied (R2), so it is outside the fill set by construction rather
than by filtering. And a sets derivation whose target is not fillable is
computed but discarded — a hidden or disabled target must not re-enter through
the back door of its own derivation.
sets transforms
sets derivations are a client convenience — “slugify the title as I type” —
but a client that never ran them still submits, and a hostile one submits
whatever it likes for the target. So the server recomputes every entry from the
submitted sources and overlays the result onto the validated output (R6). A
tampered slug loses.
F.text('title').live().sets('slug', 'slugify')
F.markdown('body').live(500).sets('readingMinutes', 'fn:readingTime')Four transforms are built in, and each must agree character-for-character with the client engine’s copy — the fixture corpus is the shared oracle:
| Name | Behaviour |
|---|---|
slugify |
lowercase, trim, non-alphanumeric runs → single -, dashes trimmed off both ends |
lowercase |
String(value ?? '').toLowerCase() |
uppercase |
String(value ?? '').toUpperCase() |
copy |
identity — the source/if options carry the meaning |
Anything else is fn:<name>, resolved from the server registry:
registerTransform('readingTime', (value) => {
if (typeof value !== 'string') return 0
const words = value.trim().split(/\s+/).filter((word) => word.length > 0).length
return Math.max(1, Math.ceil(words / 200))
})Register at boot — a provider boot() hook or a plugin. The name may be written
with or without the fn: prefix, and colliding with a built-in throws
InvalidConfigException rather than shadowing it. A transform takes
(value, state): the whole evaluation state is passed so an aggregation can
read siblings, for instance summing a price column across a repeater’s rows.
Transforms MUST be pure — they run on every submit, and the client runs its own
copy on every keystroke.
source defaults to the declaring field and accepts dotted paths into JSON
columns and repeater rows; if guards the entry. Entries run in declaration
order across fields, in one pass: a later derivation reading an earlier
target sees the submitted value, not the derived one. That is the same
no-fixpoint discipline rule evaluation follows, and it is what keeps client and
server in step.
An unresolvable fn:* fails compilation. assertTransformsResolvable runs
inside compileValidator (and inside descriptor compilation), so a typo
surfaces on whichever comes first — rendering the form or submitting it — with a
message naming the resource, field, target, transform and the registered names.
deriveSets still fails closed if it is ever reached with an unresolved name:
skip the derivation, warn in dev, never eval. registeredTransforms() lists
what is registered, and resetTransforms() clears the fn:* registry for test
hygiene.
Errors
Vine throws E_VALIDATION_ERROR carrying a SimpleError[].
toInputErrorsBag(errors) folds it into the Inertia inputErrorsBag shape —
Record<fieldPath, message>, first error per field wins:
{
"title": "The title field must be defined",
"items.1.qty": "The qty field must be at least 1",
"items.2.title": "The title field must have at least 3 characters"
}There is no path rewriting anywhere in that fold (the native Vine path contract). Vine
already emits dot-joined paths for nested and array fields, including
arbitrarily deep ones like items.0.children.1.name, so the mapping is a pure
fold and a repeater needs no special handling on either side.
From there the bag reaches the form unchanged: the resource controller flashes
it under the session key inputErrorsBag and redirects back, Inertia shares it
as the page’s errors prop, and useFormEngine unwraps it — accepting both the
named-bag and the flattened wire shapes — into controller.error on each field.
A rejected login flashes the identical bag from AuthController, so a failed
save and a failed sign-in are indistinguishable in shape. Unknown keys are
stripped rather than reported, at the top level and inside repeater items alike.
Testing a schema’s validation
@adonia/core/testing exposes the submit pipeline as a structured result rather
than an exception:
import { validateWith } from '@adonia/core/testing'
async function check(): Promise<void> {
const result = await validateWith(resource, 'create', {
title: 'Hello',
status: 'draft',
publishedAt: '2026-01-01T00:00:00.000Z',
})
// `publishedAt` is not required for a draft, and a field hidden by the
// submitted state would be absent from `result.output` entirely.
console.log(result.outcome, result.errors, result.output)
}validateWith runs prune → Vine → sets re-derivation and returns
{ outcome, errors, output }, with errors already in inputErrorsBag shape.
For the mirroring invariants themselves, packages/core/tests/mirroring.spec.ts
runs the whole fixture corpus in docs/reactivity/fixtures/ — every case, in
both modes unless the case pins one — asserting exact validator, required and
fill key sets. The @adonia/ui parity suite reads the same files, which is how
the two evaluators are held to one answer.