Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,144 @@
{
"name": "@suaveplan/form-builder",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./schema/schema/schema": {
"import": {
"types": "./dist/schema/schema/schema.d.ts",
"default": "./dist/schema/schema/schema.js"
}
},
"./fields/field-registry/field-registry": {
"import": {
"types": "./dist/fields/field-registry/field-registry.d.ts",
"default": "./dist/fields/field-registry/field-registry.js"
}
},
"./validation/validation/validation": {
"import": {
"types": "./dist/validation/validation/validation.d.ts",
"default": "./dist/validation/validation/validation.js"
}
},
"./validation/validators/validators": {
"import": {
"types": "./dist/validation/validators/validators.d.ts",
"default": "./dist/validation/validators/validators.js"
}
},
"./conditional/conditional/conditional": {
"import": {
"types": "./dist/conditional/conditional/conditional.d.ts",
"default": "./dist/conditional/conditional/conditional.js"
}
},
"./conditional/dependency-graph/dependency-graph": {
"import": {
"types": "./dist/conditional/dependency-graph/dependency-graph.d.ts",
"default": "./dist/conditional/dependency-graph/dependency-graph.js"
}
},
"./state/form-state/form-state": {
"import": {
"types": "./dist/state/form-state/form-state.d.ts",
"default": "./dist/state/form-state/form-state.js"
}
},
"./state/undo-redo/undo-redo": {
"import": {
"types": "./dist/state/undo-redo/undo-redo.d.ts",
"default": "./dist/state/undo-redo/undo-redo.js"
}
},
"./builder/form-builder/form-builder": {
"import": {
"types": "./dist/builder/form-builder/form-builder.d.ts",
"default": "./dist/builder/form-builder/form-builder.js"
}
},
"./submission/submission/submission": {
"import": {
"types": "./dist/submission/submission/submission.d.ts",
"default": "./dist/submission/submission/submission.js"
}
},
"./serialise/serialise/serialise": {
"import": {
"types": "./dist/serialise/serialise/serialise.d.ts",
"default": "./dist/serialise/serialise/serialise.js"
}
},
"./zod-schema/zod-schema/zod-schema": {
"import": {
"types": "./dist/zod-schema/zod-schema/zod-schema.d.ts",
"default": "./dist/zod-schema/zod-schema/zod-schema.js"
}
},
"./errors/form-error/form-error": {
"import": {
"types": "./dist/errors/form-error/form-error.d.ts",
"default": "./dist/errors/form-error/form-error.js"
}
},
"./otel/instruments/instruments": {
"import": {
"types": "./dist/otel/instruments/instruments.d.ts",
"default": "./dist/otel/instruments/instruments.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,300 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FieldDefinition, FormEventMap } from "../../types/form.types.js";
import { FormBuilder } from "./form-builder.js";
const text = (
id: string,
extra: Partial<FieldDefinition> = {}
): FieldDefinition => ({
id,
name: id,
label: id,
type: "text",
...extra,
});
describe("FormBuilder — schema mutation", () => {
it("raises DUPLICATE_ID on duplicate addField", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
try {
b.addField(text("a"));
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.DUPLICATE_ID
);
}
});
it("raises FIELD_NOT_FOUND on missing removeField", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.removeField("nope")).toThrow(FormError);
});
it("raises FIELD_NOT_FOUND on missing updateField", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.updateField("nope", { label: "x" })).toThrow(FormError);
});
it("raises FIELD_NOT_FOUND on missing getField", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(() => b.getField("nope")).toThrow(FormError);
});
it("raises SECTION_NOT_FOUND on missing removeSection", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.removeSection("nope")).toThrow(FormError);
});
it("raises SECTION_NOT_FOUND on missing getSectionById", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(() => b.getSectionById("nope")).toThrow(FormError);
});
it("raises DUPLICATE_ID on duplicate addSection", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addSection({ id: "s", title: "S" });
expect(() => b.addSection({ id: "s", title: "S" })).toThrow(FormError);
});
it("addSection then removeSection works", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addSection({ id: "s", title: "S" });
expect(b.getSectionById("s").title).toBe("S");
b.removeSection("s");
expect(() => b.getSectionById("s")).toThrow(FormError);
});
it("updateField returns the updated field", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
const updated = b.updateField("a", { label: "Renamed" });
expect(updated.label).toBe("Renamed");
expect(b.getField("a").label).toBe("Renamed");
});
});
describe("FormBuilder — createSchema", () => {
it("requires id and name", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(() => b.createSchema("", "x")).toThrow(FormError);
expect(() => b.createSchema("x", "")).toThrow(FormError);
});
it("uses injected clock for timestamps", { timeout: 5000 }, () => {
const b = new FormBuilder({ clock: () => 0 });
const s = b.createSchema("id", "name");
expect(s.createdAt).toBe("1970-01-01T00:00:00.000Z");
expect(s.updatedAt).toBe("1970-01-01T00:00:00.000Z");
});
it("includes optional description and submitLabel", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
const s = b.createSchema("id", "name", {
description: "desc",
submitLabel: "Send",
});
expect(s.description).toBe("desc");
expect(s.submitLabel).toBe("Send");
});
it("includes sections when present", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addSection({ id: "s", title: "S" });
const s = b.createSchema("id", "name");
expect(s.sections?.[0]?.id).toBe("s");
});
});
describe("FormBuilder — visibility + state", () => {
it("hides field when conditional is false", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
b.addField(
text("b", {
conditional: { field: "a", operator: "eq", value: "yes" },
})
);
const visible = b.getVisibleFields({ a: "no" });
expect(visible.map((f) => f.id)).toEqual(["a"]);
});
it("getVisibleFields skips orphan ids (equalsField to missing field)", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
b.addField(
text("confirm", {
validations: [{ type: "equalsField", value: "missing" }],
})
);
// The dep-graph contains "missing" as a node but the builder has no
// FieldDefinition for it; getVisibleFields must skip the orphan.
const visible = b.getVisibleFields({});
expect(visible.map((f) => f.id)).toEqual(["confirm"]);
});
it("includes fields with no defaultValue when resetForm runs", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
b.addField(text("a"));
const state = b.resetForm();
expect(state.values.a).toBeUndefined();
});
it("respects field.hidden flag", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a", { hidden: true }));
expect(b.getVisibleFields({})).toHaveLength(0);
});
it("emits values.changed on setValues", { timeout: 5000 }, async () => {
const b = new FormBuilder();
b.addField(text("a"));
let captured: Record<string, unknown> | undefined;
b.bus.on("values.changed", (values) => {
captured = values;
});
await b.setValues({ a: 1 });
expect(captured?.a).toBe(1);
});
it("resetForm applies defaultValue and emits form.reset", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
b.addField(text("a", { defaultValue: "x" }));
let resetFired = false;
b.bus.on("form.reset", () => {
resetFired = true;
});
const state = b.resetForm();
expect(state.values.a).toBe("x");
expect(resetFired).toBe(true);
});
it("validateForm emits form.validated", { timeout: 5000 }, async () => {
const b = new FormBuilder();
b.addField(text("a", { validations: [{ type: "required" }] }));
let captured: readonly unknown[] | undefined;
b.bus.on("form.validated", (errors) => {
captured = errors;
});
await b.validateForm({ a: "" });
expect(captured?.length).toBe(1);
});
it("validateField runs only that field's rules", {
timeout: 5000,
}, async () => {
const b = new FormBuilder();
b.addField(text("a", { validations: [{ type: "required" }] }));
const errors = await b.validateField("a", "");
expect(errors).toHaveLength(1);
});
it("getState returns the current snapshot", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(b.getState().dirty).toBe(false);
});
it("toJSON serialises fields + sections", { timeout: 5000 }, () => {
const b = new FormBuilder();
b.addField(text("a"));
b.addSection({ id: "s", title: "S" });
const json = JSON.parse(b.toJSON()) as {
fields: unknown[];
sections: unknown[];
};
expect(json.fields).toHaveLength(1);
expect(json.sections).toHaveLength(1);
});
it("newId emits a non-empty string", { timeout: 5000 }, () => {
const b = new FormBuilder({ idGenerator: () => "abc" });
expect(b.newId()).toBe("abc");
});
it("default idGenerator emits a non-empty string", {
timeout: 5000,
}, () => {
const b = new FormBuilder();
expect(b.newId().length).toBeGreaterThan(0);
});
it("default clock advances on each call", { timeout: 5000 }, () => {
const b = new FormBuilder();
const s = b.createSchema("a", "b");
expect(typeof s.createdAt).toBe("string");
});
it("uses a caller-supplied EventBus", { timeout: 5000 }, () => {
const bus = new EventBus<FormEventMap>();
const b = new FormBuilder({ bus });
expect(b.bus).toBe(bus);
});
});
describe("FormBuilder — undo/redo", () => {
it("undo reverses addField", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory();
b.addField(text("a"));
expect(b.getSchema().fields).toHaveLength(1);
expect(b.undo()).toBe(true);
expect(b.getSchema().fields).toHaveLength(0);
expect(b.redo()).toBe(true);
expect(b.getSchema().fields).toHaveLength(1);
});
it("undo without history returns false", { timeout: 5000 }, () => {
const b = new FormBuilder();
expect(b.undo()).toBe(false);
expect(b.redo()).toBe(false);
});
it("enableHistory accepts a capacity", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory(5);
b.addField(text("a"));
expect(b.undo()).toBe(true);
});
it("undo and redo of updateField round-trip", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory();
b.addField(text("a", { label: "first" }));
b.updateField("a", { label: "second" });
expect(b.getField("a").label).toBe("second");
b.undo();
expect(b.getField("a").label).toBe("first");
b.redo();
expect(b.getField("a").label).toBe("second");
});
it("undo and redo of removeField round-trip", { timeout: 5000 }, () => {
const b = new FormBuilder().enableHistory();
b.addField(text("a"));
b.removeField("a");
expect(b.getSchema().fields).toHaveLength(0);
b.undo();
expect(b.getSchema().fields).toHaveLength(1);
b.redo();
expect(b.getSchema().fields).toHaveLength(0);
});
});

View File

