first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
# @suaveplan/form-builder - Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.
Universal dynamic form schema engine. Renderer-agnostic — no DOM, no
React, no Node-specific globals. Drives every dynamic form surface in the
Suaveplan.
<!-- begin:auto-docs -->
## Documentation tree
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
<!-- end:auto-docs -->
## Features
- 17 built-in field types (`text`, `number`, `email`, `url`, `tel`,
`password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`,
`date`, `file`, `range`, `hidden`, `group`, `array`) via an extensible
`FieldRegistry`.
- 11 built-in validation rules (`required`, `minLength`, `maxLength`, `min`,
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`).
Async-friendly via `Promise<boolean>` predicates.
- Conditional logic with 9 leaf operators (`eq`, `neq`, `in`, `notIn`,
`gt`, `lt`, `gte`, `lte`, `contains`) and boolean composition
(`and` / `or` / `not`).
- Field-level dependency graph with topological evaluation and cycle
detection (`FORM_BUILDER.CYCLIC_DEPENDENCY`).
- Bounded undo / redo on schema mutations.
- Typed event bus via `EventBus<FormEventMap>` from `@suaveplan/events`.
- Versioned JSON serialise / deserialise round-trip.
- `toZodSchema(form)` generator for `react-hook-form` consumers.
- Submission pipeline: `prepare → validate → transform → submit`.
- Six stable `FORM_BUILDER.*` error codes via `defineCodes`.
- Three OTel counters and two histograms emitted on every code path.
- Injectable `Clock` + `IdGenerator` for deterministic tests.
## Install
```bash
bun add @suaveplan/form-builder
```
## Usage
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const builder = new FormBuilder().enableHistory();
builder.addField({
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
});
builder.addField({
id: "subscribe",
name: "subscribe",
label: "Subscribe to newsletter",
type: "checkbox",
});
builder.addField({
id: "frequency",
name: "frequency",
label: "Frequency",
type: "select",
options: [
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
],
conditional: { field: "subscribe", operator: "eq", value: true },
});
builder.bus.on("values.changed", (values) =>
console.log("changed", values)
);
const schema = builder.createSchema("signup", "Sign-up form");
```
## Submission
```ts
import { submit } from "@suaveplan/form-builder";
await submit({
fields: schema.fields,
values: { name: "alice", subscribe: true, frequency: "weekly" },
submitter: async (values, signal) =>
fetch("/api/forms", {
method: "POST",
signal,
body: JSON.stringify(values),
}),
});
```
## License
MIT.

View File

@@ -0,0 +1,80 @@
# form-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-builder.ts` exposes the top-level `FormBuilder` class — the public
entry point for `@suaveplan/form-builder`. The class aggregates every
subsystem (schema, state, validation, conditional logic, dependency
graph, undo/redo, event bus, OTel) behind a renderer-agnostic, universal-
tier façade. It is the only class downstream packages need to instantiate
to model a dynamic form end-to-end.
The builder emits typed events through an `EventBus<FormEventMap>` from
`@suaveplan/events` so consumers (React adapters, debug overlays, audit
logs) can subscribe to lifecycle changes without polling. Optional
injectable `Clock` and `IdGenerator` make the package deterministic for
snapshot tests and golden-master fixtures.
## Features
- Full upstream public surface preserved (`createSchema`, `addField`,
`removeField`, `updateField`, `addSection`, `getField`, `getSchema`,
`validateField`, `validateForm`, `getVisibleFields`, `setValues`,
`resetForm`, `getState`, `toJSON`, `getSectionById`).
- New: `removeSection`, `enableHistory`, `undo`, `redo`, `newId`.
- `EventBus<FormEventMap>` emission for every mutation + lifecycle event.
- Dependency-graph driven conditional evaluation.
- Optional bounded undo/redo with linear history semantics.
- Injectable `Clock` + `IdGenerator` for determinism.
## Usage: Basic
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const builder = new FormBuilder();
builder.addField({ id: "email", name: "email", label: "Email", type: "email", validations: [{ type: "required" }] });
const schema = builder.createSchema("contact", "Contact form");
```
## Usage: Advanced
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const clock = () => 1700_000_000_000;
const builder = new FormBuilder({ clock }).enableHistory(50);
builder.addField({ id: "a", name: "a", label: "A", type: "text" });
builder.bus.on("field.added", (field) => console.log("added", field.id));
builder.undo(); // removes the field, fires field.removed
builder.redo(); // re-adds it
```
## API Reference
- `new FormBuilder(options?)` — `{ clock?, idGenerator?, bus? }`.
- `bus: EventBus<FormEventMap>` — public event bus.
- `createSchema(id, name, options?): FormSchema`.
- `addField(field)`, `removeField(id)`, `updateField(id, patch)`.
- `addSection(section)`, `removeSection(id)`, `getSectionById(id)`.
- `getField(id)`, `getSchema()`.
- `validateField(id, value, signal?)`, `validateForm(values, signal?)`.
- `getVisibleFields(values)`.
- `setValues(values, signal?)`, `resetForm()`, `getState()`.
- `toJSON()`, `newId()`.
- `enableHistory(capacity?)`, `undo()`, `redo()`.
## Implementation Notes
`getVisibleFields` builds a fresh `DependencyGraph` on each call rather
than caching, because field additions/removals invalidate the cache and
the graph build is O(V + E) — measurably under 1 ms for forms of any
realistic size. Undo ops are paired symmetrically: `addField`'s reverse-op
removes the field, whose reverse-op re-adds it. Pushing onto history while
history is disabled is a no-op so the class is safe to use with or without
history enabled.

View File

@@ -0,0 +1,70 @@
# conditional
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`.
It supports nine leaf operators (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`,
`gte`, `lte`, `contains`) and three boolean combinators (`and`, `or`,
`not`). It also normalises the legacy upstream shape
(`{ field, operator, value }`) into the tagged-leaf representation so
schemas authored against the original `form-builder` continue to work
unchanged.
The same file exposes `fieldsReferencedBy`, a recursive walker that returns
every field id referenced by a conditional rule. The dependency-graph
builder consumes that walker to derive evaluation order for chained
conditionals.
## Features
- Nine leaf operators.
- Boolean combinators with short-circuit semantics.
- Legacy upstream shape accepted (no migration step).
- Pure: no side effects, no IO, deterministic per `(rule, values)` pair.
## Usage: Basic
```ts
import { evaluateConditional } from "@suaveplan/form-builder";
evaluateConditional(
{ field: "country", operator: "eq", value: "US" },
{ country: "US" }
); // true
```
## Usage: Advanced
```ts
import { evaluateConditional } from "@suaveplan/form-builder";
const rule = {
kind: "and" as const,
rules: [
{ kind: "leaf" as const, field: "age", operator: "gte" as const, value: 18 },
{ kind: "or" as const, rules: [
{ kind: "leaf" as const, field: "country", operator: "in" as const, value: ["US", "CA"] },
{ kind: "not" as const, rule: { kind: "leaf" as const, field: "blocked", operator: "eq" as const, value: true } },
]},
],
};
evaluateConditional(rule, { age: 21, country: "GB", blocked: false }); // true
```
## API Reference
- `evaluateConditional(rule, values): boolean`.
- `fieldsReferencedBy(rule): readonly string[]` — unique field ids.
## Implementation Notes
`and` returns `false` on the first false child; `or` returns `true` on the
first true child. Numeric operators (`gt`, `lt`, `gte`, `lte`) return
`false` when either side is not a `number`. `contains` works for both
strings (substring) and arrays (membership). The function is recursive but
form schemas in practice have shallow conditional trees, so the recursion
depth is bounded.

