Skip to content

Reactivity, server side (§9.3 / §11.2)

What an author writes, what rides the wire, and what the server enforces at submit. The client half is docs/form-engine.md; the rule vocabulary is the reactivity contract and docs/reactivity/catalogue.md.

The rule of the whole subsystem: client evaluation is cosmetic. Every declarative rule that affects visibility or requiredness is re-evaluated server-side against the submitted state, and every sets derivation is recomputed there, so a bypassed UI cannot change what is validated or persisted.

The when() DSL

when(path) opens a condition on a flat state key (dotted for JSON columns, ./-prefixed inside a repeater row). Every terminal method returns plain JsonLogic — the exact JSON the fixtures pin — so rules compose, nest, and can be stored in a constant.

import { F, when } from '@adonia/core'

F.datetime('publishedAt')
  .visibleWhen(when('status').is('published'))
  .requiredWhen(when('status').is('published'))

F.text('coupon').visibleWhen(
  when.all([when('type').is('discount'), when('amount').gt(100)])
)

F.text('vatId').requiredWhen(when.not(when('country').is('US')))
Builder Emits
.equals(v) / .is(v) {"===": [{"var": path}, v]}
.notEquals(v) / .isNot(v) {"!==": …}
.in([…]) {"in": [{"var": path}, […]]}
.notIn([…]) {"!": [{"in": …}]}
.gt/.gte/.lt/.lte(v) {">": …}, … — null-safe, never coerced to 0
.contains(v) {"in": [v, {"var": path}]} (state is the haystack)
.truthy() / .isTruthy() bare {"var": path} — consumers Boolean() it
.falsy() / .isFalsy() {"!": [{"var": path}]}
when.all(…) / when.any(…) {"and": […]} / {"or": […]} (array or variadic)
when.not(rule) {"!": [rule]}
when.ref(path) {"var": path} — the operand form, for cross-field comparisons

The field shorthand is unchanged and compiles identically: visibleWhen('status', 'published')visibleWhen(when('status').is('published')), and an array matcher ≡ .in([...]). A raw rule object still passes through.

Comparisons take a second rule as their operand, which is how a cross-field condition is expressed without leaving the subset:

F.text('overlapReason').requiredWhen(when('endDate').lt(when.ref('startDate')))

packages/core/tests/when_dsl.spec.ts re-emits every reactive block in docs/reactivity/fixtures/ from DSL calls and compares it byte-identically with the committed JSON, and fails if a fixture block has no DSL spelling.

The closure fallback

A closure passed where a rule was expected cannot be serialized. The field is flagged reactive.refetchSchema: true — it stays validated and fillable, and the client refetches the descriptor after the state settles. Outside production the descriptor compiler warns through the app logger (module: 'adonia', §20) naming the resource, the field and the offending call:

resource "posts", field "legacy": visibleWhen(<closure>) passed a server
closure, so the node is flagged refetchSchema:true and the form refetches
its descriptor after every state change. Express the condition with the
when() DSL to stay on the fast path.

sets derivations, server side

A client that never ran a derivation 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 payload for targets that are in the fill set (R6). A tampered slug loses.

F.text('title').live().sets('slug', 'slugify')

F.toggle('sameAsShipping').live(0).sets('billingStreet', 'copy', {
  source: 'shippingStreet',
  if: when('sameAsShipping').isTruthy(),
})

source defaults to the declaring field; if guards the entry. Built-ins are slugify, lowercase, uppercase and copy, and they MUST agree character-for-character with the client’s copies — the fixture corpus is the shared oracle.

fn:<name> resolves from the server registry:

import { registerTransform } from '@adonia/core'

registerTransform('sumPrices', (items) =>
  Array.isArray(items)
    ? items.reduce<number>((total, row) => total + Number((row as { price?: number }).price ?? 0), 0)
    : 0
)

Register at boot (a provider boot() hook or a plugin). Transforms MUST be pure — they run on every submit, and the client runs its own copy on every keystroke.

An unresolvable fn:* fails compilation, naming the resource, field, target and transform. The client can only warn and skip; the server can fail loudly, and it should: silently persisting the un-derived value is the one outcome nobody can debug from the outside.

What the validator does with a rule

compileValidator folds R1–R6 into one Vine schema (see packages/core/src/validation/validator_compiler.ts):

  • hidden ⇒ absent. visibleWhen false deletes the key before Vine runs and drops it from the validator key set and the fill set (R3).
  • required ⇒ mirrored. A simple equality becomes Vine’s native requiredWhen(field, '=', value). Any richer shape — composite and/or/ !, a numeric threshold, a cross-field comparison — becomes a complementary vine.group pair, so the key is shape-validated whether or not it is required.
  • disabled ⇒ never required, never filled. disabledWhen folds INTO the requiredness rule rather than sitting beside it, so a disabled input cannot be demanded (R5).
  • derived ⇒ recomputed. The sets overlay is applied last, after validation (R6).

Conformance

packages/core/tests/mirroring.spec.ts runs the WHOLE corpus in docs/reactivity/fixtures/ — every case, in both modes unless the case pins one — asserting exact validator/required/fill key sets, that a hidden key survives neither the validator nor the fill, that a mirrored requirement holds when the UI is bypassed, and that derivations match. The @adonia/ui parity suite reads the same files. A new catalogue pattern ships its fixture in the same PR (plan §5) and is enrolled automatically.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close