Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
# Design: enterprise-form-builder
> Non-normative. Architecture, file layout, and integration narrative for
> `@suaveplan/form-builder`. The normative contract is `./specs/form-builder/spec.md`.
## Tier & dependencies
Universal-tier (runs in Bun, Node, browser without polyfills). Direct deps:
- `@suaveplan/error` (`workspace:^`) — `SuaveplanError`, `defineCodes`.
- `@suaveplan/events` (`workspace:^`) — `EventBus<FormEventMap>` for state-change emission.
- `@suaveplan/types` (`workspace:^`) — `Clock`, `IdGenerator` for injectable time / id.
- `@opentelemetry/api` (`catalog:observability`) — counters + histograms.
- `tslib` (`catalog:build`).
- `zod` (`catalog:forms`) — boundary validation + Zod schema generator.
`devDependencies`: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `vite`, `vite-plugin-dts`,
`vitest`, `typescript`, `@types/node`.
## File layout
```text
packages/ui/forms/form-builder/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
tests/setup/polyfills.ts
src/
index.ts # root barrel (named re-exports only)
types/form.types.ts # shared structural types (no .md)
constants/index.ts # FIELD_TYPES, OPERATORS, defaults
schema/schema/
schema.ts # Zod schemas + parse/validate
schema.test.ts
schema.md
index.ts
fields/field-registry/
field-registry.ts # FieldRegistry + 17 built-ins
field-registry.test.ts
field-registry.md
index.ts
validation/validation/
validation.ts # runRule, runField, runForm (sync + async)
validation.test.ts
validation.md
index.ts
validation/validators/
validators.ts # 11 built-in rule runners
validators.test.ts
validators.md
index.ts
conditional/conditional/
conditional.ts # operator + boolean evaluator
conditional.test.ts
conditional.md
index.ts
conditional/dependency-graph/
dependency-graph.ts # topo sort + cycle detect
dependency-graph.test.ts
dependency-graph.md
index.ts
state/form-state/
form-state.ts # FormState machine
form-state.test.ts
form-state.md
index.ts
state/undo-redo/
undo-redo.ts # bounded history stack
undo-redo.test.ts
undo-redo.md
index.ts
builder/form-builder/
form-builder.ts # FormBuilder class (entry-point)
form-builder.test.ts
form-builder.md
index.ts
submission/submission/
submission.ts # submit pipeline
submission.test.ts
submission.md
index.ts
serialise/serialise/
serialise.ts # JSON round-trip
serialise.test.ts
serialise.md
index.ts
zod-schema/zod-schema/
zod-schema.ts # FormSchema → ZodObject
zod-schema.test.ts
zod-schema.md
index.ts
errors/form-error/
form-error.ts # defineCodes + FormError
form-error.test.ts
form-error.md
index.ts
otel/instruments/
instruments.ts # lazy-init counters + histograms
instruments.test.ts
instruments.md
index.ts
```
## Error codes — `FORM_BUILDER.*`
| Code | When |
|---|---|
| `FORM_BUILDER.FIELD_NOT_FOUND` | `getField`, `removeField`, `updateField` on missing id |
| `FORM_BUILDER.SECTION_NOT_FOUND` | `getSectionById`, `removeSection` on missing id |
| `FORM_BUILDER.DUPLICATE_ID` | `addField` / `addSection` with already-registered id |
| `FORM_BUILDER.CONFIG_INVALID` | Zod parse failure on `FormSchema` or empty `id`/`name` |
| `FORM_BUILDER.VALIDATION_FAILED` | `submit` aborted because validation produced errors |
| `FORM_BUILDER.CYCLIC_DEPENDENCY` | `DependencyGraph.topoSort` detects a cycle |
## Conditional logic
`ConditionalRule` is a discriminated union:
```ts
type ConditionalRule =
| { kind: "leaf"; field: string; operator: LeafOperator; value: unknown }
| { kind: "and"; rules: readonly ConditionalRule[] }
| { kind: "or"; rules: readonly ConditionalRule[] }
| { kind: "not"; rule: ConditionalRule };
```
Leaf operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `contains`.
A `FieldDefinition.conditional` may be a single rule (backwards-compatible with the upstream
`{ field, operator, value }` shape; the deserialiser auto-wraps in a `leaf`).
## Dependency graph
`DependencyGraph` extracts every referenced field id from a `ConditionalRule` (and from
`equalsField` validators), forms a DAG keyed by field id, runs Kahn's topo sort, and emits
`CYCLIC_DEPENDENCY` if a cycle is found. The builder evaluates conditional visibility in topo
order so chains (`a → b → c`) settle in one pass.
## Undo / redo
Schema-mutation operations (`addField`, `removeField`, `updateField`, `addSection`,
`removeSection`) push a reverse-op onto a bounded `UndoRedoStack` (default capacity 100). `undo()`
applies the inverse and pushes the forward-op onto the redo stack; any new mutation clears the redo
stack. The stack is opt-in: `FormBuilder` exposes `enableHistory(capacity?)`.
## Zod schema generator
`toZodSchema(form)` walks `form.fields` and emits a `z.ZodObject` whose shape mirrors the field
types and validation rules. Conditional `required` is folded into the schema via
`z.unknown().superRefine` so consumers using `react-hook-form` or `@hookform/resolvers/zod` get
identical behaviour to the schema engine's `validateForm`.
## OTel instruments
Lazily initialised via `metrics.getMeter("@suaveplan/form-builder")`:
| Instrument | Kind | Attributes |
|---|---|---|
| `form_builder.field.mutated_total` | counter | `op: add\|remove\|update` |
| `form_builder.validation.run_total` | counter | `scope: field\|form`, `result: ok\|errors` |
| `form_builder.submission.total` | counter | `result: ok\|invalid\|failed` |
| `form_builder.validation.duration_ms` | histogram | `scope: field\|form` |
| `form_builder.submission.duration_ms` | histogram | `result: ok\|invalid\|failed` |
## Determinism
No `Date.now()` or `Math.random()` at module scope. The builder takes an optional `Clock` and
`IdGenerator` in its constructor; defaults derive from `@suaveplan/types`. Timestamps on
`FormSchema.createdAt` / `updatedAt` come from the injected clock.

