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,351 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: form-builder
**Package**: `@suaveplan/form-builder`
**Status**: SHIPPED (2026-05-25, change `enterprise-form-builder`)
> Every requirement below carries a `**Verified by:**` line.
## 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"`