View File

@@ -0,0 +1,82 @@
# dependency-graph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`dependency-graph.ts` builds a directed acyclic graph of field-id
dependencies derived from `FieldDefinition.conditional` and `equalsField`
validators. The graph is the substrate for `FormBuilder.getVisibleFields`,
which evaluates conditional fields in topological order so chained
dependencies (`a → b → c`) settle in a single pass instead of converging
over multiple iterations.
`topoSort()` runs Kahn's algorithm and emits a stable ordering; if it
detects a cycle, it throws a `FormError` carrying
`FORM_BUILDER.CYCLIC_DEPENDENCY` and the unresolved set of field ids.
Forms with cyclic visibility rules are by definition undefined, so the
package treats them as a configuration error rather than silently
diverging.
## Features
- Builds edges from both conditional rules and `equalsField` validators.
- Kahn's topological sort with deterministic insertion order.
- `cyclicDependency` factory raises `FORM_BUILDER.CYCLIC_DEPENDENCY` with
the unresolved id list in `context.cycle`.
- O(V + E) time, O(V) space.
## Usage: Basic
```ts
import { DependencyGraph } from "@suaveplan/form-builder";
const graph = new DependencyGraph([
{ id: "a", name: "a", label: "A", type: "text" },
{
id: "b",
name: "b",
label: "B",
type: "text",
conditional: { field: "a", operator: "eq", value: "yes" },
},
]);
graph.topoSort(); // ["a", "b"]
```
## Usage: Advanced
```ts
import { DependencyGraph } from "@suaveplan/form-builder";
const graph = new DependencyGraph();
graph.addField({ id: "first", name: "first", label: "First", type: "text" });
graph.addField({
id: "confirm",
name: "confirm",
label: "Confirm",
type: "text",
validations: [{ type: "equalsField", value: "first" }],
});
graph.dependents("first"); // ["confirm"]
```
## API Reference
- `new DependencyGraph(fields?)` — optionally seeded.
- `addField(field)` — register a node and its edges.
- `nodes(): readonly string[]`.
- `dependents(from): readonly string[]`.
- `topoSort(): readonly string[]` — may throw `FormError`.
## Implementation Notes
Cycle detection compares the sorted order's length to the registered node
count; any divergence implies a cycle, and the remaining nodes form the
reported cycle context. The graph stores edges in
`Map<string, Set<string>>` to keep edge insertion O(1) and dedup automatic.
Nodes referenced only from `equalsField` (e.g. the password field
referenced by a confirm field) are added as bare nodes so the topo sort
can place them before their dependents.

