16 KiB
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
parseFormSchemais given a well-formedFormSchema - THEN the returned object structurally equals the input and
freezeis applied to top-level arrays
Scenario: empty name fails
- WHEN
parseFormSchema({ id: "x", name: "", version: 1, fields: [], createdAt: now, updatedAt: now }) - THEN a
FormErroris thrown withcode === "FORM_BUILDER.CONFIG_INVALID"
Scenario: duplicate field id fails
- WHEN
parseFormSchemais given two fields with the sameid - THEN a
FormErroris thrown withcode === "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
FieldRegistryis constructed - THEN
list().length === 17andhas("text") === true
Scenario: custom field-type registration
- WHEN a caller
register("rating", { defaultValue: 0 }) - THEN
has("rating") === trueandget("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
ValidationErrorwithrule === "required"
Scenario: equalsField compares two field values
- WHEN
runRule({ type: "equalsField", value: "password" }, "x", { password: "y" }, "confirm") - THEN the return value is a
ValidationErrorwithrule === "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
AbortSignalis aborted beforerunFormValidationis invoked - THEN the returned array is empty and no rules are executed
Scenario: errors aggregate per field
- WHEN
runFormValidationruns 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
andrule with three children evaluates and the first child is false - THEN the return value is
falseand 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
bdepends onaand fieldcdepends onb - THEN
topoSort()returns an order in whichaprecedesbprecedesc
Scenario: cycle raises CYCLIC_DEPENDENCY
- WHEN field
adepends onband fieldbdepends ona - THEN
topoSort()throws aFormErrorwithcode === "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 freshFormState - THEN
state.getState().dirty === trueandstate.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
UndoRedoStackwith capacity 2 receives three pushes - THEN the first pushed op is no longer recoverable via
undo()
Scenario: new push clears redo
- WHEN an
UndoRedoStackhas one undone op and a newpusharrives - THEN
redo()returnsundefined
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
Setinstances, which are JSON-rebuilt asSet)
Scenario: malformed JSON raises CONFIG_INVALID
- WHEN
deserialiseForm("{not json")is called - THEN a
FormErroris thrown withcode === "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 }andtoZodSchema(form).safeParse({}) - THEN
safeParsereturnssuccess === false
Scenario: conditional required is optional when condition is false
- WHEN
brequiresa === "yes"andsafeParse({ 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
submitis 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
submitis called with valid values - THEN the
submitteris 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
FormErrorwithcode === "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
FormErrorwithcode === "FORM_BUILDER.FIELD_NOT_FOUND"
Scenario: getVisibleFields hides on false conditional
- WHEN field
bdeclares{ field: "a", operator: "eq", value: "yes" }andvalues.a === "no" - THEN
getVisibleFields(values)excludesb
Scenario: setValues emits values.changed
- WHEN a listener subscribes to
values.changedandsetValues({ a: 1 })is called - THEN the listener is invoked exactly once with
{ a: 1 }
Scenario: undo after addField restores previous state
- WHEN
enableHistory()thenaddField(f)thenundo()are invoked - THEN
getSchema().fieldsis 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.tsruns - 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
createdAtandupdatedAtderive from12345
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
MeterProvideris 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"