View File

@@ -0,0 +1,75 @@
# Proposal: Enterprise migration of `@suaveplan/form-builder`
- **Status**: PROPOSED
- **Package**: `@suaveplan/form-builder`
- **Category / plugin**: `ui` / `forms`
- **Tier**: universal (zero browser, zero React, zero Node)
- **Design**: ./design.md
- **Spec delta**: ./specs/form-builder/spec.md
## Why
`@suaveplan/form-builder` is the canonical universal-tier schema engine for dynamic forms in the
Suaveplan monorepo. The upstream copy at `../suaveplan/packages/universal/form-builder` ships only
ten field types, a thin synchronous validation surface (eight built-in rules), a single conditional
operator family (`eq` / `neq` / `in`), no dependency graph, no undo/redo, no submission pipeline,
no Zod schema generator, and no OpenTelemetry instrumentation. The renderer-agnostic schema engine
is expected to drive every form surface in the monorepo (admin panels, marketing onboarding flows,
the internal vault wizard), and the current surface is too thin to serve them. This migration ports
the package onto `genesis` at enterprise quality: full field-type registry, async validation,
cross-field validators, dependency-graph driven re-evaluation, undo/redo, JSON serialisation
round-trip, Zod schema generation, OTel instruments, and a stable error-code namespace.
## What changes
- New package `packages/ui/forms/form-builder/` at `0.1.0`.
- Zod-validated public schema (`FormSchema`, `FieldDefinition`, `FormSection`, `ValidationRule`,
`ConditionalRule`).
- Extensible `FieldRegistry` with 17 built-in field types: `text`, `number`, `email`, `url`,
`tel`, `password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`, `date`, `file`,
`range`, `hidden`, `group`, `array`.
- Sync + async validation pipeline with `AbortSignal` propagation and 11 built-in rule kinds
(`required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`, `oneOf`,
`equalsField`, `custom`).
- Conditional logic with 9 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.
- Undo / redo on schema-mutation operations.
- Form-state machine emitting typed events through `EventBus<FormEventMap>` from
`@suaveplan/events`.
- JSON serialise / deserialise round-trip with versioned wire format.
- Zod schema generator: `toZodSchema(form)`.
- Submission pipeline: `prepare → validate → transform → submit` with injectable `Submitter`.
- Six stable `FORM_BUILDER.*` error codes via `defineCodes`.
- OTel instruments: three counters + two histograms.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `FormBuilder` class | ✅ | ✅ | Public API preserved. |
| Field types | ✅ (10) | ⬆️ (17) | Added `url`, `tel`, `password`, `multiselect`, `range`, `group`, `array`. |
| Validation rules | ✅ (8) | ⬆️ (11) | Added `url`, `oneOf`, `equalsField`. Async + cross-field validators added. |
| Conditional operators | ✅ (3) | ⬆️ (9 + boolean) | Added `notIn`, `gt`, `lt`, `gte`, `lte`, `contains`, plus `and` / `or` / `not`. |
| Sections | ✅ | ✅ | Plus `removeSection`. |
| Event bus | ✅ | ✅ | Migrated from `EventEmitter` to `EventBus<FormEventMap>` from `@suaveplan/events`. |
| Error codes | ✅ | ⬆️ | Migrated from `BaseError` to `defineCodes("FORM_BUILDER", …)`. |
| Dependency graph + topo eval | 🚫 | ⬆️ | New: required for chained conditional fields. |
| Undo / redo | 🚫 | ⬆️ | New: bounded mutation-history stack. |
| JSON round-trip | partial | ⬆️ | `serialiseForm` / `deserialiseForm` with Zod validation. |
| Zod schema generator | 🚫 | ⬆️ | New: `toZodSchema(form)` for `react-hook-form` consumers. |
| Submission pipeline | 🚫 | ⬆️ | New: pluggable `Submitter` with hooks. |
| OTel instrumentation | 🚫 | ⬆️ | New: 3 counters + 2 histograms. |
| Field registry (extensibility) | 🚫 | ⬆️ | New: downstream packages can contribute field types. |
## Impact
### Affected dependents
None on `genesis` (form-builder is a leaf at migration time). Downstream integration with
`@suaveplan/react-forms` is a follow-up change. Upstream main-branch consumers remain on the
unmigrated copy until their own migration land.
### Risk
Low. New package in a leaf position. Public API behaviour is exercised at 100% coverage.