View File

@@ -0,0 +1,84 @@
# form-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-error.ts` owns the stable error-code registry and the typed `FormError`
class for the entire `@suaveplan/form-builder` package. Every failure mode
exposed by the public API surfaces as a `FormError` carrying one of six
literal codes registered through `defineCodes("FORM_BUILDER", …)`, so
downstream consumers (loggers, problem+json responders, retry wrappers, i18n
adapters) can pattern-match on `error.code` without re-implementing the
mapping.
The module also exports six factory helpers — `fieldNotFound`,
`sectionNotFound`, `duplicateId`, `configInvalid`, `validationFailed`, and
`cyclicDependency` — that build pre-shaped errors with the appropriate
context payload (field id, section id, cycle path, aggregated validation
errors, etc.). These are the only constructors used inside the package.
## Features
- Codes defined via `@suaveplan/error`'s `defineCodes`, giving fully
qualified strings such as `"FORM_BUILDER.FIELD_NOT_FOUND"`.
- `FormError extends SuaveplanError<FormBuilderCode>` so `instanceof
FormError` narrows the union.
- Strongly-typed `FormErrorContext` with optional `fieldId`, `sectionId`,
`schemaId`, `cycle`, and aggregated `errors`.
- Factory helpers keep error construction consistent and ensure every
registered code is reachable from at least one production call site.
## Usage: Basic
```ts
import { fieldNotFound, FORM_BUILDER_CODES } from "@suaveplan/form-builder";
try {
throw fieldNotFound("email");
} catch (err) {
if (err instanceof Error && "code" in err) {
// err.code === FORM_BUILDER_CODES.FIELD_NOT_FOUND
}
}
```
## Usage: Advanced
```ts
import { cyclicDependency, FormError, validationFailed } from "@suaveplan/form-builder";
function safeTopo(cycle: readonly string[]): never {
throw cyclicDependency(cycle);
}
const aggregated = validationFailed([
{ fieldId: "name", rule: "required", message: "Name is required" },
]);
if (aggregated instanceof FormError) {
// aggregated.context?.errors contains the per-field details
}
```
## API Reference
- `FORM_BUILDER_CODES` — frozen code table; six string members.
- `FormBuilderCode` — union of every registered code value.
- `FormError(code, message, options?)` — base error class.
- `fieldNotFound(fieldId)` — `FORM_BUILDER.FIELD_NOT_FOUND`.
- `sectionNotFound(sectionId)` — `FORM_BUILDER.SECTION_NOT_FOUND`.
- `duplicateId(kind, id)` — `FORM_BUILDER.DUPLICATE_ID`.
- `configInvalid(message, cause?)` — `FORM_BUILDER.CONFIG_INVALID`.
- `validationFailed(errors)` — `FORM_BUILDER.VALIDATION_FAILED`.
- `cyclicDependency(cycle)` — `FORM_BUILDER.CYCLIC_DEPENDENCY`.
## Implementation Notes
`SuaveplanError` is an abstract class. `FormError` declares a public
constructor so callers may throw new instances directly without subclassing
further. The factory helpers are pure functions and do not allocate event
buses or otel handles, so they are safe to call from hot validation loops.
The code registry is intentionally closed; adding a code requires an
OpenSpec change that mirrors the bump in this file.