@@ -0,0 +1,329 @@
import { EventBus } from "@suaveplan/events";
import { evaluateConditional } from "../../conditional/conditional/conditional.js";
import { DependencyGraph } from "../../conditional/dependency-graph/dependency-graph.js";
import {
configInvalid,
duplicateId,
fieldNotFound,
sectionNotFound,
} from "../../errors/form-error/form-error.js";
import { fieldMutatedCounter } from "../../otel/instruments/instruments.js";
import { FormStateMachine } from "../../state/form-state/form-state.js";
import type { UndoOp } from "../../state/undo-redo/undo-redo.js";
import { UndoRedoStack } from "../../state/undo-redo/undo-redo.js";
import type {
FieldDefinition,
FormEventMap,
FormSchema,
FormSection,
FormState,
FormValues,
ValidationError,
} from "../../types/form.types.js";
import {
runFieldValidation,
runFormValidation,
} from "../../validation/validation/validation.js";
/** Clock signature compatible with `@suaveplan/types`. */
export type Clock = () => number;
/** Id-generator signature compatible with `@suaveplan/types`. */
export type IdGenerator = () => string;
const defaultClock: Clock = () => Date.now();
const defaultIdGenerator: IdGenerator = () =>
Math.random().toString(36).slice(2);
export interface FormBuilderOptions {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
readonly bus?: EventBus<FormEventMap>;
}
/**
* Top-level façade aggregating schema, state, validation, undo/redo,
* dependency graph, and event emission. Universal — zero DOM,
* zero React, zero Node-specific globals.
*/
export class FormBuilder {
public readonly bus: EventBus<FormEventMap>;
private readonly fields: FieldDefinition[] = [];
private readonly sections: FormSection[] = [];
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
private readonly state = new FormStateMachine();
private history?: UndoRedoStack;
public constructor(options: FormBuilderOptions = {}) {
this.clock = options.clock ?? defaultClock;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
this.bus = options.bus ?? new EventBus<FormEventMap>();
}
/** Produce a `FormSchema` snapshot from the current fields + sections. */
public createSchema(
id: string,
name: string,
options: {
readonly description?: string;
readonly submitLabel?: string;
} = {}
): FormSchema {
if (!id || !name) {
throw configInvalid("id and name are required");
}
const ts = new Date(this.clock()).toISOString();
const base: FormSchema = {
id,
name,
version: 1,
fields: [...this.fields],
createdAt: ts,
updatedAt: ts,
...(options.description !== undefined
? { description: options.description }
: {}),
...(options.submitLabel !== undefined
? { submitLabel: options.submitLabel }
: {}),
...(this.sections.length > 0
? { sections: [...this.sections] }
: {}),
};
return base;
}
/** Register a new field. Emits `field.added`. */
public addField(field: FieldDefinition): this {
this.addFieldInternal(field);
this.pushUndoPair(
() => this.removeFieldInternal(field.id),
() => this.addFieldInternal(field)
);
return this;
}
private addFieldInternal(field: FieldDefinition): void {
if (this.fields.some((f) => f.id === field.id)) {
throw duplicateId("field", field.id);
}
this.fields.push(field);
this.bus.emit("field.added", field);
fieldMutatedCounter().add(1, { op: "add" });
}
/** Remove a field by id. Emits `field.removed`. */
public removeField(fieldId: string): void {
const field = this.fields.find((f) => f.id === fieldId);
if (field === undefined) throw fieldNotFound(fieldId);
this.removeFieldInternal(fieldId);
this.pushUndoPair(
() => this.addFieldInternal(field),
() => this.removeFieldInternal(fieldId)
);
}
private removeFieldInternal(fieldId: string): void {
const idx = this.fields.findIndex((f) => f.id === fieldId);
this.fields.splice(idx, 1);
this.bus.emit("field.removed", fieldId);
fieldMutatedCounter().add(1, { op: "remove" });
}
/** Update an existing field's fragment. Emits `field.updated`. */
public updateField(
fieldId: string,
updates: Partial<Omit<FieldDefinition, "id">>
): FieldDefinition {
const idx = this.fields.findIndex((f) => f.id === fieldId);
if (idx === -1) throw fieldNotFound(fieldId);
const previous = this.fields[idx] as FieldDefinition;
const next = { ...previous, ...updates } as FieldDefinition;
this.applyUpdate(idx, next);
this.pushUndoPair(
() => this.applyUpdate(idx, previous),
() => this.applyUpdate(idx, next)
);
return next;
}
private applyUpdate(idx: number, field: FieldDefinition): void {
this.fields[idx] = field;
this.bus.emit("field.updated", field);
fieldMutatedCounter().add(1, { op: "update" });
}
/** Register a section. Emits `section.added`. */
public addSection(section: FormSection): this {
if (this.sections.some((s) => s.id === section.id)) {
throw duplicateId("section", section.id);
}
this.sections.push(section);
this.bus.emit("section.added", section);
return this;
}
/** Remove a section by id. Emits `section.removed`. */
public removeSection(sectionId: string): void {
const idx = this.sections.findIndex((s) => s.id === sectionId);
if (idx === -1) throw sectionNotFound(sectionId);
this.sections.splice(idx, 1);
this.bus.emit("section.removed", sectionId);
}
/** Lookup a field by id. */
public getField(fieldId: string): FieldDefinition {
const field = this.fields.find((f) => f.id === fieldId);
if (field === undefined) throw fieldNotFound(fieldId);
return field;
}
/** Lookup a section by id. */
public getSectionById(sectionId: string): FormSection {
const section = this.sections.find((s) => s.id === sectionId);
if (section === undefined) throw sectionNotFound(sectionId);
return section;
}
/** Snapshot the current fields + sections. */
public getSchema(): {
readonly fields: readonly FieldDefinition[];
readonly sections: readonly FormSection[];
} {
return {
fields: [...this.fields],
sections: [...this.sections],
};
}
/** Validate a single field. */
public async validateField(
fieldId: string,
value: unknown,
signal?: AbortSignal
): Promise<readonly ValidationError[]> {
const field = this.getField(fieldId);
return runFieldValidation(
field,
value,
this.state.getState().values,
signal,
this.clock
);
}
/** Validate the entire form. Emits `form.validated`. */
public async validateForm(
values: FormValues,
signal?: AbortSignal
): Promise<readonly ValidationError[]> {
const errors = await runFormValidation(
this.fields,
values,
signal,
this.clock
);
this.state.setErrors(errors);
this.bus.emit("form.validated", errors);
return errors;
}
/** Return only the fields whose conditional rule evaluates true. */
public getVisibleFields(values: FormValues): readonly FieldDefinition[] {
const graph = new DependencyGraph(this.fields);
const order = graph.topoSort();
const indexedById = new Map<string, FieldDefinition>();
for (const field of this.fields) indexedById.set(field.id, field);
const visible: FieldDefinition[] = [];
for (const id of order) {
const field = indexedById.get(id);
if (field === undefined) continue;
if (field.hidden === true) continue;
if (
field.conditional !== undefined &&
!evaluateConditional(field.conditional, values)
) {
continue;
}
visible.push(field);
}
return visible;
}
/** Replace the current values with a new map. */
public async setValues(
values: FormValues,
signal?: AbortSignal
): Promise<FormState> {
this.state.setValues(values);
const errors = await this.validateForm(values, signal);
this.state.setErrors(errors);
const snapshot = this.state.getState();
this.bus.emit("values.changed", snapshot.values);
return snapshot;
}
/** Reset the form to its defaults (taken from each field's `defaultValue`). */
public resetForm(): FormState {
const defaults: FormValues = {};
for (const field of this.fields) {
if (field.defaultValue !== undefined) {
defaults[field.id] = field.defaultValue;
}
}
this.state.reset(defaults);
this.bus.emit("form.reset", null);
return this.state.getState();
}
/** Snapshot the current runtime state. */
public getState(): FormState {
return this.state.getState();
}
/** JSON serialisation of the current fields + sections. */
public toJSON(): string {
return JSON.stringify({
fields: this.fields,
sections: this.sections,
});
}
/** Enable bounded undo/redo recording for schema mutations. */
public enableHistory(capacity?: number): this {
this.history =
capacity === undefined
? new UndoRedoStack()
: new UndoRedoStack(capacity);
return this;
}
/** Apply the most-recent undo op (no-op if history disabled). */
public undo(): boolean {
if (this.history === undefined) return false;
return this.history.undo() !== undefined;
}
/** Apply the most-recent redo op (no-op if history disabled). */
public redo(): boolean {
if (this.history === undefined) return false;
return this.history.redo() !== undefined;
}
/** Generate an id via the injected `IdGenerator`. */
public newId(): string {
return this.idGenerator();
}
private pushUndoPair(undoApply: () => void, redoApply: () => void): void {
if (this.history === undefined) return;
const undoOp: UndoOp = {
apply: undoApply,
inverse: undefined as unknown as UndoOp,
};
const redoOp: UndoOp = { apply: redoApply, inverse: undoOp };
(undoOp as { inverse: UndoOp }).inverse = redoOp;
this.history.push(undoOp);
}
}

View File

@@ -0,0 +1,6 @@
export {
type Clock,
FormBuilder,
type FormBuilderOptions,
type IdGenerator,
} from "./form-builder.js";

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,289 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { evaluateConditional, fieldsReferencedBy } from "./conditional.js";
describe("evaluateConditional — leaf operators", () => {
it("leaf eq evaluates equal", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "eq", value: 1 },
{ x: 1 }
)
).toBe(true);
});
it("leaf neq evaluates not-equal", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "neq", value: 2 },
{ x: 1 }
)
).toBe(true);
});
it("leaf in / notIn behave inversely", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "in", value: [1, 2] },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "notIn", value: [1, 2] },
{ x: 3 }
)
).toBe(true);
expect(
evaluateConditional(
{
kind: "leaf",
field: "x",
operator: "in",
value: "nope" as unknown as readonly unknown[],
},
{ x: 1 }
)
).toBe(false);
expect(
evaluateConditional(
{
kind: "leaf",
field: "x",
operator: "notIn",
value: "nope" as unknown as readonly unknown[],
},
{ x: 1 }
)
).toBe(false);
});
it("leaf gt / lt / gte / lte work for numbers and reject non-numbers", {
timeout: 5000,
}, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gt", value: 1 },
{ x: 2 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gt", value: "1" },
{ x: 2 }
)
).toBe(false);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lt", value: 2 },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lt", value: "x" },
{ x: 1 }
)
).toBe(false);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gte", value: 1 },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "gte", value: "x" },
{ x: 1 }
)
).toBe(false);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lte", value: 1 },
{ x: 1 }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "lte", value: "x" },
{ x: 1 }
)
).toBe(false);
});
it("contains works for strings and arrays", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "contains", value: "lo" },
{ x: "hello" }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "contains", value: 2 },
{ x: [1, 2, 3] }
)
).toBe(true);
expect(
evaluateConditional(
{ kind: "leaf", field: "x", operator: "contains", value: 9 },
{ x: 5 }
)
).toBe(false);
});
});
describe("evaluateConditional — boolean composition", () => {
it("and short-circuits on first false", { timeout: 5000 }, () => {
let called = 0;
const rule = {
kind: "and" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
{
kind: "leaf" as const,
field: "spy",
operator: "eq" as const,
get value(): number {
called++;
return 2;
},
},
],
};
const result = evaluateConditional(rule, { x: 9, spy: 2 });
expect(result).toBe(false);
expect(called).toBe(0);
});
it("and returns true when all rules pass", { timeout: 5000 }, () => {
const rule = {
kind: "and" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
{
kind: "leaf" as const,
field: "y",
operator: "eq" as const,
value: 2,
},
],
};
expect(evaluateConditional(rule, { x: 1, y: 2 })).toBe(true);
});
it("or evaluates true when any rule passes", { timeout: 5000 }, () => {
const rule = {
kind: "or" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
{
kind: "leaf" as const,
field: "y",
operator: "eq" as const,
value: 2,
},
],
};
expect(evaluateConditional(rule, { x: 9, y: 2 })).toBe(true);
});
it("or evaluates false when no rule passes", { timeout: 5000 }, () => {
const rule = {
kind: "or" as const,
rules: [
{
kind: "leaf" as const,
field: "x",
operator: "eq" as const,
value: 1,
},
],
};
expect(evaluateConditional(rule, { x: 9 })).toBe(false);
});
it("not inverts the inner rule", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{
kind: "not",
rule: {
kind: "leaf",
field: "x",
operator: "eq",
value: 1,
},
},
{ x: 9 }
)
).toBe(true);
});
});
describe("evaluateConditional — legacy shape", () => {
it("accepts legacy leaf shape", { timeout: 5000 }, () => {
expect(
evaluateConditional(
{ field: "x", operator: "eq", value: 1 },
{ x: 1 }
)
).toBe(true);
});
});
describe("fieldsReferencedBy", () => {
it("collects leaf field ids", { timeout: 5000 }, () => {
expect(
fieldsReferencedBy({
kind: "leaf",
field: "x",
operator: "eq",
value: 1,
})
).toEqual(["x"]);
});
it("collects deduped ids from boolean trees", { timeout: 5000 }, () => {
const ids = fieldsReferencedBy({
kind: "and",
rules: [
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
{
kind: "or",
rules: [
{ kind: "leaf", field: "b", operator: "eq", value: 2 },
{
kind: "not",
rule: {
kind: "leaf",
field: "a",
operator: "eq",
value: 3,
},
},
],
},
],
});
expect(ids.sort()).toEqual(["a", "b"]);
});
it("collects from a bare not rule", { timeout: 5000 }, () => {
expect(
fieldsReferencedBy({
kind: "not",
rule: { kind: "leaf", field: "x", operator: "eq", value: 1 },
})
).toEqual(["x"]);
});
it("collects from legacy shape", { timeout: 5000 }, () => {
expect(
fieldsReferencedBy({ field: "x", operator: "eq", value: 1 })
).toEqual(["x"]);
});
});

View File

@@ -0,0 +1,133 @@
import type {
ConditionalRule,
FormValues,
LeafOperator,
} from "../../types/form.types.js";
interface NormalisedLeaf {
readonly kind: "leaf";
readonly field: string;
readonly operator: LeafOperator;
readonly value: unknown;
}
function normaliseLeaf(rule: ConditionalRule): NormalisedLeaf | null {
if ("kind" in rule) {
return rule.kind === "leaf" ? rule : null;
}
return {
kind: "leaf",
field: rule.field,
operator: rule.operator,
value: rule.value,
};
}
function compareLeaf(leaf: NormalisedLeaf, values: FormValues): boolean {
const fieldValue = values[leaf.field];
switch (leaf.operator) {
case "eq":
return fieldValue === leaf.value;
case "neq":
return fieldValue !== leaf.value;
case "in":
return Array.isArray(leaf.value) && leaf.value.includes(fieldValue);
case "notIn":
return (
Array.isArray(leaf.value) && !leaf.value.includes(fieldValue)
);
case "gt":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue > leaf.value
);
case "lt":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue < leaf.value
);
case "gte":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue >= leaf.value
);
case "lte":
return (
typeof fieldValue === "number" &&
typeof leaf.value === "number" &&
fieldValue <= leaf.value
);
case "contains":
if (
typeof fieldValue === "string" &&
typeof leaf.value === "string"
) {
return fieldValue.includes(leaf.value);
}
return Array.isArray(fieldValue) && fieldValue.includes(leaf.value);
}
}
/**
* Evaluate a `ConditionalRule` against the current `FormValues`.
* Returns `true` when the condition holds. Boolean nodes short-circuit
* on the first decisive child.
*/
export function evaluateConditional(
rule: ConditionalRule,
values: FormValues
): boolean {
const leaf = normaliseLeaf(rule);
if (leaf !== null) return compareLeaf(leaf, values);
const tagged = rule as { kind: "and" | "or" | "not" } & ConditionalRule;
if (tagged.kind === "and") {
for (const child of (tagged as { rules: readonly ConditionalRule[] })
.rules) {
if (!evaluateConditional(child, values)) return false;
}
return true;
}
if (tagged.kind === "or") {
for (const child of (tagged as { rules: readonly ConditionalRule[] })
.rules) {
if (evaluateConditional(child, values)) return true;
}
return false;
}
return !evaluateConditional(
(tagged as { rule: ConditionalRule }).rule,
values
);
}
/**
* Return every field id referenced by a conditional rule (recursively).
* Used by the dependency-graph builder.
*/
export function fieldsReferencedBy(rule: ConditionalRule): readonly string[] {
const out = new Set<string>();
const walk = (r: ConditionalRule): void => {
const leaf = normaliseLeaf(r);
if (leaf !== null) {
out.add(leaf.field);
return;
}
const tagged = r as { kind: "and" | "or" | "not" } & ConditionalRule;
if (tagged.kind === "not") {
walk((tagged as { rule: ConditionalRule }).rule);
return;
}
for (const child of (
tagged as {
rules: readonly ConditionalRule[];
}
).rules) {
walk(child);
}
};
walk(rule);
return Array.from(out);
}

View File

