# @wrnexus/validation

> One fluent schema, validated on the server (API bodies, env vars) and mirrored to an eval-free browser validator for forms.

Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.

## Boundary contracts

Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the
same schema descriptors for APIs, actions, webhooks, realtime, queues, cron,
pub/sub, plugins, configuration, and environment variables.

```ts
import { ContractRegistry, defineEvent, v } from "@wrnexus/validation";

export const contracts = new ContractRegistry().register(
  defineEvent({
    name: "user.created",
    version: 1,
    consumers: ["notification-worker", "audit-service"],
    payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }),
  }),
);
```

Export the registry from `app/contracts.ts`, then accept a baseline with
`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed
contracts/fields, required-field additions, type changes, narrowed enums, and
tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known
consumers. A generated `wrnexus.contracts.json` can be used instead of a module.

## Overview

Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`.

## Installation

```bash
bun add @wrnexus/validation
```

> Private package — the machine must be authenticated to the `wrnexus` npm org
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).

## API

### The `v` builder

```ts
import { v } from "@wrnexus/validation";
```

| Factory            | Returns         | Field methods                                                                                                       |
| ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------- |
| `v.string()`       | `StringSchema`  | `email()`, `url()`, `uuid()`, `date()`, `length(n)`, `oneOf(string[])`, `pattern(re)`, `trim()`, `min(n)`, `max(n)` |
| `v.number()`       | `NumberSchema`  | `integer()`, `positive()`, `oneOf(number[])`, `min(n)`, `max(n)`                                                    |
| `v.boolean()`      | `BooleanSchema` | (base methods only)                                                                                                 |
| `v.object(fields)` | `ObjectSchema`  | `parse(input)`, `describe()`                                                                                        |

Every field schema is chainable and shares these base methods:

- `min(n, message?)` / `max(n, message?)` — for strings, bounds the length; for numbers, bounds the value.
- `required(message?)` — require a non-empty value and optionally replace the default `"Required"` message on both server and browser validation.
- `optional()` — an empty/missing value passes instead of erroring `"Required"`.
- `label(text)` — human label carried into the descriptor.
- `default(value)` — value substituted when the field is absent (implies `optional`).
- `refine(fn, message?)` — **server-only** predicate. `fn` returns `true` (ok), `false` (use `message`), or a `string` (that error). Not serialized to the client.

Each string rule accepts an optional trailing `message` to override the default error text.

### `ObjectSchema`

```ts
schema.parse(input: unknown): ParseResult
schema.describe(): SchemaDescriptor
```

`parse` coerces each field (strings stay strings, `v.number()` runs `Number()`, `v.boolean()` treats `true` / `"true"` / `"on"` as true), applies its rules and refinements, fills in `default()` values, and returns:

```ts
interface ParseResult<T = Record<string, unknown>> {
  ok: boolean; // true when errors is empty
  value: T; // coerced values (present pass or fail)
  errors: Record<string, string>; // field name → first failing message
}
```

`describe()` returns the JSON bridge for the client:

```ts
interface SchemaDescriptor {
  type: "object";
  fields: Record<string, FieldDescriptor>;
}
interface FieldDescriptor {
  type: "string" | "number" | "boolean";
  optional?: boolean;
  label?: string;
  trim?: boolean; // strings only
  rules: RuleDescriptor[];
}
```

### Rules and coercion

`RuleDescriptor` is a discriminated union of the serializable rules — `min`, `max`, `length`, `email`, `url`, `uuid`, `date`, `oneOf`, `pattern`, `integer`. Two exported functions apply them and are shared by the server (the client runtime reimplements the same logic):

- `applyRule(type, rule, value): string | null` — validate one already-coerced value against one rule.
- `checkField(desc, raw): { value, error }` — coerce and validate one field. Empty input (`undefined`/`null`/`""`) is `"Required"` unless `optional`. Strings with `trim` are trimmed first. Numbers that fail `Number()` yield `"Must be a number"`.

Notes on specific rules: `email`/`url`/`uuid` test built-in regexes; `date` uses `Date.parse`; `pattern` reconstructs a `RegExp` from its `source`/`flags` and passes silently if the pattern is invalid; `integer` requires `Number.isInteger`; `positive()` is implemented as `min(Number.MIN_VALUE)`.

### API helpers

```ts
invalid(errors: Record<string, string>): Response   // ready 400 { ok:false, errors }

parseBody<T>(schema, req):
  Promise<{ ok: true; value: T } | { ok: false; response: Response }>
```

`parseBody` reads the request body from JSON, `application/x-www-form-urlencoded`, or `multipart/form-data`, validates it, and on failure hands back a ready 400 `Response`.

### Environment config