View File

@@ -0,0 +1,69 @@
# field-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`field-registry.ts` is the extensible registry of field-type descriptors
used by `FormBuilder`. It ships seventeen built-in descriptors (`text`,
`number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`,
`checkbox`, `radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`,
`array`) and exposes `register`, `unregister`, `has`, `get`, and `list` so
downstream packages can contribute additional field types without forking
the form-builder.
Descriptors carry a human-readable label, a default value, a `hasOptions`
flag (used by select-family fields), and an optional `coerce` function that
normalises raw text input (e.g. `"42"` → `42`) into the canonical runtime
representation.
## Features
- 17 built-in field types registered out of the box.
- Pluggable: third-party packages can `register("rating", { defaultValue:
0 })` to add custom types.
- Coercers normalise external string inputs into the runtime shape.
- A singleton `defaultFieldRegistry` is exported for callers who only need
the built-ins.
## Usage: Basic
```ts
import { defaultFieldRegistry } from "@suaveplan/form-builder";
const desc = defaultFieldRegistry.get("number");
desc?.coerce?.("42"); // → 42
```
## Usage: Advanced
```ts
import { FieldRegistry } from "@suaveplan/form-builder";
const registry = new FieldRegistry();
registry.register("rating", { label: "Rating", defaultValue: 0, hasOptions: false });
registry.has("rating"); // true
```
## API Reference
- `FieldRegistry` — registry class.
- `FieldRegistry#register(type, descriptor)` — chainable.
- `FieldRegistry#unregister(type)`.
- `FieldRegistry#has(type)`.
- `FieldRegistry#get(type)`.
- `FieldRegistry#list()` — snapshot of registered ids.
- `FieldTypeDescriptor` — `{ label, defaultValue, hasOptions, coerce? }`.
- `defaultFieldRegistry` — singleton with the 17 built-ins.
## Implementation Notes
The registry stores descriptors in a `Map<string, FieldTypeDescriptor>` and
the built-in identifiers come from the `FIELD_TYPES` constant so the union
type, the Zod schema, and the registry stay in sync. Custom types may
overwrite built-ins (useful for theming the default `text` field). The
registry deliberately has no opinions about rendering — `defaultValue`
exists so consumers can derive `resetForm` defaults when a field declares
no explicit `defaultValue`.

View File