@@ -0,0 +1,4 @@
export {
evaluateConditional,
fieldsReferencedBy,
} from "./conditional.js";

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,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FieldDefinition } from "../../types/form.types.js";
import { DependencyGraph } from "./dependency-graph.js";
const f = (
id: string,
extra: Partial<FieldDefinition> = {}
): FieldDefinition => ({
id,
name: id,
label: id,
type: "text",
...extra,
});
describe("DependencyGraph", () => {
it("topo-sorts linear chain", { timeout: 5000 }, () => {
const graph = new DependencyGraph([
f("a"),
f("b", {
conditional: { field: "a", operator: "eq", value: "yes" },
}),
f("c", {
conditional: { field: "b", operator: "eq", value: "yes" },
}),
]);
const order = graph.topoSort();
expect(order.indexOf("a")).toBeLessThan(order.indexOf("b"));
expect(order.indexOf("b")).toBeLessThan(order.indexOf("c"));
});
it("derives edges from equalsField validators", {
timeout: 5000,
}, () => {
const graph = new DependencyGraph([
f("password"),
f("confirm", {
validations: [{ type: "equalsField", value: "password" }],
}),
]);
expect(graph.dependents("password")).toContain("confirm");
});
it("throws on cycle", { timeout: 5000 }, () => {
const graph = new DependencyGraph([
f("a", {
conditional: { field: "b", operator: "eq", value: "yes" },
}),
f("b", {
conditional: { field: "a", operator: "eq", value: "yes" },
}),
]);
try {
graph.topoSort();
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.CYCLIC_DEPENDENCY
);
}
});
it("nodes() returns every registered id", { timeout: 5000 }, () => {
const graph = new DependencyGraph([f("a"), f("b")]);
expect(graph.nodes().sort()).toEqual(["a", "b"]);
});
it("addField is idempotent for the same id", { timeout: 5000 }, () => {
const graph = new DependencyGraph();
graph.addField(f("a"));
graph.addField(f("a"));
expect(graph.nodes()).toEqual(["a"]);
});
it("dependents returns [] for unknown source", { timeout: 5000 }, () => {
const graph = new DependencyGraph([f("a")]);
expect(graph.dependents("nope")).toEqual([]);
});
it("ignores equalsField with a non-string value", { timeout: 5000 }, () => {
const graph = new DependencyGraph([
f("confirm", {
validations: [
{ type: "equalsField", value: 5 as unknown as string },
],
}),
]);
expect(graph.nodes()).toEqual(["confirm"]);
});
it("ignores non-equalsField validators when building edges", {
timeout: 5000,
}, () => {
const graph = new DependencyGraph([
f("name", { validations: [{ type: "required" }] }),
]);
expect(graph.nodes()).toEqual(["name"]);
});
it("topo-sorts a diamond (node with multiple inbound edges)", {
timeout: 5000,
}, () => {
const graph = new DependencyGraph([
f("a"),
f("b"),
f("c", {
conditional: {
kind: "and",
rules: [
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
{ kind: "leaf", field: "b", operator: "eq", value: 1 },
],
},
}),
]);
const order = graph.topoSort();
expect(order.indexOf("c")).toBeGreaterThan(order.indexOf("a"));
expect(order.indexOf("c")).toBeGreaterThan(order.indexOf("b"));
});
it("orders bare nodes deterministically", { timeout: 5000 }, () => {
const graph = new DependencyGraph([f("a"), f("b"), f("c")]);
const order = graph.topoSort();
expect(order).toHaveLength(3);
});
});

View File

@@ -0,0 +1,96 @@
import { cyclicDependency } from "../../errors/form-error/form-error.js";
import type { FieldDefinition } from "../../types/form.types.js";
import { fieldsReferencedBy } from "../conditional/conditional.js";
/**
* Adjacency-list DAG keyed by field id. Edges point from a referenced
* field to the field that references it: if `b.conditional` references
* `a`, then the edge is `a → b`, and `topoSort` returns `a` before `b`.
*/
export class DependencyGraph {
private readonly adjacency = new Map<string, Set<string>>();
private readonly ids = new Set<string>();
public constructor(fields: readonly FieldDefinition[] = []) {
for (const field of fields) {
this.addField(field);
}
}
/** Register a field and every edge derived from its rules. */
public addField(field: FieldDefinition): void {
this.ids.add(field.id);
if (!this.adjacency.has(field.id)) {
this.adjacency.set(field.id, new Set());
}
if (field.conditional !== undefined) {
for (const referenced of fieldsReferencedBy(field.conditional)) {
this.ensureNode(referenced).add(field.id);
}
}
const validations = field.validations ?? [];
for (const rule of validations) {
if (rule.type !== "equalsField") continue;
if (typeof rule.value !== "string") continue;
this.ensureNode(rule.value).add(field.id);
}
}
/** Snapshot list of every registered field id. */
public nodes(): readonly string[] {
return Array.from(this.ids);
}
/** Outgoing edges from `from`. */
public dependents(from: string): readonly string[] {
const set = this.adjacency.get(from);
if (set === undefined) return [];
return Array.from(set);
}
/**
* Kahn's topological sort. Throws
* `FORM_BUILDER.CYCLIC_DEPENDENCY` if a cycle is detected.
*/
public topoSort(): readonly string[] {
const indegree = new Map<string, number>();
for (const id of this.ids) indegree.set(id, 0);
for (const [, outgoing] of this.adjacency) {
for (const target of outgoing) {
indegree.set(target, (indegree.get(target) as number) + 1);
}
}
const queue: string[] = [];
for (const [id, degree] of indegree) {
if (degree === 0) queue.push(id);
}
const order: string[] = [];
while (queue.length > 0) {
const next = queue.shift() as string;
order.push(next);
const outgoing = this.adjacency.get(next) as Set<string>;
for (const target of outgoing) {
const newDegree = (indegree.get(target) as number) - 1;
indegree.set(target, newDegree);
if (newDegree === 0) queue.push(target);
}
}
if (order.length !== this.ids.size) {
const remaining = Array.from(this.ids).filter(
(id) => !order.includes(id)
);
throw cyclicDependency(remaining);
}
return order;
}
private ensureNode(id: string): Set<string> {
let edges = this.adjacency.get(id);
if (edges === undefined) {
edges = new Set();
this.adjacency.set(id, edges);
}
this.ids.add(id);
return edges;
}
}

View File

@@ -0,0 +1 @@
export { DependencyGraph } from "./dependency-graph.js";

View File

