Files
2026-05-27 23:29:53 +01:00

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 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"