@@ -0,0 +1,65 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` lazily resolves the OpenTelemetry meter and the five
instruments emitted across the package: three counters
(`form_builder.field.mutated_total`, `form_builder.validation.run_total`,
`form_builder.submission.total`) and two histograms
(`form_builder.validation.duration_ms`, `form_builder.submission.duration_ms`).
Lazy resolution lets test environments install a custom `MeterProvider`
via `metrics.setGlobalMeterProvider` *after* the module is imported and
still observe production emissions.
A test-only `_resetInstrumentsForTests` is exported so the lazy cache can
be cleared between tests, ensuring each test fixture starts from a clean
slate. The leading underscore signals "do not call from production code".
## Features
- Lazy meter + instrument resolution.
- Stable instrument names that double as OTel collector route keys.
- Test-only reset hook for fixture isolation.
- No imports outside `@opentelemetry/api`.
## Usage: Basic
```ts
import { fieldMutatedCounter } from "@suaveplan/form-builder";
fieldMutatedCounter().add(1, { op: "add" });
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { _resetInstrumentsForTests, validationRunCounter } from "@suaveplan/form-builder";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
_resetInstrumentsForTests();
validationRunCounter().add(1, { scope: "field", result: "ok" });
```
## API Reference
- `fieldMutatedCounter(): Counter`.
- `validationRunCounter(): Counter`.
- `submissionTotalCounter(): Counter`.
- `validationDurationHistogram(): Histogram`.
- `submissionDurationHistogram(): Histogram`.
- `_resetInstrumentsForTests()` — test-only.
## Implementation Notes
The module-level `_meter` cache is the only piece of mutable state in the
file. Multiple producers (the validation engine, the builder, the
submission pipeline) call into these helpers from independent code paths;
sharing the cache avoids creating duplicate instruments per call site and
preserves correct accumulation in the OTel SDK.

View File

@@ -0,0 +1,67 @@
# schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schema.ts` is the Zod boundary for the public `FormSchema`,
`FieldDefinition`, and `FormSection` types. It exposes parse functions that
validate untrusted input (JSON from disk, network requests, persisted
storage) and produces the canonical runtime shape used by the rest of the
package. The schema is the single source of truth for what a "valid form"
looks like; every other module assumes the values it receives have already
passed through this layer.
Parse failures uniformly raise `FormError` with code
`FORM_BUILDER.CONFIG_INVALID`, so consumers do not have to special-case Zod
issue trees. Field-id uniqueness is checked on top of the structural Zod
parse because Zod arrays do not natively support cross-element invariants
without `refine`, and emitting a typed `FormError` is friendlier to
problem+json responders than a generic `ZodError`.
## Features
- Strict Zod schemas (`.strict()` blocks unknown keys at every level).
- Recursive support for nested `FieldDefinition.fields` (group/array types).
- Lazy conditional-rule schema covering the legacy upstream shape, the
tagged `leaf`, and boolean composition (`and` / `or` / `not`).
- Re-exports `schemas` namespace for callers that want to compose.
## Usage: Basic
```ts
import { parseFormSchema } from "@suaveplan/form-builder";
const form = parseFormSchema(JSON.parse(rawJsonFromDisk));
```
## Usage: Advanced
```ts
import { schemas } from "@suaveplan/form-builder";
import z from "zod";
const FormWithMetadata = schemas.formSchema.extend({
tenant: z.string().uuid(),
});
type FormWithMetadata = z.infer<typeof FormWithMetadata>;
```
## API Reference
- `parseFormSchema(raw): FormSchema` — full validation incl. unique ids.
- `parseFieldDefinition(raw): FieldDefinition`.
- `parseFormSection(raw): FormSection`.
- `schemas` — namespace with all underlying `z.ZodType`s.
## Implementation Notes
The conditional-rule schema is `z.lazy` because the type is self-recursive
(`and` / `or` / `not` carry sub-rules). `isoDateString` is a lightweight
refinement around `Date.parse`; we deliberately do not import a heavyweight
date library because the package is universal with zero browser/Node
APIs assumed in callers. Boundary errors are thrown — never returned —
because every caller in this package raises rather than returns, and the
schema layer should match.

View File

@@ -0,0 +1,68 @@
# serialise
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is
the bridge between the in-memory schema engine and persisted forms in a
database, blob store, or message bus. The wire format is a small envelope
carrying a `$version` discriminator and the schema body; bumping the
discriminator in a future change lets consumers migrate forward without
ambiguity.
`deserialiseForm` performs three checks in order: JSON parse,
envelope-shape match (including `$version` equality), and full
`parseFormSchema` validation (which itself enforces Zod conformance plus
field-id uniqueness). Every failure mode raises `FormError` carrying
`FORM_BUILDER.CONFIG_INVALID`, so callers can rely on a single typed
exception path.
## Features
- Versioned wire format (`$version: 1`).
- Symmetric: `deserialiseForm(serialiseForm(s))` returns a value
structurally equal to `s`.
- Single error code (`FORM_BUILDER.CONFIG_INVALID`) for every failure.
- Pure: deterministic; no IO.
## Usage: Basic
```ts
import { serialiseForm, deserialiseForm } from "@suaveplan/form-builder";
const wire = serialiseForm(schema);
const restored = deserialiseForm(wire);
```
## Usage: Advanced
```ts
import { deserialiseForm, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
try {
deserialiseForm("{not json");
} catch (err) {
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.CONFIG_INVALID) {
// record the cause + telemetry
}
}
```
## API Reference
- `serialiseForm(schema): string`.
- `deserialiseForm(json): FormSchema`.
- `SERIALISE_VERSION = 1` — wire version constant.
## Implementation Notes
The envelope wraps the schema rather than annotating it inline because that
keeps the schema body untouched and lets the version discriminator be a
plain object key rather than a magic field embedded in the schema. The
implementation deliberately performs the version check *before* delegating
to `parseFormSchema`, so callers see a clear "wrong version" error rather
than a flood of Zod-issue noise when the inner shape is for a future
release.