@@ -0,0 +1,60 @@
import type {
FieldType,
LeafOperator,
ValidationRuleType,
} from "../types/form.types.js";
/** Closed list of built-in field types. */
export const FIELD_TYPES: readonly FieldType[] = [
"text",
"number",
"email",
"url",
"tel",
"password",
"select",
"multiselect",
"checkbox",
"radio",
"textarea",
"date",
"file",
"range",
"hidden",
"group",
"array",
] as const;
/** Closed list of built-in leaf operators. */
export const LEAF_OPERATORS: readonly LeafOperator[] = [
"eq",
"neq",
"in",
"notIn",
"gt",
"lt",
"gte",
"lte",
"contains",
] as const;
/** Closed list of built-in rule kinds. */
export const BUILT_IN_RULES: readonly ValidationRuleType[] = [
"required",
"minLength",
"maxLength",
"min",
"max",
"pattern",
"email",
"url",
"oneOf",
"equalsField",
"custom",
] as const;
/** Default capacity for the undo/redo stack. */
export const DEFAULT_UNDO_CAPACITY = 100;
/** OTel meter name used across the package. */
export const METER_NAME = "@suaveplan/form-builder";

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,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
configInvalid,
cyclicDependency,
duplicateId,
FORM_BUILDER_CODES,
FormError,
fieldNotFound,
sectionNotFound,
validationFailed,
} from "./form-error.js";
describe("FormError", () => {
it("constructs with code, message, and context", { timeout: 5000 }, () => {
const err = new FormError(
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
"missing",
{ context: { fieldId: "x" } }
);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(FORM_BUILDER_CODES.FIELD_NOT_FOUND);
expect(err.message).toBe("missing");
expect(err.context?.fieldId).toBe("x");
expect(err.name).toBe("FormError");
});
it("works with no options argument", { timeout: 5000 }, () => {
const err = new FormError(
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
"missing"
);
expect(err.code).toBe(FORM_BUILDER_CODES.FIELD_NOT_FOUND);
expect(err.context).toBeUndefined();
});
it("preserves the cause when supplied", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new FormError(FORM_BUILDER_CODES.CONFIG_INVALID, "bad", {
cause,
});
expect(err.cause).toBe(cause);
});
});
describe("error factories", () => {
it("every code is reachable from a factory", { timeout: 5000 }, () => {
const codes = new Set<string>();
codes.add(fieldNotFound("a").code);
codes.add(sectionNotFound("a").code);
codes.add(duplicateId("field", "a").code);
codes.add(configInvalid("oops").code);
codes.add(
validationFailed([
{ fieldId: "x", rule: "required", message: "no" },
]).code
);
codes.add(cyclicDependency(["a", "b"]).code);
const expected = Object.values(FORM_BUILDER_CODES);
for (const code of expected) {
expect(codes.has(code)).toBe(true);
}
});
it("duplicateId for sections sets sectionId in context", {
timeout: 5000,
}, () => {
const err = duplicateId("section", "general");
expect(err.context?.sectionId).toBe("general");
});
it("validationFailed reports correct singular wording", {
timeout: 5000,
}, () => {
const err = validationFailed([
{ fieldId: "x", rule: "required", message: "no" },
]);
expect(err.message).toContain("1 error");
});
it("validationFailed reports plural wording", { timeout: 5000 }, () => {
const err = validationFailed([
{ fieldId: "x", rule: "required", message: "no" },
{ fieldId: "y", rule: "required", message: "no" },
]);
expect(err.message).toContain("2 errors");
});
it("cyclicDependency lists the cycle", { timeout: 5000 }, () => {
const err = cyclicDependency(["a", "b"]);
expect(err.message).toContain("a → b");
expect(err.context?.cycle).toEqual(["a", "b"]);
});
it("configInvalid accepts a cause", { timeout: 5000 }, () => {
const cause = new Error("zod");
const err = configInvalid("nope", cause);
expect(err.cause).toBe(cause);
});
it("configInvalid without a cause leaves it undefined", {
timeout: 5000,
}, () => {
const err = configInvalid("nope");
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,110 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/form-builder`.
*
* Every public-API failure surfaces as a {@link FormError} carrying one of
* these literal codes. The registry uses `defineCodes` from
* `@suaveplan/error` so consumers can pattern-match on the fully-qualified
* code (`"FORM_BUILDER.FIELD_NOT_FOUND"` and friends).
*/
export const FORM_BUILDER_CODES = defineCodes("FORM_BUILDER", [
"FIELD_NOT_FOUND",
"SECTION_NOT_FOUND",
"DUPLICATE_ID",
"CONFIG_INVALID",
"VALIDATION_FAILED",
"CYCLIC_DEPENDENCY",
] as const);
export type FormBuilderCode =
(typeof FORM_BUILDER_CODES)[keyof typeof FORM_BUILDER_CODES];
export interface FormErrorContext extends Record<string, unknown> {
readonly fieldId?: string;
readonly sectionId?: string;
readonly schemaId?: string;
readonly cycle?: readonly string[];
readonly errors?: ReadonlyArray<{
readonly fieldId: string;
readonly rule: string;
readonly message: string;
}>;
}
/** Typed error class for every `@suaveplan/form-builder` failure. */
export class FormError extends SuaveplanError<FormBuilderCode> {
public constructor(
code: FormBuilderCode,
message: string,
options: {
readonly context?: FormErrorContext;
readonly cause?: Error;
} = {}
) {
super(code, message, options);
this.name = "FormError";
}
}
/** Throws when a referenced field id is missing from the schema. */
export function fieldNotFound(fieldId: string): FormError {
return new FormError(
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
`Field "${fieldId}" not found`,
{ context: { fieldId } }
);
}
/** Throws when a referenced section id is missing from the schema. */
export function sectionNotFound(sectionId: string): FormError {
return new FormError(
FORM_BUILDER_CODES.SECTION_NOT_FOUND,
`Section "${sectionId}" not found`,
{ context: { sectionId } }
);
}
/** Thrown when the same field or section id is registered twice. */
export function duplicateId(kind: "field" | "section", id: string): FormError {
return new FormError(
FORM_BUILDER_CODES.DUPLICATE_ID,
`Duplicate ${kind} id "${id}"`,
{
context: kind === "field" ? { fieldId: id } : { sectionId: id },
}
);
}
/** Thrown when a public input fails Zod boundary validation. */
export function configInvalid(message: string, cause?: Error): FormError {
return new FormError(
FORM_BUILDER_CODES.CONFIG_INVALID,
`Invalid form schema: ${message}`,
cause === undefined ? {} : { cause }
);
}
/** Thrown when `submit` aborts because validation produced errors. */
export function validationFailed(
errors: ReadonlyArray<{
readonly fieldId: string;
readonly rule: string;
readonly message: string;
}>
): FormError {
return new FormError(
FORM_BUILDER_CODES.VALIDATION_FAILED,
`Form validation failed with ${errors.length} error${errors.length === 1 ? "" : "s"}`,
{ context: { errors } }
);
}
/** Thrown when the dependency graph detects a cycle. */
export function cyclicDependency(cycle: readonly string[]): FormError {
return new FormError(
FORM_BUILDER_CODES.CYCLIC_DEPENDENCY,
`Cyclic field dependency detected: ${cycle.join(" → ")}`,
{ context: { cycle } }
);
}

View File

@@ -0,0 +1,12 @@
export {
configInvalid,
cyclicDependency,
duplicateId,
FORM_BUILDER_CODES,
type FormBuilderCode,
FormError,
type FormErrorContext,
fieldNotFound,
sectionNotFound,
validationFailed,
} from "./form-error.js";

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,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FIELD_TYPES } from "../../constants/index.js";
import { defaultFieldRegistry, FieldRegistry } from "./field-registry.js";
describe("FieldRegistry", () => {
it("exposes 17 built-in field types", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
expect(registry.list().length).toBe(FIELD_TYPES.length);
expect(registry.has("text")).toBe(true);
expect(registry.has("array")).toBe(true);
});
it("number descriptor coerces strings", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
const desc = registry.get("number");
expect(desc?.coerce?.("42")).toBe(42);
expect(desc?.coerce?.("")).toBeNull();
expect(desc?.coerce?.(null)).toBeNull();
expect(desc?.coerce?.(undefined)).toBeNull();
expect(desc?.coerce?.("abc")).toBe("abc");
});
it("range descriptor coerces strings with fallback to 0", {
timeout: 5000,
}, () => {
const registry = new FieldRegistry();
const desc = registry.get("range");
expect(desc?.coerce?.("5")).toBe(5);
expect(desc?.coerce?.("")).toBe(0);
expect(desc?.coerce?.(null)).toBe(0);
expect(desc?.coerce?.(undefined)).toBe(0);
expect(desc?.coerce?.("not-a-number")).toBe(0);
});
it("accepts custom field-type registration", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
registry.register("rating", {
label: "Rating",
defaultValue: 0,
hasOptions: false,
});
expect(registry.has("rating")).toBe(true);
expect(registry.get("rating")?.defaultValue).toBe(0);
});
it("unregister removes a type", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
expect(registry.unregister("text")).toBe(true);
expect(registry.has("text")).toBe(false);
expect(registry.unregister("nope")).toBe(false);
});
it("get returns undefined for unknown types", { timeout: 5000 }, () => {
const registry = new FieldRegistry();
expect(registry.get("nope")).toBeUndefined();
});
it("defaultFieldRegistry exposes the built-ins", { timeout: 5000 }, () => {
expect(defaultFieldRegistry.has("text")).toBe(true);
});
});

View File

@@ -0,0 +1,99 @@
import { FIELD_TYPES } from "../../constants/index.js";
import type { FieldType } from "../../types/form.types.js";
/** Descriptor backing a field type in the registry. */
export interface FieldTypeDescriptor {
/** Human-readable name (`"Single-line text"`). */
readonly label: string;
/** Value returned by `getDefault()` when no `defaultValue` is set. */
readonly defaultValue: unknown;
/** Whether the field type natively expects `options[]`. */
readonly hasOptions: boolean;
/**
* Optional coercer used to normalise raw input from a form into the
* canonical runtime shape (e.g. `"42"` → `42` for number fields).
*/
readonly coerce?: (raw: unknown) => unknown;
}
const BUILT_IN_DESCRIPTORS: Record<FieldType, FieldTypeDescriptor> = {
text: { label: "Text", defaultValue: "", hasOptions: false },
number: {
label: "Number",
defaultValue: null,
hasOptions: false,
coerce: (raw) => {
if (raw === null || raw === undefined || raw === "") return null;
const n = Number(raw);
return Number.isFinite(n) ? n : raw;
},
},
email: { label: "Email", defaultValue: "", hasOptions: false },
url: { label: "URL", defaultValue: "", hasOptions: false },
tel: { label: "Telephone", defaultValue: "", hasOptions: false },
password: { label: "Password", defaultValue: "", hasOptions: false },
select: { label: "Single select", defaultValue: null, hasOptions: true },
multiselect: {
label: "Multi select",
defaultValue: [],
hasOptions: true,
},
checkbox: { label: "Checkbox", defaultValue: false, hasOptions: false },
radio: { label: "Radio", defaultValue: null, hasOptions: true },
textarea: { label: "Long text", defaultValue: "", hasOptions: false },
date: { label: "Date", defaultValue: null, hasOptions: false },
file: { label: "File", defaultValue: null, hasOptions: false },
range: {
label: "Range",
defaultValue: 0,
hasOptions: false,
coerce: (raw) => {
if (raw === null || raw === undefined || raw === "") return 0;
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
},
},
hidden: { label: "Hidden", defaultValue: null, hasOptions: false },
group: { label: "Group", defaultValue: {}, hasOptions: false },
array: { label: "Array", defaultValue: [], hasOptions: false },
};
/** Registry for both built-in and custom field types. */
export class FieldRegistry {
private readonly entries = new Map<string, FieldTypeDescriptor>();
public constructor() {
for (const type of FIELD_TYPES) {
this.entries.set(type, BUILT_IN_DESCRIPTORS[type]);
}
}
/** Register a new field type (or overwrite an existing one). */
public register(type: string, descriptor: FieldTypeDescriptor): this {
this.entries.set(type, descriptor);
return this;
}
/** Remove a field type. Returns `true` if a descriptor was removed. */
public unregister(type: string): boolean {
return this.entries.delete(type);
}
/** Whether the registry knows the given type. */
public has(type: string): boolean {
return this.entries.has(type);
}
/** Lookup the descriptor for a type, or `undefined` if unknown. */
public get(type: string): FieldTypeDescriptor | undefined {
return this.entries.get(type);
}
/** Snapshot list of all registered type identifiers. */
public list(): readonly string[] {
return Array.from(this.entries.keys());
}
}
/** Singleton registry seeded with the 17 built-ins. */
export const defaultFieldRegistry = new FieldRegistry();

View File

@@ -0,0 +1,5 @@
export {
defaultFieldRegistry,
FieldRegistry,
type FieldTypeDescriptor,
} from "./field-registry.js";

View File

@@ -0,0 +1,88 @@
export {
type Clock,
FormBuilder,
type FormBuilderOptions,
type IdGenerator,
} from "./builder/form-builder/form-builder.js";
export {
evaluateConditional,
fieldsReferencedBy,
} from "./conditional/conditional/conditional.js";
export { DependencyGraph } from "./conditional/dependency-graph/dependency-graph.js";
export {
BUILT_IN_RULES,
DEFAULT_UNDO_CAPACITY,
FIELD_TYPES,
LEAF_OPERATORS,
METER_NAME,
} from "./constants/index.js";
export {
configInvalid,
cyclicDependency,
duplicateId,
FORM_BUILDER_CODES,
type FormBuilderCode,
FormError,
type FormErrorContext,
fieldNotFound,
sectionNotFound,
validationFailed,
} from "./errors/form-error/form-error.js";
export {
defaultFieldRegistry,
FieldRegistry,
type FieldTypeDescriptor,
} from "./fields/field-registry/field-registry.js";
export {
_resetInstrumentsForTests,
fieldMutatedCounter,
submissionDurationHistogram,
submissionTotalCounter,
validationDurationHistogram,
validationRunCounter,
} from "./otel/instruments/instruments.js";
export {
parseFieldDefinition,
parseFormSchema,
parseFormSection,
schemas,
} from "./schema/schema/schema.js";
export {
deserialiseForm,
SERIALISE_VERSION,
serialiseForm,
} from "./serialise/serialise/serialise.js";
export { FormStateMachine } from "./state/form-state/form-state.js";
export { type UndoOp, UndoRedoStack } from "./state/undo-redo/undo-redo.js";
export {
type SubmitOptions,
type Submitter,
submit,
type Transformer,
} from "./submission/submission/submission.js";
export type {
ConditionalRule,
FieldDefinition,
FieldType,
FormEventBus,
FormEventMap,
FormSchema,
FormSection,
FormState,
FormValues,
LeafOperator,
LegacyConditionalRule,
SelectOption,
ValidationError,
ValidationRule,
ValidationRuleType,
} from "./types/form.types.js";
export {
runFieldValidation,
runFormValidation,
} from "./validation/validation/validation.js";
export {
type CustomRulePredicate,
runRule,
} from "./validation/validators/validators.js";
export { toZodSchema } from "./zod-schema/zod-schema/zod-schema.js";

View File

@@ -0,0 +1,8 @@
export {
_resetInstrumentsForTests,
fieldMutatedCounter,
submissionDurationHistogram,
submissionTotalCounter,
validationDurationHistogram,
validationRunCounter,
} from "./instruments.js";

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,122 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
type CollectionResult,
MeterProvider,
MetricReader,
type ResourceMetrics,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { FormBuilder } from "../../builder/form-builder/form-builder.js";
import { submit } from "../../submission/submission/submission.js";
import {
_resetInstrumentsForTests,
fieldMutatedCounter,
submissionDurationHistogram,
submissionTotalCounter,
validationDurationHistogram,
validationRunCounter,
} from "./instruments.js";
class CollectingReader extends MetricReader {
constructor() {
super({
aggregationTemporalitySelector: () =>
AggregationTemporality.CUMULATIVE,
});
}
protected override async onForceFlush(): Promise<void> {}
protected override async onShutdown(): Promise<void> {}
async snapshot(): Promise<CollectionResult | undefined> {
return this.collect();
}
}
let reader: CollectingReader;
let provider: MeterProvider;
beforeEach(() => {
metrics.disable();
reader = new CollectingReader();
provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
_resetInstrumentsForTests();
});
afterEach(async () => {
await provider.shutdown();
});
async function collect(): Promise<ResourceMetrics[]> {
const r = await reader.snapshot();
return r ? [r.resourceMetrics] : [];
}
describe("otel instruments", () => {
it("exposes the five instruments", { timeout: 5000 }, () => {
expect(fieldMutatedCounter()).toBeDefined();
expect(validationRunCounter()).toBeDefined();
expect(submissionTotalCounter()).toBeDefined();
expect(validationDurationHistogram()).toBeDefined();
expect(submissionDurationHistogram()).toBeDefined();
});
it("every instrument records at least one observation", {
timeout: 30000,
}, async () => {
const builder = new FormBuilder();
builder.addField({
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
});
builder.updateField("name", { label: "Renamed" });
builder.removeField("name");
builder.addField({
id: "email",
name: "email",
label: "Email",
type: "email",
validations: [{ type: "required" }],
});
await builder.validateForm({ email: "a@b.co" });
await submit({
fields: builder.getSchema().fields,
values: { email: "a@b.co" },
submitter: async () => undefined,
});
// Force an invalid submission to record `result: invalid`.
await submit({
fields: builder.getSchema().fields,
values: { email: "" },
submitter: async () => undefined,
}).catch(() => undefined);
// Force a failed submission to record `result: failed`.
await submit({
fields: builder.getSchema().fields,
values: { email: "x@x.io" },
submitter: async () => {
throw new Error("boom");
},
}).catch(() => undefined);
const collected = await collect();
const all = collected
.flatMap((r) => r.scopeMetrics)
.flatMap((s) => s.metrics);
const names = all.map((m) => m.descriptor.name);
expect(names).toContain("form_builder.field.mutated_total");
expect(names).toContain("form_builder.validation.run_total");
expect(names).toContain("form_builder.submission.total");
expect(names).toContain("form_builder.validation.duration_ms");
expect(names).toContain("form_builder.submission.duration_ms");
});
});

View File

@@ -0,0 +1,94 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { METER_NAME } from "../../constants/index.js";
/**
* Lazily resolves the meter and five OTel instruments emitted by the
* package. Resolving at first call lets tests install a custom
* `MeterProvider` after import and still observe production emissions.
*/
let _meter: Meter | undefined;
let _fieldMutated: Counter | undefined;
let _validationRun: Counter | undefined;
let _submissionTotal: Counter | undefined;
let _validationDuration: Histogram | undefined;
let _submissionDuration: Histogram | undefined;
function meter(): Meter {
if (!_meter) _meter = metrics.getMeter(METER_NAME);
return _meter;
}
/** Counter: incremented on every schema-mutation operation. */
export function fieldMutatedCounter(): Counter {
if (!_fieldMutated) {
_fieldMutated = meter().createCounter(
"form_builder.field.mutated_total",
{
description:
"Count of field/section mutations performed via FormBuilder.",
}
);
}
return _fieldMutated;
}
/** Counter: incremented on every validation invocation. */
export function validationRunCounter(): Counter {
if (!_validationRun) {
_validationRun = meter().createCounter(
"form_builder.validation.run_total",
{ description: "Count of validation invocations." }
);
}
return _validationRun;
}
/** Counter: incremented on every submission attempt. */
export function submissionTotalCounter(): Counter {
if (!_submissionTotal) {
_submissionTotal = meter().createCounter(
"form_builder.submission.total",
{ description: "Count of submission attempts." }
);
}
return _submissionTotal;
}
/** Histogram: time spent in `runFieldValidation` / `runFormValidation`. */
export function validationDurationHistogram(): Histogram {
if (!_validationDuration) {
_validationDuration = meter().createHistogram(
"form_builder.validation.duration_ms",
{
description: "Validation execution duration.",
unit: "ms",
}
);
}
return _validationDuration;
}
/** Histogram: time spent in the submission pipeline. */
export function submissionDurationHistogram(): Histogram {
if (!_submissionDuration) {
_submissionDuration = meter().createHistogram(
"form_builder.submission.duration_ms",
{
description: "Submission pipeline duration.",
unit: "ms",
}
);
}
return _submissionDuration;
}
/** Test-only: reset all cached instruments. */
export function _resetInstrumentsForTests(): void {
_meter = undefined;
_fieldMutated = undefined;
_validationRun = undefined;
_submissionTotal = undefined;
_validationDuration = undefined;
_submissionDuration = undefined;
}

View File

@@ -0,0 +1,6 @@
export {
parseFieldDefinition,
parseFormSchema,
parseFormSection,
schemas,
} from "./schema.js";

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,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FormSchema } from "../../types/form.types.js";
import {
parseFieldDefinition,
parseFormSchema,
parseFormSection,
schemas,
} from "./schema.js";
const NOW = "2026-01-01T00:00:00.000Z";
function validSchema(overrides: Partial<FormSchema> = {}): FormSchema {
return {
id: "f1",
name: "form one",
version: 1,
fields: [{ id: "a", name: "a", label: "A", type: "text" }],
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
describe("parseFormSchema", () => {
it("parses a valid schema", { timeout: 5000 }, () => {
const result = parseFormSchema(validSchema());
expect(result.id).toBe("f1");
expect(result.fields).toHaveLength(1);
});
it("rejects empty name", { timeout: 5000 }, () => {
try {
parseFormSchema(validSchema({ name: "" }));
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.CONFIG_INVALID
);
}
});
it("rejects duplicate field ids", { timeout: 5000 }, () => {
const schema = validSchema({
fields: [
{ id: "a", name: "a", label: "A", type: "text" },
{ id: "a", name: "a2", label: "A2", type: "text" },
],
});
expect(() => parseFormSchema(schema)).toThrow(FormError);
});
it("rejects invalid timestamps", { timeout: 5000 }, () => {
expect(() =>
parseFormSchema(validSchema({ createdAt: "not-a-date" }))
).toThrow(FormError);
});
it("rejects unknown top-level keys", { timeout: 5000 }, () => {
expect(() =>
parseFormSchema({ ...validSchema(), extra: 1 } as unknown)
).toThrow(FormError);
});
});
describe("parseFieldDefinition", () => {
it("accepts a minimal field", { timeout: 5000 }, () => {
const f = parseFieldDefinition({
id: "a",
name: "a",
label: "A",
type: "text",
});
expect(f.type).toBe("text");
});
it("rejects unknown field type", { timeout: 5000 }, () => {
expect(() =>
parseFieldDefinition({
id: "a",
name: "a",
label: "A",
type: "rocket",
})
).toThrow(FormError);
});
it("accepts conditional rules and nested fields", {
timeout: 5000,
}, () => {
const f = parseFieldDefinition({
id: "g",
name: "g",
label: "G",
type: "group",
conditional: {
kind: "and",
rules: [
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
{
kind: "or",
rules: [
{
kind: "leaf",
field: "b",
operator: "eq",
value: 2,
},
{
kind: "not",
rule: {
kind: "leaf",
field: "c",
operator: "eq",
value: 3,
},
},
],
},
],
},
fields: [{ id: "nested", name: "n", label: "N", type: "text" }],
validations: [{ type: "required" }],
options: [{ label: "A", value: "a" }],
});
expect(f.id).toBe("g");
});
});
describe("parseFormSection", () => {
it("accepts a minimal section", { timeout: 5000 }, () => {
const s = parseFormSection({ id: "s", title: "S" });
expect(s.id).toBe("s");
});
it("rejects an empty id", { timeout: 5000 }, () => {
expect(() => parseFormSection({ id: "", title: "x" })).toThrow(
FormError
);
});
});
describe("schemas namespace", () => {
it("exposes every sub-schema", { timeout: 5000 }, () => {
expect(schemas.formSchema).toBeDefined();
expect(schemas.fieldDefinition).toBeDefined();
expect(schemas.formSection).toBeDefined();
expect(schemas.validationRule).toBeDefined();
expect(schemas.conditionalRule).toBeDefined();
expect(schemas.selectOption).toBeDefined();
});
});

View File

@@ -0,0 +1,218 @@
import z from "zod";
import { configInvalid } from "../../errors/form-error/form-error.js";
import type {
FieldDefinition,
FormSchema,
FormSection,
} from "../../types/form.types.js";
function formatZodIssues(issues: readonly z.core.$ZodIssue[]): string {
// Zod guarantees `issues.length >= 1` whenever safeParse fails.
return (issues[0] as z.core.$ZodIssue).message;
}
const fieldTypeSchema = z.enum([
"text",
"number",
"email",
"url",
"tel",
"password",
"select",
"multiselect",
"checkbox",
"radio",
"textarea",
"date",
"file",
"range",
"hidden",
"group",
"array",
]);
const leafOperatorSchema = z.enum([
"eq",
"neq",
"in",
"notIn",
"gt",
"lt",
"gte",
"lte",
"contains",
]);
const validationRuleTypeSchema = z.enum([
"required",
"minLength",
"maxLength",
"min",
"max",
"pattern",
"email",
"url",
"oneOf",
"equalsField",
"custom",
]);
const validationRuleSchema = z
.object({
type: validationRuleTypeSchema,
value: z.unknown().optional(),
message: z.string().min(1).optional(),
})
.strict();
const selectOptionSchema = z
.object({
label: z.string().min(1),
value: z.string(),
disabled: z.boolean().optional(),
})
.strict();
const legacyConditionalSchema = z
.object({
field: z.string().min(1),
operator: leafOperatorSchema,
value: z.unknown(),
})
.strict();
const conditionalLeafSchema = z
.object({
kind: z.literal("leaf"),
field: z.string().min(1),
operator: leafOperatorSchema,
value: z.unknown(),
})
.strict();
const conditionalRuleSchema: z.ZodType = z.lazy(() =>
z.union([
legacyConditionalSchema,
conditionalLeafSchema,
z
.object({
kind: z.literal("and"),
rules: z.array(conditionalRuleSchema).min(1),
})
.strict(),
z
.object({
kind: z.literal("or"),
rules: z.array(conditionalRuleSchema).min(1),
})
.strict(),
z
.object({
kind: z.literal("not"),
rule: conditionalRuleSchema,
})
.strict(),
])
);
const fieldDefinitionSchema: z.ZodType = z.lazy(() =>
z
.object({
id: z.string().min(1),
name: z.string().min(1),
label: z.string().min(1),
type: fieldTypeSchema,
placeholder: z.string().optional(),
defaultValue: z.unknown().optional(),
options: z.array(selectOptionSchema).optional(),
validations: z.array(validationRuleSchema).optional(),
conditional: conditionalRuleSchema.optional(),
order: z.number().int().optional(),
section: z.string().min(1).optional(),
description: z.string().optional(),
disabled: z.boolean().optional(),
hidden: z.boolean().optional(),
fields: z.array(fieldDefinitionSchema).optional(),
})
.strict()
);
const formSectionSchema = z
.object({
id: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
order: z.number().int().optional(),
})
.strict();
const isoDateString = z.string().refine(
(s) => {
const t = Date.parse(s);
return Number.isFinite(t);
},
{ message: "must be an ISO-8601 timestamp" }
);
const formSchemaShape = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
version: z.number().int().min(1),
fields: z.array(fieldDefinitionSchema),
sections: z.array(formSectionSchema).optional(),
submitLabel: z.string().min(1).optional(),
createdAt: isoDateString,
updatedAt: isoDateString,
})
.strict();
/**
* Validate an arbitrary value against the canonical {@link FormSchema}
* shape and check uniqueness of field ids. Throws a `FormError` carrying
* `FORM_BUILDER.CONFIG_INVALID` on failure.
*/
export function parseFormSchema(raw: unknown): FormSchema {
const parsed = formSchemaShape.safeParse(raw);
if (!parsed.success) {
throw configInvalid(formatZodIssues(parsed.error.issues));
}
const data = parsed.data as unknown as FormSchema;
const ids = new Set<string>();
for (const field of data.fields) {
if (ids.has(field.id)) {
throw configInvalid(`duplicate field id "${field.id}"`);
}
ids.add(field.id);
}
return data;
}
/** Parse and return a single `FieldDefinition`. */
export function parseFieldDefinition(raw: unknown): FieldDefinition {
const parsed = fieldDefinitionSchema.safeParse(raw);
if (!parsed.success) {
throw configInvalid(formatZodIssues(parsed.error.issues));
}
return parsed.data as unknown as FieldDefinition;
}
/** Parse and return a single `FormSection`. */
export function parseFormSection(raw: unknown): FormSection {
const parsed = formSectionSchema.safeParse(raw);
if (!parsed.success) {
throw configInvalid(formatZodIssues(parsed.error.issues));
}
return parsed.data as unknown as FormSection;
}
/** Re-exported schemas for downstream consumers wanting to compose. */
export const schemas = {
fieldDefinition: fieldDefinitionSchema,
formSchema: formSchemaShape,
formSection: formSectionSchema,
validationRule: validationRuleSchema,
conditionalRule: conditionalRuleSchema,
selectOption: selectOptionSchema,
} as const;

View File

@@ -0,0 +1,5 @@
export {
deserialiseForm,
SERIALISE_VERSION,
serialiseForm,
} from "./serialise.js";

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 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FormSchema } from "../../types/form.types.js";
import {
deserialiseForm,
SERIALISE_VERSION,
serialiseForm,
} from "./serialise.js";
const NOW = "2026-01-01T00:00:00.000Z";
const sample: FormSchema = {
id: "f1",
name: "Form 1",
version: 1,
fields: [{ id: "a", name: "a", label: "A", type: "text" }],
createdAt: NOW,
updatedAt: NOW,
};
describe("serialise / deserialise", () => {
it("round-trips a schema", { timeout: 5000 }, () => {
const wire = serialiseForm(sample);
const restored = deserialiseForm(wire);
expect(restored).toEqual(sample);
});
it("raises on malformed json", { timeout: 5000 }, () => {
try {
deserialiseForm("{not json");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.CONFIG_INVALID
);
}
});
it("rejects wrong envelope shape", { timeout: 5000 }, () => {
expect(() => deserialiseForm("[]")).toThrow(FormError);
expect(() => deserialiseForm("null")).toThrow(FormError);
expect(() =>
deserialiseForm(JSON.stringify({ schema: sample }))
).toThrow(FormError);
});
it("rejects unsupported version", { timeout: 5000 }, () => {
const wire = JSON.stringify({ $version: 99, schema: sample });
expect(() => deserialiseForm(wire)).toThrow(FormError);
});
it("exposes SERIALISE_VERSION as 1", { timeout: 5000 }, () => {
expect(SERIALISE_VERSION).toBe(1);
});
it("rejects schema with invalid inner shape", { timeout: 5000 }, () => {
const wire = JSON.stringify({
$version: 1,
schema: { ...sample, name: "" },
});
expect(() => deserialiseForm(wire)).toThrow(FormError);
});
});

View File

@@ -0,0 +1,49 @@
import { configInvalid } from "../../errors/form-error/form-error.js";
import { parseFormSchema } from "../../schema/schema/schema.js";
import type { FormSchema } from "../../types/form.types.js";
/** Wire-format version emitted by `serialiseForm`. */
export const SERIALISE_VERSION = 1 as const;
interface WireEnvelope {
readonly $version: typeof SERIALISE_VERSION;
readonly schema: FormSchema;
}
/** Serialise a schema into a JSON string carrying a wire-format version. */
export function serialiseForm(schema: FormSchema): string {
const envelope: WireEnvelope = {
$version: SERIALISE_VERSION,
schema,
};
return JSON.stringify(envelope);
}
/**
* Inverse of `serialiseForm`. Performs JSON parse + Zod validation +
* field-id uniqueness check. Throws `FORM_BUILDER.CONFIG_INVALID` on
* any failure mode (malformed JSON, wrong shape, duplicate ids, etc.).
*/
export function deserialiseForm(json: string): FormSchema {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (cause) {
throw configInvalid("malformed JSON", cause as Error);
}
if (
parsed === null ||
typeof parsed !== "object" ||
!("schema" in parsed) ||
!("$version" in parsed)
) {
throw configInvalid("missing schema or $version");
}
const envelope = parsed as Partial<WireEnvelope>;
if (envelope.$version !== SERIALISE_VERSION) {
throw configInvalid(
`unsupported wire version ${String(envelope.$version)}`
);
}
return parseFormSchema(envelope.schema);
}

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,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FormStateMachine } from "./form-state.js";
describe("FormStateMachine", () => {
it("setValue marks dirty and touched", { timeout: 5000 }, () => {
const state = new FormStateMachine({ name: "" });
state.setValue("name", "alice");
const snap = state.getState();
expect(snap.dirty).toBe(true);
expect(snap.touched.has("name")).toBe(true);
expect(snap.values.name).toBe("alice");
});
it("setValues marks every key touched", { timeout: 5000 }, () => {
const state = new FormStateMachine();
state.setValues({ a: 1, b: 2 });
const snap = state.getState();
expect(snap.touched.has("a")).toBe(true);
expect(snap.touched.has("b")).toBe(true);
expect(snap.dirty).toBe(true);
});
it("touch records visit without changing values", { timeout: 5000 }, () => {
const state = new FormStateMachine({ name: "" });
state.touch("name");
expect(state.getState().touched.has("name")).toBe(true);
expect(state.getState().dirty).toBe(false);
});
it("reset clears state", { timeout: 5000 }, () => {
const state = new FormStateMachine({ name: "alice" });
state.setValue("name", "bob");
state.reset({ name: "default" });
const snap = state.getState();
expect(snap.values.name).toBe("default");
expect(snap.dirty).toBe(false);
expect(snap.touched.size).toBe(0);
expect(snap.valid).toBe(true);
});
it("setErrors flips valid", { timeout: 5000 }, () => {
const state = new FormStateMachine();
state.setErrors([{ fieldId: "x", rule: "required", message: "no" }]);
expect(state.getState().valid).toBe(false);
state.setErrors([]);
expect(state.getState().valid).toBe(true);
});
it("getState returns an independent copy", { timeout: 5000 }, () => {
const state = new FormStateMachine({ a: 1 });
const snap = state.getState();
(snap.values as Record<string, unknown>).a = 99;
expect(state.getState().values.a).toBe(1);
});
it("constructs with empty initial values by default", {
timeout: 5000,
}, () => {
const state = new FormStateMachine();
expect(state.getState().values).toEqual({});
});
it("reset with no args clears to empty", { timeout: 5000 }, () => {
const state = new FormStateMachine({ a: 1 });
state.reset();
expect(state.getState().values).toEqual({});
});
});

View File

@@ -0,0 +1,72 @@
import type {
FormState,
FormValues,
ValidationError,
} from "../../types/form.types.js";
/**
* Mutable holder for the runtime state of a form instance.
*
* The state mirrors the upstream `FormState` shape (values, errors,
* touched, dirty, valid) and is the structure emitted on
* `values.changed` and returned from `FormBuilder.getState`.
*/
export class FormStateMachine {
private values: FormValues;
private errors: readonly ValidationError[];
private readonly touched = new Set<string>();
private dirty = false;
private valid = true;
public constructor(initial: FormValues = {}) {
this.values = { ...initial };
this.errors = [];
}
/** Update a single field's value (marks dirty + touched). */
public setValue(fieldId: string, value: unknown): void {
this.values = { ...this.values, [fieldId]: value };
this.touched.add(fieldId);
this.dirty = true;
}
/** Replace the entire value map (marks every key touched). */
public setValues(values: FormValues): void {
this.values = { ...values };
for (const key of Object.keys(values)) {
this.touched.add(key);
}
this.dirty = true;
}
/** Record that a field has been visited by the user. */
public touch(fieldId: string): void {
this.touched.add(fieldId);
}
/** Reset to the supplied defaults (or empty), clearing dirty + touched. */
public reset(defaults: FormValues = {}): void {
this.values = { ...defaults };
this.errors = [];
this.touched.clear();
this.dirty = false;
this.valid = true;
}
/** Update the cached error list and recompute `valid`. */
public setErrors(errors: readonly ValidationError[]): void {
this.errors = errors;
this.valid = errors.length === 0;
}
/** Immutable snapshot of the current state. */
public getState(): FormState {
return {
values: { ...this.values },
errors: this.errors,
touched: new Set(this.touched),
dirty: this.dirty,
valid: this.valid,
};
}
}

View File

@@ -0,0 +1 @@
export { FormStateMachine } from "./form-state.js";

View File

@@ -0,0 +1 @@
export { type UndoOp, UndoRedoStack } from "./undo-redo.js";

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,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { UndoOp } from "./undo-redo.js";
import { UndoRedoStack } from "./undo-redo.js";
function pair(state: { value: number }, delta: number): UndoOp {
const apply: UndoOp = {
apply: () => {
state.value -= delta;
},
inverse: undefined as unknown as UndoOp,
};
const inverse: UndoOp = {
apply: () => {
state.value += delta;
},
inverse: apply,
};
(apply as { inverse: UndoOp }).inverse = inverse;
return apply;
}
describe("UndoRedoStack", () => {
it("drops oldest when capacity is exceeded", { timeout: 5000 }, () => {
const stack = new UndoRedoStack(2);
const state = { value: 0 };
stack.push(pair(state, 1));
stack.push(pair(state, 2));
stack.push(pair(state, 3));
// Now only the last two ops remain — undo twice and try a third
stack.undo();
stack.undo();
expect(stack.canUndo()).toBe(false);
});
it("new push clears redo stack", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
const state = { value: 0 };
stack.push(pair(state, 1));
stack.undo();
expect(stack.canRedo()).toBe(true);
stack.push(pair(state, 5));
expect(stack.canRedo()).toBe(false);
});
it("undo and redo apply ops symmetrically", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
const state = { value: 0 };
const op = pair(state, 4);
op.inverse.apply();
expect(state.value).toBe(4);
stack.push(op);
stack.undo();
expect(state.value).toBe(0);
stack.redo();
expect(state.value).toBe(4);
});
it("undo and redo no-op when empty", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
expect(stack.undo()).toBeUndefined();
expect(stack.redo()).toBeUndefined();
});
it("canUndo and canRedo reflect stack state", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(false);
const state = { value: 0 };
stack.push(pair(state, 1));
expect(stack.canUndo()).toBe(true);
});
it("clear empties both stacks", { timeout: 5000 }, () => {
const stack = new UndoRedoStack();
const state = { value: 0 };
stack.push(pair(state, 1));
stack.undo();
stack.clear();
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(false);
});
it("rejects non-positive integer capacity", { timeout: 5000 }, () => {
expect(() => new UndoRedoStack(0)).toThrow(RangeError);
expect(() => new UndoRedoStack(1.5)).toThrow(RangeError);
expect(() => new UndoRedoStack(-1)).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,70 @@
import { DEFAULT_UNDO_CAPACITY } from "../../constants/index.js";
/**
* Op recorded on the undo stack. `apply` performs the reverse operation
* on the host; `inverse` is the forward operation pushed onto the redo
* stack when `undo` consumes this entry.
*/
export interface UndoOp {
readonly apply: () => void;
readonly inverse: UndoOp;
}
/** Bounded LIFO with redo support. */
export class UndoRedoStack {
private readonly undoStack: UndoOp[] = [];
private readonly redoStack: UndoOp[] = [];
private readonly capacity: number;
public constructor(capacity: number = DEFAULT_UNDO_CAPACITY) {
if (!Number.isInteger(capacity) || capacity < 1) {
throw new RangeError(
"UndoRedoStack capacity must be a positive integer"
);
}
this.capacity = capacity;
}
/** Push an op onto undo, clearing the redo stack. */
public push(op: UndoOp): void {
this.undoStack.push(op);
if (this.undoStack.length > this.capacity) {
this.undoStack.shift();
}
this.redoStack.length = 0;
}
/** Pop and apply the most recent undo op. */
public undo(): UndoOp | undefined {
const op = this.undoStack.pop();
if (op === undefined) return undefined;
op.apply();
this.redoStack.push(op.inverse);
return op;
}
/** Pop and apply the most recent redo op. */
public redo(): UndoOp | undefined {
const op = this.redoStack.pop();
if (op === undefined) return undefined;
op.apply();
this.undoStack.push(op.inverse);
return op;
}
/** Whether `undo()` would have effect. */
public canUndo(): boolean {
return this.undoStack.length > 0;
}
/** Whether `redo()` would have effect. */
public canRedo(): boolean {
return this.redoStack.length > 0;
}
/** Drop every op on both stacks. */
public clear(): void {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
}

View File

@@ -0,0 +1,6 @@
export {
type SubmitOptions,
type Submitter,
submit,
type Transformer,
} from "./submission.js";

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,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FORM_BUILDER_CODES,
FormError,
} from "../../errors/form-error/form-error.js";
import type { FieldDefinition } from "../../types/form.types.js";
import { submit } from "./submission.js";
const requiredText = (id: string): FieldDefinition => ({
id,
name: id,
label: id,
type: "text",
validations: [{ type: "required" }],
});
describe("submit", () => {
it("rejects when validation fails", { timeout: 5000 }, async () => {
let called = false;
try {
await submit({
fields: [requiredText("a")],
values: { a: "" },
submitter: async () => {
called = true;
},
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(FormError);
expect((err as FormError).code).toBe(
FORM_BUILDER_CODES.VALIDATION_FAILED
);
}
expect(called).toBe(false);
});
it("invokes submitter on ok path", { timeout: 5000 }, async () => {
let captured: Record<string, unknown> | undefined;
await submit({
fields: [requiredText("a")],
values: { a: "alice" },
submitter: async (values) => {
captured = values;
},
});
expect(captured?.a).toBe("alice");
});
it("applies the transformer between validate and submit", {
timeout: 5000,
}, async () => {
let captured: Record<string, unknown> | undefined;
await submit({
fields: [requiredText("a")],
values: { a: "Alice" },
transform: (v) => ({ ...v, a: String(v.a).toLowerCase() }),
submitter: async (values) => {
captured = values;
},
});
expect(captured?.a).toBe("alice");
});
it("rethrows submitter failures and emits failed counter", {
timeout: 5000,
}, async () => {
await expect(
submit({
fields: [requiredText("a")],
values: { a: "alice" },
submitter: async () => {
throw new Error("upstream");
},
})
).rejects.toThrow("upstream");
});
it("returns the submitter's result", { timeout: 5000 }, async () => {
const result = await submit<number>({
fields: [requiredText("a")],
values: { a: "x" },
submitter: async () => 42,
});
expect(result).toBe(42);
});
it("threads the signal into the submitter", { timeout: 5000 }, async () => {
const controller = new AbortController();
let received: AbortSignal | undefined;
const promise = submit({
fields: [requiredText("a")],
values: { a: "x" },
signal: controller.signal,
submitter: async (_values, signal) => {
received = signal;
},
});
await promise;
expect(received).toBe(controller.signal);
});
it("accepts an injectable clock", { timeout: 5000 }, async () => {
let n = 0;
await submit({
fields: [requiredText("a")],
values: { a: "x" },
clock: () => ++n,
submitter: async () => undefined,
});
expect(n).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,70 @@
import { validationFailed } from "../../errors/form-error/form-error.js";
import {
submissionDurationHistogram,
submissionTotalCounter,
} from "../../otel/instruments/instruments.js";
import type { FieldDefinition, FormValues } from "../../types/form.types.js";
import {
type Clock,
runFormValidation,
} from "../../validation/validation/validation.js";
/** Async predicate the submission pipeline invokes after validation. */
export type Submitter<TResult = void> = (
values: FormValues,
signal?: AbortSignal
) => Promise<TResult>;
/** Optional pure-function pre-submit transformer. */
export type Transformer = (values: FormValues) => FormValues;
export interface SubmitOptions<TResult = void> {
readonly fields: readonly FieldDefinition[];
readonly values: FormValues;
readonly submitter: Submitter<TResult>;
readonly transform?: Transformer;
readonly signal?: AbortSignal;
readonly clock?: Clock;
}
const defaultClock: Clock = () => Date.now();
/**
* `prepare → validate → transform → submit`. Throws
* `FORM_BUILDER.VALIDATION_FAILED` if validation errors are present.
* Records `form_builder.submission.total` and
* `form_builder.submission.duration_ms` with `result: ok|invalid|failed`.
*/
export async function submit<TResult = void>(
options: SubmitOptions<TResult>
): Promise<TResult> {
const clock = options.clock ?? defaultClock;
const start = clock();
const errors = await runFormValidation(
options.fields,
options.values,
options.signal,
clock
);
if (errors.length > 0) {
const elapsed = Math.max(0, clock() - start);
submissionTotalCounter().add(1, { result: "invalid" });
submissionDurationHistogram().record(elapsed, { result: "invalid" });
throw validationFailed(errors);
}
const transformed = options.transform
? options.transform(options.values)
: options.values;
try {
const result = await options.submitter(transformed, options.signal);
const elapsed = Math.max(0, clock() - start);
submissionTotalCounter().add(1, { result: "ok" });
submissionDurationHistogram().record(elapsed, { result: "ok" });
return result;
} catch (cause) {
const elapsed = Math.max(0, clock() - start);
submissionTotalCounter().add(1, { result: "failed" });
submissionDurationHistogram().record(elapsed, { result: "failed" });
throw cause;
}
}

View File

@@ -0,0 +1,156 @@
import type { EventBus } from "@suaveplan/events";
/** Closed vocabulary of built-in field types. */
export type FieldType =
| "text"
| "number"
| "email"
| "url"
| "tel"
| "password"
| "select"
| "multiselect"
| "checkbox"
| "radio"
| "textarea"
| "date"
| "file"
| "range"
| "hidden"
| "group"
| "array";
/** Built-in leaf operator vocabulary for conditional rules. */
export type LeafOperator =
| "eq"
| "neq"
| "in"
| "notIn"
| "gt"
| "lt"
| "gte"
| "lte"
| "contains";
/** Built-in rule kinds the validation engine knows how to run. */
export type ValidationRuleType =
| "required"
| "minLength"
| "maxLength"
| "min"
| "max"
| "pattern"
| "email"
| "url"
| "oneOf"
| "equalsField"
| "custom";
/** A single validation rule. `value` is interpreted per rule kind. */
export interface ValidationRule {
readonly type: ValidationRuleType;
readonly value?: unknown;
readonly message?: string;
}
/** Option used by `select`, `multiselect`, and `radio` fields. */
export interface SelectOption {
readonly label: string;
readonly value: string;
readonly disabled?: boolean;
}
/** Legacy upstream conditional shape — accepted for backwards compatibility. */
export interface LegacyConditionalRule {
readonly field: string;
readonly operator: LeafOperator;
readonly value: unknown;
}
/** Discriminated conditional rule. */
export type ConditionalRule =
| LegacyConditionalRule
| {
readonly kind: "leaf";
readonly field: string;
readonly operator: LeafOperator;
readonly value: unknown;
}
| { readonly kind: "and"; readonly rules: readonly ConditionalRule[] }
| { readonly kind: "or"; readonly rules: readonly ConditionalRule[] }
| { readonly kind: "not"; readonly rule: ConditionalRule };
/** Definition of a single form field. */
export interface FieldDefinition {
readonly id: string;
readonly name: string;
readonly label: string;
readonly type: FieldType;
readonly placeholder?: string;
readonly defaultValue?: unknown;
readonly options?: readonly SelectOption[];
readonly validations?: readonly ValidationRule[];
readonly conditional?: ConditionalRule;
readonly order?: number;
readonly section?: string;
readonly description?: string;
readonly disabled?: boolean;
readonly hidden?: boolean;
readonly fields?: readonly FieldDefinition[];
}
/** Logical grouping of fields. */
export interface FormSection {
readonly id: string;
readonly title: string;
readonly description?: string;
readonly order?: number;
}
/** Top-level schema produced by `FormBuilder`. */
export interface FormSchema {
readonly id: string;
readonly name: string;
readonly description?: string;
readonly version: number;
readonly fields: readonly FieldDefinition[];
readonly sections?: readonly FormSection[];
readonly submitLabel?: string;
readonly createdAt: string;
readonly updatedAt: string;
}
/** A single per-field validation error. */
export interface ValidationError {
readonly fieldId: string;
readonly rule: string;
readonly message: string;
}
/** Map of field id → arbitrary value. */
export type FormValues = Record<string, unknown>;
/** Form-state snapshot. */
export interface FormState {
readonly values: FormValues;
readonly errors: readonly ValidationError[];
readonly touched: ReadonlySet<string>;
readonly dirty: boolean;
readonly valid: boolean;
}
/** Typed event map for `EventBus<FormEventMap>`. */
export interface FormEventMap {
readonly "field.added": FieldDefinition;
readonly "field.removed": string;
readonly "field.updated": FieldDefinition;
readonly "section.added": FormSection;
readonly "section.removed": string;
readonly "values.changed": FormValues;
readonly "form.validated": readonly ValidationError[];
readonly "form.reset": null;
readonly [k: string]: unknown;
}
/** Convenience alias for the form-builder event bus. */
export type FormEventBus = EventBus<FormEventMap>;

View File

@@ -0,0 +1,5 @@
export {
type Clock,
runFieldValidation,
runFormValidation,
} from "./validation.js";

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,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FieldDefinition } from "../../types/form.types.js";
import { runFieldValidation, runFormValidation } from "./validation.js";
const textField = (
id: string,
validations: FieldDefinition["validations"] = []
): FieldDefinition => ({
id,
name: id,
label: id.toUpperCase(),
type: "text",
validations,
});
describe("runFieldValidation", () => {
it("aggregates per-field errors", { timeout: 5000 }, async () => {
const field = textField("name", [
{ type: "required" },
{ type: "minLength", value: 3 },
]);
const errors = await runFieldValidation(field, "", {});
expect(errors).toHaveLength(2);
});
it("honours abort signal", { timeout: 5000 }, async () => {
const controller = new AbortController();
controller.abort();
const field = textField("name", [{ type: "required" }]);
const errors = await runFieldValidation(
field,
"",
{},
controller.signal
);
expect(errors).toHaveLength(0);
});
it("awaits async custom rules", { timeout: 5000 }, async () => {
const field = textField("x", [
{ type: "custom", value: async () => false },
]);
const errors = await runFieldValidation(field, "v", {});
expect(errors).toHaveLength(1);
});
it("returns empty array when no validations are set", {
timeout: 5000,
}, async () => {
const field = textField("name");
const errors = await runFieldValidation(field, "", {});
expect(errors).toHaveLength(0);
});
it("emits counter and histogram", { timeout: 5000 }, async () => {
// Instruments are real; just exercise the path to ensure no throws.
const field = textField("name", [{ type: "required" }]);
await runFieldValidation(field, "alice", {});
});
it("stops iterating when signal aborts mid-flight", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
let count = 0;
const field = textField("x", [
{
type: "custom",
value: () => {
count++;
controller.abort();
return true;
},
},
{ type: "required" },
]);
await runFieldValidation(field, "ok", {}, controller.signal);
expect(count).toBe(1);
});
});
describe("runFormValidation", () => {
it("aggregates per-field errors", { timeout: 5000 }, async () => {
const fields = [
textField("a", [{ type: "required" }]),
textField("b", [{ type: "required" }]),
];
const errors = await runFormValidation(fields, {});
expect(errors).toHaveLength(2);
expect(errors.map((e) => e.fieldId).sort()).toEqual(["a", "b"]);
});
it("returns empty array when aborted before invocation", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const errors = await runFormValidation(
[textField("a", [{ type: "required" }])],
{},
controller.signal
);
expect(errors).toHaveLength(0);
});
it("stops mid-form on abort", { timeout: 5000 }, async () => {
const controller = new AbortController();
const fields = [
textField("a", [
{
type: "custom",
value: () => {
controller.abort();
return true;
},
},
]),
textField("b", [{ type: "required" }]),
];
const errors = await runFormValidation(
fields,
{ a: "ok" },
controller.signal
);
expect(errors).toHaveLength(0);
});
it("returns ok counter result for clean run", {
timeout: 5000,
}, async () => {
const fields = [textField("a", [{ type: "required" }])];
const errors = await runFormValidation(fields, { a: "ok" });
expect(errors).toHaveLength(0);
});
it("accepts an injectable clock", { timeout: 5000 }, async () => {
let n = 0;
const clock = () => ++n;
const fields = [textField("a", [{ type: "required" }])];
const errors = await runFormValidation(
fields,
{ a: "ok" },
undefined,
clock
);
expect(errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,77 @@
import {
validationDurationHistogram,
validationRunCounter,
} from "../../otel/instruments/instruments.js";
import type {
FieldDefinition,
FormValues,
ValidationError,
} from "../../types/form.types.js";
import { runRule } from "../validators/validators.js";
/** A `Clock` returning the current epoch-millis timestamp. */
export type Clock = () => number;
const defaultClock: Clock = () => Date.now();
/**
* Run every rule attached to a single `FieldDefinition`. Returns the
* collected errors. Honours `AbortSignal`: if the signal is aborted
* before invocation, an empty array is returned and no rule executes.
* The validation counter and histogram are emitted on every call.
*/
export async function runFieldValidation(
field: FieldDefinition,
value: unknown,
values: FormValues,
signal?: AbortSignal,
clock: Clock = defaultClock
): Promise<readonly ValidationError[]> {
if (signal?.aborted) return [];
const start = clock();
const errors: ValidationError[] = [];
const rules = field.validations ?? [];
for (const rule of rules) {
if (signal?.aborted) break;
const result = runRule(rule, value, values, field.id);
const resolved = result instanceof Promise ? await result : result;
if (resolved !== null) errors.push(resolved);
}
const elapsed = Math.max(0, clock() - start);
const result = errors.length === 0 ? "ok" : "errors";
validationRunCounter().add(1, { scope: "field", result });
validationDurationHistogram().record(elapsed, { scope: "field" });
return errors;
}
/**
* Run validation across every field in the given list. Aggregates errors
* in field order, honours `AbortSignal`, and emits the validation
* counter + histogram with `scope: "form"`.
*/
export async function runFormValidation(
fields: readonly FieldDefinition[],
values: FormValues,
signal?: AbortSignal,
clock: Clock = defaultClock
): Promise<readonly ValidationError[]> {
if (signal?.aborted) return [];
const start = clock();
const errors: ValidationError[] = [];
for (const field of fields) {
if (signal?.aborted) break;
const fieldErrors = await runFieldValidation(
field,
values[field.id],
values,
signal,
clock
);
errors.push(...fieldErrors);
}
const elapsed = Math.max(0, clock() - start);
const result = errors.length === 0 ? "ok" : "errors";
validationRunCounter().add(1, { scope: "form", result });
validationDurationHistogram().record(elapsed, { scope: "form" });
return errors;
}

View File

@@ -0,0 +1 @@
export { type CustomRulePredicate, runRule } from "./validators.js";

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,221 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
ValidationError,
ValidationRule,
} from "../../types/form.types.js";
import { runRule } from "./validators.js";
function sync(
rule: ValidationRule,
value: unknown,
values: Record<string, unknown> = {},
fieldId = "x"
): ValidationError | null {
const r = runRule(rule, value, values, fieldId);
if (r instanceof Promise) throw new Error("expected sync");
return r;
}
describe("runRule — passing paths", () => {
it("minLength passes when long enough", { timeout: 5000 }, () => {
expect(sync({ type: "minLength", value: 3 }, "abc")).toBeNull();
});
it("maxLength passes when short enough", { timeout: 5000 }, () => {
expect(sync({ type: "maxLength", value: 5 }, "abc")).toBeNull();
});
it("min passes when large enough", { timeout: 5000 }, () => {
expect(sync({ type: "min", value: 5 }, 10)).toBeNull();
});
it("max passes when small enough", { timeout: 5000 }, () => {
expect(sync({ type: "max", value: 5 }, 1)).toBeNull();
});
});
describe("runRule — required", () => {
it("required passes on present value", { timeout: 5000 }, () => {
expect(sync({ type: "required" }, "alice")).toBeNull();
});
it("required fails on empty", { timeout: 5000 }, () => {
expect(sync({ type: "required" }, "")?.rule).toBe("required");
expect(sync({ type: "required" }, null)?.rule).toBe("required");
expect(sync({ type: "required" }, undefined)?.rule).toBe("required");
expect(sync({ type: "required" }, [])?.rule).toBe("required");
});
});
describe("runRule — minLength / maxLength", () => {
it("minLength fails when shorter", { timeout: 5000 }, () => {
expect(sync({ type: "minLength", value: 3 }, "ab")?.rule).toBe(
"minLength"
);
});
it("minLength is a no-op for non-strings or missing config", {
timeout: 5000,
}, () => {
expect(sync({ type: "minLength", value: 3 }, 4)).toBeNull();
expect(sync({ type: "minLength" }, "abc")).toBeNull();
});
it("maxLength fails when longer", { timeout: 5000 }, () => {
expect(sync({ type: "maxLength", value: 2 }, "abc")?.rule).toBe(
"maxLength"
);
});
it("maxLength is a no-op for non-strings or missing config", {
timeout: 5000,
}, () => {
expect(sync({ type: "maxLength", value: 2 }, 1)).toBeNull();
expect(sync({ type: "maxLength" }, "abc")).toBeNull();
});
});
describe("runRule — min / max", () => {
it("min fails when smaller", { timeout: 5000 }, () => {
expect(sync({ type: "min", value: 5 }, 1)?.rule).toBe("min");
});
it("min is a no-op for non-numbers", { timeout: 5000 }, () => {
expect(sync({ type: "min", value: 5 }, "a")).toBeNull();
expect(sync({ type: "min" }, 5)).toBeNull();
});
it("max fails when larger", { timeout: 5000 }, () => {
expect(sync({ type: "max", value: 5 }, 10)?.rule).toBe("max");
});
it("max is a no-op for non-numbers", { timeout: 5000 }, () => {
expect(sync({ type: "max", value: 5 }, "a")).toBeNull();
expect(sync({ type: "max" }, 5)).toBeNull();
});
});
describe("runRule — pattern", () => {
it("fails when pattern does not match", { timeout: 5000 }, () => {
expect(sync({ type: "pattern", value: "^abc$" }, "xyz")?.rule).toBe(
"pattern"
);
});
it("passes when pattern matches", { timeout: 5000 }, () => {
expect(sync({ type: "pattern", value: "^abc$" }, "abc")).toBeNull();
});
it("is a no-op for non-string value or missing pattern", {
timeout: 5000,
}, () => {
expect(sync({ type: "pattern", value: "^a$" }, 1)).toBeNull();
expect(sync({ type: "pattern" }, "abc")).toBeNull();
});
});
describe("runRule — email and url", () => {
it("email fails on bad strings", { timeout: 5000 }, () => {
expect(sync({ type: "email" }, "bad")?.rule).toBe("email");
});
it("email passes on valid string", { timeout: 5000 }, () => {
expect(sync({ type: "email" }, "a@b.co")).toBeNull();
});
it("email is a no-op for non-strings", { timeout: 5000 }, () => {
expect(sync({ type: "email" }, 1)).toBeNull();
});
it("url passes on valid url", { timeout: 5000 }, () => {
expect(sync({ type: "url" }, "https://example.com")).toBeNull();
});
it("url fails on garbage", { timeout: 5000 }, () => {
expect(sync({ type: "url" }, "not a url")?.rule).toBe("url");
});
it("url is a no-op for non-strings", { timeout: 5000 }, () => {
expect(sync({ type: "url" }, 1)).toBeNull();
});
});
describe("runRule — oneOf", () => {
it("fails when value not in list", { timeout: 5000 }, () => {
expect(sync({ type: "oneOf", value: ["a", "b"] }, "c")?.rule).toBe(
"oneOf"
);
});
it("passes when value is allowed", { timeout: 5000 }, () => {
expect(sync({ type: "oneOf", value: ["a", "b"] }, "a")).toBeNull();
});
it("is a no-op when value is not an array", { timeout: 5000 }, () => {
expect(sync({ type: "oneOf", value: "nope" }, "a")).toBeNull();
});
});
describe("runRule — equalsField", () => {
it("equalsField detects mismatch", { timeout: 5000 }, () => {
expect(
sync(
{ type: "equalsField", value: "password" },
"abc",
{ password: "xyz" },
"confirm"
)?.rule
).toBe("equalsField");
});
it("equalsField passes when equal", { timeout: 5000 }, () => {
expect(
sync(
{ type: "equalsField", value: "password" },
"abc",
{ password: "abc" },
"confirm"
)
).toBeNull();
});
it("is a no-op when value is not a string id", { timeout: 5000 }, () => {
expect(
sync({ type: "equalsField", value: 5 }, "x", { password: "y" })
).toBeNull();
});
});
describe("runRule — custom", () => {
it("custom rule passes when predicate is true", { timeout: 5000 }, () => {
expect(sync({ type: "custom", value: () => true }, "x")).toBeNull();
});
it("custom rule fails when predicate is false", { timeout: 5000 }, () => {
expect(sync({ type: "custom", value: () => false }, "x")?.rule).toBe(
"custom"
);
});
it("custom rule supports async", { timeout: 5000 }, async () => {
const r = runRule(
{ type: "custom", value: async () => false },
"x",
{},
"x"
);
const resolved = r instanceof Promise ? await r : r;
expect(resolved?.rule).toBe("custom");
});
it("custom rule async resolves to null on pass", {
timeout: 5000,
}, async () => {
const r = runRule(
{ type: "custom", value: async () => true },
"x",
{},
"x"
);
const resolved = r instanceof Promise ? await r : r;
expect(resolved).toBeNull();
});
it("custom is a no-op when value is not a function", {
timeout: 5000,
}, () => {
expect(sync({ type: "custom", value: 1 }, "x")).toBeNull();
});
});
describe("runRule — unknown", () => {
it("returns null on unknown rule kind", { timeout: 5000 }, () => {
const result = sync(
{ type: "nope" as unknown as ValidationRule["type"] },
"x"
);
expect(result).toBeNull();
});
});
describe("runRule — custom message", () => {
it("uses custom message when provided", { timeout: 5000 }, () => {
const err = sync({ type: "required", message: "Please fill" }, "");
expect(err?.message).toBe("Please fill");
});
});

View File

@@ -0,0 +1,134 @@
import type {
FormValues,
ValidationError,
ValidationRule,
} from "../../types/form.types.js";
/** Custom-rule predicate signature. May be sync or async. */
export type CustomRulePredicate = (
value: unknown,
values: FormValues
) => boolean | Promise<boolean>;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isPresent(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string" && value.length === 0) return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
}
function error(
fieldId: string,
rule: ValidationRule,
fallback: string
): ValidationError {
return {
fieldId,
rule: rule.type,
message: rule.message ?? fallback,
};
}
/**
* Execute a single validation rule against a candidate `value`. Returns
* `null` when the rule passes; otherwise a `ValidationError`. `custom`
* rules may return a `Promise<boolean>`, in which case the returned
* value is also a promise.
*/
export function runRule(
rule: ValidationRule,
value: unknown,
values: FormValues,
fieldId: string
): ValidationError | null | Promise<ValidationError | null> {
switch (rule.type) {
case "required":
if (!isPresent(value))
return error(fieldId, rule, "Field is required");
return null;
case "minLength": {
if (typeof value !== "string") return null;
if (typeof rule.value !== "number") return null;
if (value.length < rule.value)
return error(
fieldId,
rule,
`Must be at least ${rule.value} characters`
);
return null;
}
case "maxLength": {
if (typeof value !== "string") return null;
if (typeof rule.value !== "number") return null;
if (value.length > rule.value)
return error(
fieldId,
rule,
`Must be at most ${rule.value} characters`
);
return null;
}
case "min": {
if (typeof value !== "number") return null;
if (typeof rule.value !== "number") return null;
if (value < rule.value)
return error(fieldId, rule, `Must be ≥ ${rule.value}`);
return null;
}
case "max": {
if (typeof value !== "number") return null;
if (typeof rule.value !== "number") return null;
if (value > rule.value)
return error(fieldId, rule, `Must be ≤ ${rule.value}`);
return null;
}
case "pattern": {
if (typeof value !== "string") return null;
if (typeof rule.value !== "string") return null;
if (!new RegExp(rule.value).test(value))
return error(fieldId, rule, "Does not match pattern");
return null;
}
case "email":
if (typeof value !== "string") return null;
if (!EMAIL_RE.test(value))
return error(fieldId, rule, "Not a valid email");
return null;
case "url":
if (typeof value !== "string") return null;
try {
new URL(value);
return null;
} catch {
return error(fieldId, rule, "Not a valid URL");
}
case "oneOf": {
if (!Array.isArray(rule.value)) return null;
if (!rule.value.includes(value))
return error(fieldId, rule, "Value is not allowed");
return null;
}
case "equalsField": {
if (typeof rule.value !== "string") return null;
const other = values[rule.value];
if (other !== value)
return error(fieldId, rule, `Must equal field "${rule.value}"`);
return null;
}
case "custom": {
if (typeof rule.value !== "function") return null;
const predicate = rule.value as CustomRulePredicate;
const result = predicate(value, values);
if (result instanceof Promise) {
return result.then((ok) =>
ok ? null : error(fieldId, rule, "Custom rule failed")
);
}
return result ? null : error(fieldId, rule, "Custom rule failed");
}
default:
return null;
}
}

View File

@@ -0,0 +1 @@
export { toZodSchema } from "./zod-schema.js";

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.

View File

@@ -0,0 +1,324 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FormSchema } from "../../types/form.types.js";
import { toZodSchema } from "./zod-schema.js";
const NOW = "2026-01-01T00:00:00.000Z";
function form(fields: FormSchema["fields"]): FormSchema {
return {
id: "f",
name: "F",
version: 1,
fields,
createdAt: NOW,
updatedAt: NOW,
};
}
describe("toZodSchema", () => {
it("enforces required", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
},
])
);
expect(schema.safeParse({}).success).toBe(false);
expect(schema.safeParse({ name: "alice" }).success).toBe(true);
});
it("respects conditional required", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{ id: "a", name: "a", label: "A", type: "text" },
{
id: "b",
name: "b",
label: "B",
type: "text",
validations: [{ type: "required" }],
conditional: {
kind: "leaf",
field: "a",
operator: "eq",
value: "yes",
},
},
])
);
expect(schema.safeParse({ a: "no" }).success).toBe(true);
expect(schema.safeParse({ a: "yes" }).success).toBe(false);
});
it("applies number min and max", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "age",
name: "age",
label: "Age",
type: "number",
validations: [
{ type: "min", value: 18 },
{ type: "max", value: 65 },
],
},
])
);
expect(schema.safeParse({ age: 12 }).success).toBe(false);
expect(schema.safeParse({ age: 70 }).success).toBe(false);
expect(schema.safeParse({ age: 30 }).success).toBe(true);
});
it("applies string min, max, email, url, pattern", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [
{ type: "minLength", value: 2 },
{ type: "maxLength", value: 5 },
],
},
{
id: "email",
name: "email",
label: "Email",
type: "email",
validations: [{ type: "email" }],
},
{
id: "site",
name: "site",
label: "Site",
type: "url",
validations: [{ type: "url" }],
},
{
id: "code",
name: "code",
label: "Code",
type: "text",
validations: [{ type: "pattern", value: "^[a-z]+$" }],
},
])
);
expect(schema.safeParse({ name: "x" }).success).toBe(false);
expect(schema.safeParse({ name: "abcdef" }).success).toBe(false);
expect(schema.safeParse({ name: "abc", email: "bad" }).success).toBe(
false
);
expect(
schema.safeParse({
name: "abc",
email: "a@b.co",
site: "https://x.io",
code: "abc",
}).success
).toBe(true);
});
it("applies oneOf via refine", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "color",
name: "color",
label: "Color",
type: "select",
validations: [{ type: "oneOf", value: ["red", "blue"] }],
},
])
);
expect(schema.safeParse({ color: "green" }).success).toBe(false);
expect(schema.safeParse({ color: "red" }).success).toBe(true);
});
it("supports equalsField via superRefine", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "password",
name: "password",
label: "Password",
type: "password",
},
{
id: "confirm",
name: "confirm",
label: "Confirm",
type: "password",
validations: [{ type: "equalsField", value: "password" }],
},
])
);
expect(schema.safeParse({ password: "a", confirm: "b" }).success).toBe(
false
);
expect(schema.safeParse({ password: "a", confirm: "a" }).success).toBe(
true
);
});
it("validates checkbox, multiselect, group, range, array", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{ id: "a", name: "a", label: "A", type: "checkbox" },
{ id: "b", name: "b", label: "B", type: "multiselect" },
{ id: "c", name: "c", label: "C", type: "group" },
{
id: "d",
name: "d",
label: "D",
type: "range",
validations: [{ type: "min", value: 0 }],
},
{ id: "e", name: "e", label: "E", type: "array" },
])
);
expect(
schema.safeParse({
a: true,
b: ["x"],
c: { k: 1 },
d: 5,
e: [1, 2],
}).success
).toBe(true);
});
it("respects conditional required with explicit empty value", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required", message: "Need it" }],
},
])
);
const result = schema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("uses default required message when not provided", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
},
])
);
const result = schema.safeParse({ name: null });
expect(result.success).toBe(false);
});
it("skips min/max with non-number value", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "n",
name: "n",
label: "N",
type: "number",
validations: [
{ type: "min", value: "x" },
{ type: "max", value: null },
],
},
])
);
expect(schema.safeParse({ n: 100 }).success).toBe(true);
});
it("skips minLength/maxLength/pattern with non-string value", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "s",
name: "s",
label: "S",
type: "text",
validations: [
{ type: "minLength", value: "x" },
{ type: "maxLength", value: null },
{ type: "pattern", value: 99 },
],
},
])
);
expect(schema.safeParse({ s: "abc" }).success).toBe(true);
});
it("skips oneOf with a non-array value", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "x",
name: "x",
label: "X",
type: "select",
validations: [{ type: "oneOf", value: "nope" }],
},
])
);
expect(schema.safeParse({ x: "anything" }).success).toBe(true);
});
it("skips equalsField with a non-string value", { timeout: 5000 }, () => {
const schema = toZodSchema(
form([
{
id: "a",
name: "a",
label: "A",
type: "text",
validations: [{ type: "equalsField", value: 5 }],
},
])
);
expect(schema.safeParse({ a: "x" }).success).toBe(true);
});
it("treats empty array as missing for required", {
timeout: 5000,
}, () => {
const schema = toZodSchema(
form([
{
id: "tags",
name: "tags",
label: "Tags",
type: "multiselect",
validations: [{ type: "required" }],
},
])
);
expect(schema.safeParse({ tags: [] }).success).toBe(false);
expect(schema.safeParse({ tags: ["x"] }).success).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import z from "zod";
import { evaluateConditional } from "../../conditional/conditional/conditional.js";
import type {
FieldDefinition,
FormSchema,
FormValues,
ValidationRule,
} from "../../types/form.types.js";
function ruleHasType(
rules: readonly ValidationRule[] | undefined,
type: ValidationRule["type"]
): ValidationRule | undefined {
return rules?.find((r) => r.type === type);
}
function buildFieldSchema(field: FieldDefinition): z.ZodTypeAny {
const rules = field.validations ?? [];
let base: z.ZodTypeAny;
switch (field.type) {
case "number":
case "range": {
let n: z.ZodNumber = z.number();
const min = ruleHasType(rules, "min");
if (min && typeof min.value === "number") n = n.min(min.value);
const max = ruleHasType(rules, "max");
if (max && typeof max.value === "number") n = n.max(max.value);
base = n;
break;
}
case "checkbox":
base = z.boolean();
break;
case "multiselect":
case "array":
base = z.array(z.unknown());
break;
case "group":
base = z.record(z.string(), z.unknown());
break;
default: {
let s: z.ZodString = z.string();
const minLen = ruleHasType(rules, "minLength");
if (minLen && typeof minLen.value === "number")
s = s.min(minLen.value);
const maxLen = ruleHasType(rules, "maxLength");
if (maxLen && typeof maxLen.value === "number")
s = s.max(maxLen.value);
const email = ruleHasType(rules, "email");
if (email) s = s.email();
const url = ruleHasType(rules, "url");
if (url) s = s.url();
const pattern = ruleHasType(rules, "pattern");
if (pattern && typeof pattern.value === "string")
s = s.regex(new RegExp(pattern.value));
base = s;
break;
}
}
const oneOf = ruleHasType(rules, "oneOf");
if (oneOf && Array.isArray(oneOf.value)) {
const allowed = oneOf.value as readonly unknown[];
base = base.refine((v) => allowed.includes(v), {
message: oneOf.message ?? "Value is not allowed",
});
}
return base;
}
/**
* Generate a `z.ZodObject` whose `safeParse` matches `runFormValidation`
* for the canonical 11 rule kinds (where statically expressible). A
* conditional `required` is applied via `superRefine`: the field is
* required only when its `conditional` rule evaluates to `true` against
* the candidate `values`.
*/
export function toZodSchema(form: FormSchema): z.ZodType<FormValues> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of form.fields) {
shape[field.id] = buildFieldSchema(field).optional();
}
const object = z.object(shape);
return object.superRefine((values, ctx) => {
for (const field of form.fields) {
if (field.conditional !== undefined) {
const visible = evaluateConditional(
field.conditional,
values as FormValues
);
if (!visible) continue;
}
const isRequired = field.validations?.some(
(r) => r.type === "required"
);
if (isRequired) {
const v = (values as FormValues)[field.id];
const empty =
v === undefined ||
v === null ||
v === "" ||
(Array.isArray(v) && v.length === 0);
if (empty) {
ctx.addIssue({
code: "custom",
message:
field.validations?.find(
(r) => r.type === "required"
)?.message ?? "Field is required",
path: [field.id],
});
}
}
const equalsField = field.validations?.find(
(r) => r.type === "equalsField"
);
if (
equalsField &&
typeof equalsField.value === "string" &&
(values as FormValues)[field.id] !==
(values as FormValues)[equalsField.value]
) {
ctx.addIssue({
code: "custom",
message:
equalsField.message ??
`Must equal field "${equalsField.value}"`,
path: [field.id],
});
}
}
}) as unknown as z.ZodType<FormValues>;
}

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/form-builder",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/types/**",
"src/constants/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});