View File

@@ -0,0 +1,351 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: form-builder (enterprise-form-builder)
**Package**: `@suaveplan/form-builder`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Zod-validated form schema parsing
The package SHALL expose `parseFormSchema(raw)` that validates an unknown input against the
canonical Zod schema for `FormSchema` and SHALL throw `FormError` with code
`FORM_BUILDER.CONFIG_INVALID` when validation fails. The Zod schema SHALL enforce non-empty `id`
and `name`, integer `version >= 1`, ISO-8601 `createdAt` and `updatedAt`, and unique field ids.
#### Scenario: valid schema parses
- **WHEN** `parseFormSchema` is given a well-formed `FormSchema`
- **THEN** the returned object structurally equals the input and `freeze` is applied to top-level
arrays
#### Scenario: empty name fails
- **WHEN** `parseFormSchema({ id: "x", name: "", version: 1, fields: [], createdAt: now, updatedAt: now })`
- **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"`
#### Scenario: duplicate field id fails
- **WHEN** `parseFormSchema` is given two fields with the same `id`
- **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"`
**Verified by:** `src/schema/schema/schema.test.ts::"parses a valid schema"` AND `src/schema/schema/schema.test.ts::"rejects empty name"` AND `src/schema/schema/schema.test.ts::"rejects duplicate field ids"`
---
### Requirement: Extensible field-type registry
The package SHALL expose a `FieldRegistry` that registers, at construction, all 17 built-in field
types (`text`, `number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`, `checkbox`,
`radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`, `array`), and SHALL allow
consumers to `register(type, descriptor)`, `unregister(type)`, `has(type)`, `get(type)`, and
`list()`.
#### Scenario: built-ins available
- **WHEN** a fresh `FieldRegistry` is constructed
- **THEN** `list().length === 17` and `has("text") === true`
#### Scenario: custom field-type registration
- **WHEN** a caller `register("rating", { defaultValue: 0 })`
- **THEN** `has("rating") === true` and `get("rating")?.defaultValue === 0`
**Verified by:** `src/fields/field-registry/field-registry.test.ts::"exposes 17 built-in field types"` AND `src/fields/field-registry/field-registry.test.ts::"accepts custom field-type registration"`
---
### Requirement: Eleven built-in validation rules
The package SHALL provide rule runners for `required`, `minLength`, `maxLength`, `min`, `max`,
`pattern`, `email`, `url`, `oneOf`, `equalsField`, and `custom`. Each runner SHALL accept the
candidate `value`, the full form `values` record, and an optional `AbortSignal`, returning either
`null` (passed) or a `ValidationError` shaped `{ fieldId, rule, message }`. `custom` rules MAY
return a `Promise<ValidationError | null>`.
#### Scenario: required passes on present string
- **WHEN** `runRule({ type: "required" }, "alice", {})` is invoked
- **THEN** the return value is `null`
#### Scenario: required fails on empty string
- **WHEN** `runRule({ type: "required" }, "", {})` is invoked
- **THEN** the return value is a `ValidationError` with `rule === "required"`
#### Scenario: equalsField compares two field values
- **WHEN** `runRule({ type: "equalsField", value: "password" }, "x", { password: "y" }, "confirm")`
- **THEN** the return value is a `ValidationError` with `rule === "equalsField"`
#### Scenario: custom async rule resolves to error
- **WHEN** a custom rule returns `Promise.resolve({ fieldId, rule: "custom", message: "no" })`
- **THEN** the returned promise resolves to that `ValidationError`
**Verified by:** `src/validation/validators/validators.test.ts::"required passes on present value"` AND `src/validation/validators/validators.test.ts::"required fails on empty"` AND `src/validation/validators/validators.test.ts::"equalsField detects mismatch"` AND `src/validation/validators/validators.test.ts::"custom rule supports async"`
---
### Requirement: Field and form validation with OTel + AbortSignal
The package SHALL expose `runFieldValidation(field, value, values, signal?)` and
`runFormValidation(fields, values, signal?)` that iterate the rules, honour `AbortSignal`
cancellation (resolving to `[]` when aborted), and emit the
`form_builder.validation.run_total` counter and `form_builder.validation.duration_ms` histogram
on every call.
#### Scenario: aborted form validation returns no errors
- **WHEN** an `AbortSignal` is aborted before `runFormValidation` is invoked
- **THEN** the returned array is empty and no rules are executed
#### Scenario: errors aggregate per field
- **WHEN** `runFormValidation` runs over two fields each with one failing rule
- **THEN** the returned array has length 2 with one entry per field
**Verified by:** `src/validation/validation/validation.test.ts::"honours abort signal"` AND `src/validation/validation/validation.test.ts::"aggregates per-field errors"` AND `src/validation/validation/validation.test.ts::"emits counter and histogram"`
---
### Requirement: Conditional logic engine with 9 leaf operators and boolean composition
The package SHALL evaluate `ConditionalRule` values against current form values. The leaf shape
SHALL support operators `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, and `contains`. The
boolean shapes `and`, `or`, and `not` SHALL compose sub-rules. The legacy upstream shape
`{ field, operator, value }` SHALL be accepted and treated as a `leaf` rule.
#### Scenario: leaf eq evaluates strictly equal
- **WHEN** `evaluateConditional({ kind: "leaf", field: "x", operator: "eq", value: 1 }, { x: 1 })`
- **THEN** the return value is `true`
#### Scenario: and short-circuits on first false
- **WHEN** an `and` rule with three children evaluates and the first child is false
- **THEN** the return value is `false` and no further children are evaluated (verified by spy)
#### Scenario: legacy shape is accepted
- **WHEN** `evaluateConditional({ field: "x", operator: "eq", value: 1 }, { x: 1 })`
- **THEN** the return value is `true`
**Verified by:** `src/conditional/conditional/conditional.test.ts::"leaf eq evaluates equal"` AND `src/conditional/conditional/conditional.test.ts::"and short-circuits"` AND `src/conditional/conditional/conditional.test.ts::"accepts legacy leaf shape"`
---
### Requirement: Dependency graph with topological sort and cycle detection
The package SHALL build a `DependencyGraph` from a field list by inspecting each field's
`conditional` rule and `equalsField` validators, producing a DAG keyed by field id. `topoSort()`
SHALL return a stable ordering. If a cycle is detected, the call SHALL throw `FormError` with
code `FORM_BUILDER.CYCLIC_DEPENDENCY`.
#### Scenario: linear chain topo sorts in order
- **WHEN** field `b` depends on `a` and field `c` depends on `b`
- **THEN** `topoSort()` returns an order in which `a` precedes `b` precedes `c`
#### Scenario: cycle raises CYCLIC_DEPENDENCY
- **WHEN** field `a` depends on `b` and field `b` depends on `a`
- **THEN** `topoSort()` throws a `FormError` with `code === "FORM_BUILDER.CYCLIC_DEPENDENCY"`
**Verified by:** `src/conditional/dependency-graph/dependency-graph.test.ts::"topo-sorts linear chain"` AND `src/conditional/dependency-graph/dependency-graph.test.ts::"throws on cycle"`
---
### Requirement: Form state machine
The package SHALL expose a `FormState` machine with `setValue(field, value)`,
`setValues(values)`, `touch(field)`, `reset(defaults?)`, `getState()` returning
`{ values, errors, touched, dirty, valid }`. `dirty` SHALL flip true on any value mutation and
flip false on `reset`. `valid` SHALL reflect the most recently computed validation result.
#### Scenario: setValue marks the field touched and dirty
- **WHEN** `state.setValue("name", "alice")` is called on a fresh `FormState`
- **THEN** `state.getState().dirty === true` and `state.getState().touched.has("name")`
#### Scenario: reset clears values and dirty
- **WHEN** `state.reset({ name: "default" })` is called after a mutation
- **THEN** `state.getState().values.name === "default"`, `state.getState().dirty === false`,
`state.getState().touched.size === 0`
**Verified by:** `src/state/form-state/form-state.test.ts::"setValue marks dirty and touched"` AND `src/state/form-state/form-state.test.ts::"reset clears state"`
---
### Requirement: Bounded undo/redo stack
The package SHALL expose `UndoRedoStack` that records reverse-ops with a bounded capacity
(default `DEFAULT_UNDO_CAPACITY === 100`). `push(op)` SHALL drop the oldest entry when capacity
is exceeded and clear the redo stack. `undo()` and `redo()` SHALL return the inverse / forward op
respectively, or `undefined` when empty.
#### Scenario: capacity bound drops oldest
- **WHEN** an `UndoRedoStack` with capacity 2 receives three pushes
- **THEN** the first pushed op is no longer recoverable via `undo()`
#### Scenario: new push clears redo
- **WHEN** an `UndoRedoStack` has one undone op and a new `push` arrives
- **THEN** `redo()` returns `undefined`
**Verified by:** `src/state/undo-redo/undo-redo.test.ts::"drops oldest when capacity is exceeded"` AND `src/state/undo-redo/undo-redo.test.ts::"new push clears redo stack"`
---
### Requirement: JSON serialise / deserialise round-trip
The package SHALL expose `serialiseForm(schema)` returning a versioned JSON string, and
`deserialiseForm(json)` returning a validated `FormSchema`. Round-trip equality SHALL hold for
any schema produced by `FormBuilder`. `deserialiseForm` SHALL raise
`FORM_BUILDER.CONFIG_INVALID` on malformed input.
#### Scenario: round-trip preserves structure
- **WHEN** a schema is serialised then deserialised
- **THEN** the deserialised value deep-equals the original (excluding `Set` instances, which
are JSON-rebuilt as `Set`)
#### Scenario: malformed JSON raises CONFIG_INVALID
- **WHEN** `deserialiseForm("{not json")` is called
- **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"`
**Verified by:** `src/serialise/serialise/serialise.test.ts::"round-trips a schema"` AND `src/serialise/serialise/serialise.test.ts::"raises on malformed json"`
---
### Requirement: Zod schema generator
The package SHALL expose `toZodSchema(form)` returning a `z.ZodObject` whose validation behaviour
matches `runFormValidation` for the canonical 11 rule kinds. The generated schema SHALL respect
conditional `required` (a field is required only when its conditional evaluates true).
#### Scenario: required field is required in zod
- **WHEN** a schema declares `name: text { required }` and `toZodSchema(form).safeParse({})`
- **THEN** `safeParse` returns `success === false`
#### Scenario: conditional required is optional when condition is false
- **WHEN** `b` requires `a === "yes"` and `safeParse({ a: "no" })`
- **THEN** `success === true`
**Verified by:** `src/zod-schema/zod-schema/zod-schema.test.ts::"enforces required"` AND `src/zod-schema/zod-schema/zod-schema.test.ts::"respects conditional required"`
---
### Requirement: Submission pipeline
The package SHALL expose `submit({ form, values, submitter, signal?, transform? })` that runs
`validate → optional transform → submitter(values)`. If validation produces errors, the call
SHALL reject with `FORM_BUILDER.VALIDATION_FAILED`. `signal` SHALL be threaded into the
submitter. The submission counter and histogram SHALL be emitted with `result: ok|invalid|failed`.
#### Scenario: invalid values short-circuit
- **WHEN** `submit` is called with values that fail required validation
- **THEN** the returned promise rejects with `code === "FORM_BUILDER.VALIDATION_FAILED"`
#### Scenario: ok path calls submitter once
- **WHEN** `submit` is called with valid values
- **THEN** the `submitter` is invoked exactly once with the (possibly transformed) values
**Verified by:** `src/submission/submission/submission.test.ts::"rejects when validation fails"` AND `src/submission/submission/submission.test.ts::"invokes submitter on ok path"`
---
### Requirement: FormBuilder aggregate API
The package SHALL expose `FormBuilder` with `createSchema`, `addField`, `removeField`,
`updateField`, `addSection`, `removeSection`, `getField`, `getSectionById`, `getSchema`,
`validateField`, `validateForm`, `getVisibleFields`, `setValues`, `resetForm`, `getState`,
`toJSON`, `enableHistory`, `undo`, `redo`, and `submit`. The builder SHALL emit typed events on
`EventBus<FormEventMap>` for `field.added`, `field.removed`, `field.updated`, `section.added`,
`section.removed`, `values.changed`, `form.validated`, and `form.reset`.
#### Scenario: addField then duplicate raises DUPLICATE_ID
- **WHEN** the same field id is added twice
- **THEN** the second call throws `FormError` with `code === "FORM_BUILDER.DUPLICATE_ID"`
#### Scenario: removeField on missing id raises FIELD_NOT_FOUND
- **WHEN** `removeField("nope")` is called on a fresh builder
- **THEN** the call throws `FormError` with `code === "FORM_BUILDER.FIELD_NOT_FOUND"`
#### Scenario: getVisibleFields hides on false conditional
- **WHEN** field `b` declares `{ field: "a", operator: "eq", value: "yes" }` and `values.a === "no"`
- **THEN** `getVisibleFields(values)` excludes `b`
#### Scenario: setValues emits values.changed
- **WHEN** a listener subscribes to `values.changed` and `setValues({ a: 1 })` is called
- **THEN** the listener is invoked exactly once with `{ a: 1 }`
#### Scenario: undo after addField restores previous state
- **WHEN** `enableHistory()` then `addField(f)` then `undo()` are invoked
- **THEN** `getSchema().fields` is empty
**Verified by:** `src/builder/form-builder/form-builder.test.ts::"raises DUPLICATE_ID on duplicate addField"` AND `src/builder/form-builder/form-builder.test.ts::"raises FIELD_NOT_FOUND on missing removeField"` AND `src/builder/form-builder/form-builder.test.ts::"hides field when conditional is false"` AND `src/builder/form-builder/form-builder.test.ts::"emits values.changed on setValues"` AND `src/builder/form-builder/form-builder.test.ts::"undo reverses addField"`
---
### Requirement: Stable error-code namespace
The package SHALL expose a `FORM_BUILDER_CODES` table built with `defineCodes("FORM_BUILDER", …)`
covering exactly `FIELD_NOT_FOUND`, `SECTION_NOT_FOUND`, `DUPLICATE_ID`, `CONFIG_INVALID`,
`VALIDATION_FAILED`, and `CYCLIC_DEPENDENCY`. Every code SHALL be reachable from a real call
site exercised in tests.
#### Scenario: every code is reachable
- **WHEN** the test suite for `form-error.ts` runs
- **THEN** at least one factory or builder method is asserted to throw with each of the six codes
**Verified by:** `src/errors/form-error/form-error.test.ts::"every code is reachable from a factory"`
---
### Requirement: Universal-tier deterministic boundary
The package SHALL import zero browser DOM APIs, zero React APIs, and zero Node-specific globals.
Construction SHALL accept optional `Clock` and `IdGenerator` so consumers can pin timestamps and
ids for snapshot tests. Defaults derive from `@suaveplan/types`.
#### Scenario: injected clock controls createSchema timestamps
- **WHEN** `new FormBuilder({ clock: () => 12345 }).createSchema("a", "b")` is called
- **THEN** the returned schema's `createdAt` and `updatedAt` derive from `12345`
**Verified by:** `src/builder/form-builder/form-builder.test.ts::"uses injected clock for timestamps"`
---
### Requirement: OpenTelemetry instruments
The package SHALL lazily initialise five OTel instruments via
`metrics.getMeter("@suaveplan/form-builder")`: counters
`form_builder.field.mutated_total`, `form_builder.validation.run_total`,
`form_builder.submission.total`; histograms `form_builder.validation.duration_ms` and
`form_builder.submission.duration_ms`. Each instrument SHALL be reachable from a test that
installs a real `MeterProvider` from `@opentelemetry/sdk-metrics`.
#### Scenario: every instrument records at least one observation
- **WHEN** an in-memory `MeterProvider` is installed and the public API is exercised end-to-end
- **THEN** every named instrument records at least one observation
**Verified by:** `src/otel/instruments/instruments.test.ts::"every instrument records at least one observation"`