View File

@@ -0,0 +1,67 @@
# form-state
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-state.ts` is the mutable holder for the runtime state of a form
instance: the current value map, the most recent validation errors, the
set of touched field ids, the dirty flag, and the valid flag. It is the
structure returned from `FormBuilder.getState` and emitted on the
`values.changed` event. The state machine is intentionally minimal — it
owns no validation logic of its own; the builder asks `runFormValidation`
for errors and forwards them via `setErrors`.
The state is treated as a write-through cache: every mutation produces a
new value map (via spread) so consumers that snapshot via `getState` always
get an independent copy and cannot mutate internal state by reference.
## Features
- `setValue(id, value)` — single-field update, marks dirty + touched.
- `setValues(values)` — bulk replace, marks every key touched.
- `touch(id)` — record visit without changing values.
- `reset(defaults?)` — restore defaults, clear dirty + touched.
- `setErrors(errors)` — update errors and recompute `valid`.
- `getState()` — immutable snapshot.
## Usage: Basic
```ts
import { FormStateMachine } from "@suaveplan/form-builder";
const state = new FormStateMachine({ name: "" });
state.setValue("name", "alice");
state.getState().dirty; // true
```
## Usage: Advanced
```ts
import { FormStateMachine } from "@suaveplan/form-builder";
const state = new FormStateMachine();
state.setErrors([
{ fieldId: "email", rule: "email", message: "Not a valid email" },
]);
state.getState().valid; // false
state.reset({ email: "" });
state.getState().valid; // true
state.getState().errors.length; // 0
```
## API Reference
- `FormStateMachine(initial?)`.
- `setValue(fieldId, value)`, `setValues(values)`, `touch(fieldId)`.
- `reset(defaults?)`, `setErrors(errors)`.
- `getState(): FormState`.
## Implementation Notes
`getState` returns a fresh object on every call, including a new `Set`
copy of touched ids, so consumers cannot influence subsequent mutations
by holding a reference. The class is intentionally not an `EventEmitter`
— event emission lives on `FormBuilder` where the public bus is wired up.

View File

@@ -0,0 +1,70 @@
# undo-redo
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is
the substrate for `FormBuilder.enableHistory` — when history is enabled,
every schema-mutation method (`addField`, `removeField`, `updateField`,
`addSection`, `removeSection`) pushes a paired `UndoOp` whose `apply`
reverses the operation and whose `inverse` re-applies it. Calling `undo`
pops the most recent reverse-op, applies it, and pushes its inverse onto
the redo stack; calling `redo` reverses the process. Any new mutation
clears the redo stack to preserve linear history.
The capacity bound is enforced by dropping the oldest undo entry when a
push exceeds the limit, mirroring the canonical "infinite-undo with
ceiling" behaviour users expect from desktop applications. The default
capacity is 100, configured via `DEFAULT_UNDO_CAPACITY`.
## Features
- Bounded LIFO with FIFO-eviction on overflow.
- Redo stack cleared on new mutation (linear-history semantics).
- `canUndo` / `canRedo` introspection.
- `clear()` resets both stacks.
## Usage: Basic
```ts
import { UndoRedoStack } from "@suaveplan/form-builder";
const stack = new UndoRedoStack(2);
let value = 0;
const inc: import("@suaveplan/form-builder").UndoOp = {
apply: () => { value -= 1; },
inverse: undefined as any,
};
// (set up the pairing yourself for non-builder code)
```
## Usage: Advanced
```ts
import { UndoRedoStack } from "@suaveplan/form-builder";
const stack = new UndoRedoStack();
stack.push(myUndoOp);
stack.undo();
stack.redo();
stack.canUndo(); // true again
```
## API Reference
- `new UndoRedoStack(capacity = 100)`.
- `push(op)`, `undo()`, `redo()`.
- `canUndo()`, `canRedo()`, `clear()`.
- `UndoOp = { apply: () => void; inverse: UndoOp }`.
## Implementation Notes
The capacity is validated at construction time and a `RangeError` is raised
if it is not a positive integer. The implementation uses native arrays and
`shift` / `pop` because the expected capacity ceiling (≤ 100 by default)
makes the amortised cost negligible. Switching to a ring buffer would only
matter if capacities reached the tens of thousands, which is not a
supported use case.

View File

@@ -0,0 +1,79 @@
# submission
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`submission.ts` is the orchestration layer that runs the canonical
`prepare → validate → transform → submit` pipeline for a fully-prepared
form. It validates the values against every rule, refuses to call the
submitter if validation produces errors (raising `FORM_BUILDER.VALIDATION_FAILED`),
optionally rewrites the payload via a pure-function `Transformer`, and
forwards the resulting map and the caller-supplied `AbortSignal` to the
async `Submitter` function.
Every code path emits two OpenTelemetry instruments:
`form_builder.submission.total` (counter) and
`form_builder.submission.duration_ms` (histogram) tagged with
`result: "ok" | "invalid" | "failed"`. That lets dashboards split error
budgets by terminal state.
## Features
- Strict gating: invalid values short-circuit before the submitter runs.
- Optional pure-function `Transformer` between validate and submit.
- `AbortSignal` threaded into the submitter.
- OTel counter + histogram on every terminal state.
## Usage: Basic
```ts
import { submit } from "@suaveplan/form-builder";
await submit({
fields,
values: { email: "a@example.com" },
submitter: async (values) => fetch("/api/forms", {
method: "POST",
body: JSON.stringify(values),
}),
});
```
## Usage: Advanced
```ts
import { submit, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
try {
await submit({
fields,
values,
transform: (v) => ({ ...v, normalisedEmail: String(v.email).trim().toLowerCase() }),
submitter: postSubmission,
signal,
});
} catch (err) {
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.VALIDATION_FAILED) {
// err.context?.errors is the aggregated per-field error list
}
}
```
## API Reference
- `submit<TResult = void>(options): Promise<TResult>`.
- `Submitter<TResult> = (values, signal?) => Promise<TResult>`.
- `Transformer = (values) => FormValues`.
- `SubmitOptions<TResult>` — `{ fields, values, submitter, transform?, signal?, clock? }`.
## Implementation Notes
`submit` clamps elapsed durations at zero to defend against fake clocks
producing negative deltas. Errors thrown by the submitter are re-thrown
verbatim after recording the `result: "failed"` counter — the submission
layer makes no attempt to translate them, because submitters are an
extensibility seam and downstream code chooses how to surface its own
errors.

View File

@@ -0,0 +1,69 @@
# validation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validation.ts` is the iteration layer that runs every rule attached to a
field or form and aggregates the resulting `ValidationError` array. It
honours `AbortSignal` for cancellation (so callers can race a slow async
custom rule against a debounce timer) and emits two OpenTelemetry
instruments — `form_builder.validation.run_total` counter and
`form_builder.validation.duration_ms` histogram — on every invocation so
operations dashboards can watch validation latency and error rates.
The engine deliberately knows nothing about the rule semantics; it
delegates to `runRule` from `../validators/validators.ts`. That keeps the
two layers independently testable: the validators cover rule semantics, the
engine covers iteration, cancellation, and observability.
## Features
- `runFieldValidation(field, value, values, signal?)` — single field.
- `runFormValidation(fields, values, signal?)` — every field.
- `AbortSignal` short-circuits both functions and returns `[]`.
- Async-safe: awaits any promise returned by `runRule`.
- OTel counter with `{ scope, result }` attributes and histogram with
`{ scope }`.
## Usage: Basic
```ts
import { runFieldValidation } from "@suaveplan/form-builder";
const errors = await runFieldValidation(
{ id: "name", name: "name", label: "Name", type: "text", validations: [{ type: "required" }] },
"",
{ name: "" }
);
// errors[0]?.rule === "required"
```
## Usage: Advanced
```ts
import { runFormValidation } from "@suaveplan/form-builder";
const controller = new AbortController();
const promise = runFormValidation(fields, values, controller.signal);
controller.abort();
const errors = await promise; // []
```
## API Reference
- `runFieldValidation(field, value, values, signal?, clock?): Promise<readonly ValidationError[]>`.
- `runFormValidation(fields, values, signal?, clock?): Promise<readonly ValidationError[]>`.
- `Clock = () => number` — injectable for deterministic histograms.
## Implementation Notes
Both functions check the signal before every rule (not just at the start)
so a long-running form aborts promptly. Histogram durations clamp at zero
to defend against monotonic-clock skew under fake clocks. Counters use the
`{ scope: "field"|"form", result: "ok"|"errors" }` attribute pair so
dashboards can split error rate by scope. We intentionally do not throw
on individual rule failures — validation failures are data, not
exceptions.