```ts
parseEnv<T>(schema: ObjectSchema, source?): T
```

Validates env vars (from `Bun.env`, falling back to `process.env`) against a schema and coerces them (`PORT` → number, `DEBUG` → boolean). On any problem it throws **one** error listing every offending variable, so misconfiguration fails fast at startup.

### Client runtime (from `runtime.ts`)

```ts
renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string
VALIDATE_RUNTIME: string
```

- `renderSchemasScript` produces `window.__wireSchemas = { name: descriptor, … };` to inline in the page.
- `VALIDATE_RUNTIME` is a self-contained, eval-free IIFE string. Injected as a `<script>`, it binds every `form[data-schema]` and validates on submit and blur, writing messages into `[data-error="<field>"]` elements and toggling `aria-invalid` / `.wire-invalid`. On a valid submit it `fetch`es the form `action` as JSON (attaching the `wire-csrf` cookie as an `x-csrf-token` header), then follows `data-redirect` / a `redirect` in the response, surfaces server-side field errors, and fires `wire:success` / `wire:error` events. It exposes `window.__wireValidate.init(root)` and self-initializes on `DOMContentLoaded`.

## Usage

Define a schema and validate an API body:

```ts
import { v, parseBody } from "@wrnexus/validation";

export const signupSchema = v.object({
  email: v.string().required("Enter your email address").trim().email(),
  password: v.string().required("Enter your password").min(8).max(200),
  age: v.number().integer().min(13).max(120).optional(),
  role: v.string().oneOf(["user", "admin"]).default("user"),
  agree: v.boolean(),
});

// inside a route handler
const result = await parseBody(signupSchema, req);
if (!result.ok) return result.response; // ready 400 with field errors
const { email, password, role } = result.value;
```

Server-only refinement:

```ts
const schema = v.object({
  username: v
    .string()
    .min(3)
    .refine((name) => !RESERVED.has(String(name)), "That name is taken"),
});
```

Validate environment at startup:

```ts
import { v, parseEnv } from "@wrnexus/validation";

export const env = parseEnv(
  v.object({
    DATABASE_URL: v.string().min(1),
    PORT: v.number().integer().default(3000),
    DEBUG: v.boolean().optional(),
  }),
);
// throws one readable error listing every bad variable if misconfigured
```

Wire the same schema into the browser:

```ts
import { renderSchemasScript, VALIDATE_RUNTIME } from "@wrnexus/validation";
import { signupSchema } from "./app/schemas/signup.ts";

const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })}</script>
<script>${VALIDATE_RUNTIME}</script>`;
// render a <form data-schema="signup"> with [data-error="email"] etc.
```

## Requirements / Notes

- **Bun-only.** `parseEnv` reads `Bun.env` (falling back to `process.env`); `parseBody` and `invalid` use the Web `Request`/`Response` APIs that back `Bun.serve`.
- Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list.
- No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun.
- Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`.

## Helper and component kit

The public helper API includes `parseOrThrow`, `ValidationError`, `validationResponse`, `firstValidationError`, `validationSummary`, and `schemaFieldNames`.

Schema output is inferred automatically by `ObjectSchema`, `parseOrThrow`, `parseBody`, `parseEnv`, and `asyncSchema`. Use `InferSchema<typeof schema>` when a named output type is useful:

```ts
const accountSchema = v.object({
  email: v.string().email(),
  attempts: v.number().integer(),
});

type AccountInput = InferSchema<typeof accountSchema>;
const account = parseOrThrow(accountSchema, input);
// account.email: string
// account.attempts: number
```

Enable `validationPlugin()` for:

- `<ValidationSummary />`
- `<FieldError />`

The summary block composes `Alert` from `@wrnexus/ui`, while `FieldError` remains a lightweight accessible field-level primitive.
Schemas can drive external contracts without maintaining a second definition:

```ts
import {
  localizeDescriptor,
  openApiRequestBody,
  parseDescriptor,
  toJsonSchema,
} from "@wrnexus/validation";

const jsonSchema = toJsonSchema(contactSchema, {
  id: "urn:example:contact",
  title: "Contact request",
});
const requestBody = openApiRequestBody(contactSchema);

const mr = localizeDescriptor(contactSchema, (key, params) =>
  translations.t(`validation.${key}`, params),
);
const result = parseDescriptor(mr, input);
```

JSON Schema output targets draft 2020-12, closes unknown object properties, and
maps lengths/ranges/formats/enums/patterns/integer rules. OpenAPI request bodies
reuse the same properties. Localized descriptors preserve explicit custom
messages and fill default required, type-coercion, and rule messages; the same
descriptor is consumable by server parsing and the eval-free browser runtime.
