Skip to content

Validation

Compile Vine validators that mirror reactive rules and protect persistence.

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:

  1. the base is rules(vine) when the author declared one, otherwise the field type’s own vineSchema(options);
  2. applyValidationModifiers then 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, maskregex
F.number (number) vine.number() min, max, decimaldecimal([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, sets re-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.group is closed-world: a key present while its group’s condition is false trips a root-level unionGroup error rather than being dropped. So hidden keys are deleted server-side before Vine runs. Prune + group + no-op otherwise together 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.if entries 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.
  • disabledWhen folds into the requiredness rule. A statically disabled field is never required; otherwise the emitted rule is and(requiredWhen, not(disabledWhen)). It is a conjunction rather than a second sibling condition so the compiled schema and isRequiredFor cannot 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.virtualfield.isVirtual or props.virtual
not read-only for this mode meta.readonlyOn.includes(mode) — from readonlyOn('edit')
visible for the state (R3) isVisibleForvisibleWhen 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 iterates fillKeys, 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’s dehydrate (§7.2), so the model receives storage shapes: a Luxon DateTime instead of an ISO string, a number instead of a numeric string, a sqlite 0/1 instead 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 own dehydrate() 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close