View File

@@ -0,0 +1,75 @@
# validators
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validators.ts` provides the eleven built-in rule runners used by the
validation engine. Each runner takes the candidate value, the surrounding
`FormValues` (so `equalsField` can cross-reference), and the originating
field id; it returns either `null` (passed) or a `ValidationError` shaped
`{ fieldId, rule, message }`. `custom` rules may return a
`Promise<boolean>`, in which case `runRule` also returns a promise — the
upstream engine awaits the result transparently.
This file is the only place where rule semantics live. Adding a new
built-in rule means adding a case here, extending `BUILT_IN_RULES`, and
updating the Zod schema; everything else (the engine, the Zod generator,
the builder) consumes the rule kind opaquely.
## Features
- Eleven built-in rules: `required`, `minLength`, `maxLength`, `min`,
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`.
- `required` treats `null`, `undefined`, empty string, and empty array as
absent.
- `pattern` accepts a string source; constructed into `RegExp` per call.
- `url` validates via the standard `URL` constructor.
- `custom` supports synchronous boolean predicates and async predicates.
## Usage: Basic
```ts
import { runRule } from "@suaveplan/form-builder";
const error = runRule(
{ type: "required" },
"",
{},
"name"
);
// error.rule === "required"
```
## Usage: Advanced
```ts
import { runRule } from "@suaveplan/form-builder";
const asyncCustom = runRule(
{
type: "custom",
value: async (v: unknown) => v !== "blocked",
},
"blocked",
{},
"username"
);
// asyncCustom is a Promise<ValidationError | null>
```
## API Reference
- `runRule(rule, value, values, fieldId): ValidationError | null |
Promise<ValidationError | null>`.
- `CustomRulePredicate = (value, values) => boolean | Promise<boolean>`.
## Implementation Notes
Unknown rule kinds return `null` — the engine treats unknown rules as
no-ops so a registry mismatch never crashes a hot loop. `equalsField`
compares with `!==` (strict equality), which is the right behaviour for
the canonical "password confirmation" use case. `url` constructs a `URL`
inside a try/catch; this is cheap and avoids regex traps for IPv6 hosts.

