Files
suaveplan/openspec/archive/2026-05-25-enterprise-form-builder/design.md
2026-05-27 23:29:53 +01:00

6.5 KiB

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

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:

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.