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,IdGeneratorfor 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.