View File

@@ -0,0 +1,60 @@
# zod-schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a
`FormSchema` into a `z.ZodObject` whose `safeParse` behaviour matches the
package's own `runFormValidation` for the eleven built-in rule kinds.
Downstream consumers using `react-hook-form` with
`@hookform/resolvers/zod` get identical validation semantics in the
browser without re-implementing the rule engine in two places.
Conditional required rules are honoured: the generated schema uses
`superRefine` to skip the "required" check when a field's conditional rule
evaluates to false. Cross-field `equalsField` rules are emitted as
`superRefine` issues so the Zod path-based error trees line up with the
field-id keys used elsewhere in the package.
## Features
- Generates a `ZodObject` from any `FormSchema`.
- Respects conditional `required` — optional when invisible.
- Maps `min`, `max`, `minLength`, `maxLength`, `pattern`, `email`, `url`,
and `oneOf` into native Zod constraints where possible.
- Cross-field `equalsField` reported via `superRefine`.
## Usage: Basic
```ts
import { toZodSchema } from "@suaveplan/form-builder";
const schema = toZodSchema(form);
const result = schema.safeParse({ name: "" });
// result.success === false (name is required)
```
## Usage: Advanced
```ts
import { toZodSchema } from "@suaveplan/form-builder";
// const resolver = zodResolver(toZodSchema(form));
// const { register, handleSubmit } = useForm({ resolver });
```
## API Reference
- `toZodSchema(form): z.ZodType<FormValues>`.
## Implementation Notes
Every field is wrapped in `.optional()` so the generated schema accepts
partially-filled drafts; the `superRefine` step is the canonical layer
that decides whether a value is structurally required. This means the
schema validates against the *current* values being parsed, which is what
`react-hook-form` consumers expect for conditional UIs. We deliberately
avoid `z.discriminatedUnion` over field types because field types are an
attribute of the schema, not the value space.