View File

@@ -0,0 +1,123 @@
# Tasks — enterprise-form-builder
> Tick each box as work lands. Every requirement in `specs/form-builder/spec.md` is reflected
> below. **Never silently skip a requirement because "tests already pass without it."**
## 1. Scaffold
- [ ] Create `packages/ui/forms/form-builder/` (package.json `0.1.0`, tsconfig.lib.json,
vite.config.ts, vitest.config.ts, tests/setup/polyfills.ts).
- [ ] Add canonical subpath exports in `package.json` for every feature module.
- [ ] Add catalog deps (`@opentelemetry/api`, `zod`, `tslib`).
## 2. Types & constants
- [ ] `src/types/form.types.ts` — `FieldType`, `FieldDefinition`, `FormSection`, `FormSchema`,
`FormValues`, `FormState`, `FormEventMap`, `ValidationRule`, `ValidationError`,
`ConditionalRule`, `SelectOption`, `LeafOperator`.
- [ ] `src/constants/index.ts` — `FIELD_TYPES`, `LEAF_OPERATORS`, `BUILT_IN_RULES`,
`DEFAULT_UNDO_CAPACITY`.
## 3. Errors
- [ ] `src/errors/form-error/form-error.ts` — `FORM_BUILDER_CODES` (`defineCodes`), `FormError`
extends `SuaveplanError<FormBuilderCode>`; factory helpers `fieldNotFound`,
`sectionNotFound`, `duplicateId`, `configInvalid`, `validationFailed`, `cyclicDependency`.
- [ ] `form-error.test.ts` — every factory + code reachable.
## 4. Schema (Zod boundary)
- [ ] `src/schema/schema/schema.ts` — Zod schemas for `FieldDefinition`, `FormSection`,
`FormSchema`, `ValidationRule`, `ConditionalRule`; `parseFormSchema(raw)`.
- [ ] `schema.test.ts` — valid + invalid inputs raise `FORM_BUILDER.CONFIG_INVALID`.
## 5. Field registry
- [ ] `src/fields/field-registry/field-registry.ts` — `FieldRegistry` class, 17 built-in field
types registered out-of-the-box; `register`, `unregister`, `has`, `get`, `list`.
- [ ] `field-registry.test.ts`.
## 6. Validators
- [ ] `src/validation/validators/validators.ts` — 11 rule runners
(`required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`, `oneOf`,
`equalsField`, `custom`); async-friendly contract via `Promise<ValidationError | null>`.
- [ ] `validators.test.ts`.
## 7. Validation engine
- [ ] `src/validation/validation/validation.ts` — `runFieldValidation`, `runFormValidation`,
`AbortSignal` propagation, OTel counter + histogram emission.
- [ ] `validation.test.ts`.
## 8. Conditional logic
- [ ] `src/conditional/conditional/conditional.ts` — `evaluateConditional(rule, values)`; 9 leaf
operators + `and` / `or` / `not`.
- [ ] `conditional.test.ts`.
## 9. Dependency graph
- [ ] `src/conditional/dependency-graph/dependency-graph.ts` — `DependencyGraph`: build from
fields, topo sort, cycle detection (`FORM_BUILDER.CYCLIC_DEPENDENCY`).
- [ ] `dependency-graph.test.ts`.
## 10. State + undo/redo
- [ ] `src/state/form-state/form-state.ts` — `FormState` machine: `setValue`, `setValues`,
`touch`, `reset`, dirty + valid flags.
- [ ] `src/state/undo-redo/undo-redo.ts` — bounded `UndoRedoStack`.
- [ ] `form-state.test.ts`, `undo-redo.test.ts`.
## 11. Serialise + Zod-schema generator
- [ ] `src/serialise/serialise/serialise.ts` — `serialiseForm` / `deserialiseForm`; versioned
wire format with Zod validation on `deserialiseForm`.
- [ ] `src/zod-schema/zod-schema/zod-schema.ts` — `toZodSchema(form)`.
- [ ] Round-trip + cross-check tests.
## 12. Submission
- [ ] `src/submission/submission/submission.ts` — `submit({ form, values, submitter, signal })`;
`prepare → validate → transform → submit`; OTel counter + histogram emission.
- [ ] `submission.test.ts`.
## 13. OTel instruments
- [ ] `src/otel/instruments/instruments.ts` — lazy `Counter`s + `Histogram`s.
- [ ] `instruments.test.ts` — every instrument emits at least once under a real
`MeterProvider` from `@opentelemetry/sdk-metrics`.
## 14. Builder
- [ ] `src/builder/form-builder/form-builder.ts` — `FormBuilder<TEvents>` aggregating the above;
`createSchema`, `addField`, `removeField`, `updateField`, `addSection`, `removeSection`,
`getField`, `getSchema`, `validateField`, `validateForm`, `getVisibleFields`, `setValues`,
`resetForm`, `getState`, `toJSON`, `getSectionById`, `enableHistory`, `undo`, `redo`,
`submit`. EventBus emission.
- [ ] `form-builder.test.ts` — every behaviour scenario from the spec delta.
## 15. README + barrel
- [ ] Package `README.md` (purpose, install, full feature list, examples).
- [ ] `src/index.ts` — explicit named re-exports per §6a.
## 16. Pre-publish gate
- [ ] `bunx turbo lint typecheck test build --filter=@suaveplan/form-builder` green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/form-builder` — 100 / 100 / 100 / 100.
- [ ] `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/ui/forms/form-builder/src/` zero.
- [ ] `grep -rnE "biome-ignore" packages/ui/forms/form-builder/src/` zero.
## 17. Refresh & archive
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
- [ ] Flip `@suaveplan/form-builder` row in `STATUS.md` from `–` to `✅`; update counter
(+1 migrated / -1 pending).
- [ ] Fold `specs/form-builder/spec.md` into `openspec/specs/form-builder/spec.md`.
- [ ] Move change folder to `openspec/archive/2026-05-25-enterprise-form-builder/`.
## 18. Commit
- [ ] Branch `feat/migrate-form-builder`; conventional commit; Claude co-author footer; **do
not push**.