Unified Form Orchestration

createFormSchemaOrchestrator() is the bridge between a database declaration and a form definition. It walks one expression tree, extracts every collection(), compiles those collections with createSchema(), and replaces the collection declarations in the visual tree with lightweight render_collection references.

That gives you one place to define:

  • the fields you store,
  • field defaults and validation,
  • the form element each field prefers,
  • step order and progress behavior,
  • conditional visibility,
  • repeatable record groups,
  • mutually exclusive paths.

It returns separate database and UI outputs so each consumer receives only what it needs.

Neither orchestration nor SchemaBoundary() requires a multi-step form. createFormSchemaOrchestrator([projects]) is a valid single-collection, single-form definition. SchemaBoundary(meta, projects) adds feature ownership only. Use FormStep() and the other layout containers only when the UI flow actually needs them.

End-to-end example

import {
  Enums,
  bd,
  collection,
  createFormSchemaOrchestrator,
  layoutContainers,
} from '@businessdash/sdk'

const {
  FormStep,
  ConditionalBlock,
  MultiPopulator,
  OrCondition,
} = layoutContainers

const ProjectKind = Enums(['standard', 'hardware'] as const)

export const onboarding = createFormSchemaOrchestrator(
  [
    FormStep(
      'project',
      {
        showTitle: true,
        helperText: 'Start with the project details.',
      },
      collection('projects', {
        fields: {
          title: bd
            .text()
            .required()
            .formElement('short_text')
            .validate({ minLength: 3, maxLength: 120 }),
          kind: bd
            .enum(ProjectKind)
            .required()
            .formElement('radio_group'),
          budget: bd
            .number()
            .optional()
            .formElement('currency')
            .validate({ positive: true, round: 2 }),
        },
      }),
    ),

    ConditionalBlock(
      {
        matchStrategy: 'AND',
        rules: [
          {
            fieldRef: 'project.kind',
            op: 'equals',
            values: ['hardware'],
          },
        ],
        helperText: 'Hardware details appear only for hardware projects.',
      },
      MultiPopulator(
        {
          max_entries: 5,
          button_label: 'Add hardware node',
        },
        collection('hardware_nodes', {
          fields: {
            serialNumber: bd.text().required().private(),
            busSpeed: bd.measurement({
              category: 'storageSpeed',
              unitPicker: 'fixed',
              default_unit: 'MHz',
              resolve_to_your_unit: 'MT/s',
              allow_decimals: true,
            }),
          },
        }),
      ),
    ),

    OrCondition(
      {
        showTitle: true,
        button_label: 'Choose delivery path',
      },
      [
        {
          name: 'Remote',
          helperText: 'No site visit is required.',
          nodes: [
            collection('remote_deliveries', {
              fields: {
                meetingUrl: bd.url().required().formElement('url'),
              },
            }),
          ],
        },
        {
          name: 'On site',
          nodes: [
            collection('site_visits', {
              fields: {
                address: bd.address({
                  require_postal_code: true,
                  restrict_countries: ['US'],
                }).required(),
              },
            }),
          ],
        },
      ],
    ),
  ],
  {
    defaultIdType: 'uuid',
    consecutive: true,
    progress_style: 'progress_bar',
  },
)

The compiler returns:

onboarding.databaseSchema
onboarding.uiFormTree
onboarding.generatedForms
onboarding.formControlSettings
onboarding.featureTags

generatedForms contains one optional form draft per compiled collection, including generated many-to-many junction collections. A FormStep that renders several collections also contributes a composite candidate that creates those records together and links compatible in-form relationships by action alias. Each field uses an explicit .formElement() choice when present and otherwise the first compatible recommendation. Every candidate includes an inactive-safe create_records action.

The expression tree

Collections can be top-level nodes or children of layout containers. The walk is recursive:

FormStep
└── collection("projects")

ConditionalBlock
└── MultiPopulator
    └── collection("hardware_nodes")

OrCondition
├── Remote
│   └── collection("remote_deliveries")
└── On site
    └── collection("site_visits")

After compilation, the same visual tree remains, but each collection declaration becomes:

{
  type: 'render_collection',
  collectionId: 'project',
}

collectionId is the normalized singular name. projects becomes project; hardware_nodes becomes hardwareNode.

FormStep

FormStep(
  name,
  {
    showTitle?: boolean,
    helperText?: string,
  },
  ...children,
)

A step groups child collections or containers into one sequential part of a form.

FormStep(
  'contact_details',
  {
    showTitle: true,
    helperText: 'How should we reach you?',
  },
  contactCollection,
)

FormStep alone does not force step-by-step navigation. Set consecutive: true in the orchestrator options to request sequential behavior.

ConditionalBlock

ConditionalBlock(
  {
    matchStrategy: 'AND' | 'OR',
    rules: Array<{
      fieldRef: string,
      op: 'equals' | 'not_equals' | 'any_of' | 'none_of',
      values: unknown[],
    }>,
    showTitle?: boolean,
    helperText?: string,
  },
  ...children,
)

Field references use collection.field:

{
  fieldRef: 'project.kind',
  op: 'equals',
  values: ['hardware'],
}

The orchestrator validates that:

  • the path has exactly two segments,
  • the normalized collection exists,
  • the field exists on that collection.

It does not currently validate that every value in values matches the field's runtime type.

ConditionalBlock controls the visibility of a subtree. Use bd.*.requiredIf() when one field remains in the form but becomes required because another value changed.

MultiPopulator

MultiPopulator(
  {
    max_entries?: number,
    button_label?: string,
    showTitle?: boolean,
    helperText?: string,
    output?:
      | { strategy: 'separateTable', collectionId?: string }
      | { strategy: 'jsonb', fieldRef: string },
    entryFields?: Record<string, FieldBuilder>,
  },
  ...children,
)

A multi-populator describes an array of repeated entries. It also declares whether those entries become rows in a secondary collection or one JSONB array.

Secondary-table entries

Put the entry collection under the repeater. When there is exactly one child collection, separateTable is inferred:

MultiPopulator(
  {
    max_entries: 10,
    button_label: 'Add team member',
  },
  collection('team_members', {
    fields: {
      name: bd.text().required(),
      role: bd.text().required(),
    },
  }),
)

The compiled node is aligned to:

{
  type: 'multi_populator',
  output: {
    strategy: 'separateTable',
    collectionId: 'teamMember',
  },
  // ...
}

You can declare output: { strategy: 'separateTable', collectionId: 'teamMember' } explicitly. The orchestrator rejects an unknown target and requires an explicit target when several child collections would make inference ambiguous.

JSONB entries

For JSONB, define the owning field in its parent collection, opt that field into JSONB storage, and define the repeated entry shape on the repeater:

const projects = collection('projects', {
  fields: {
    name: bd.text().required(),
    teamMembers: bd.array().strategy('jsonb'),
  },
})

const definition = createFormSchemaOrchestrator([
  projects,
  MultiPopulator({
    max_entries: 10,
    button_label: 'Add team member',
    output: {
      strategy: 'jsonb',
      fieldRef: 'project.teamMembers',
    },
    entryFields: {
      name: bd.text().required(),
      role: bd.text().optional(),
    },
  }),
])

The submitted value for project.teamMembers is an array:

[
  { "name": "Ari", "role": "Designer" },
  { "name": "Sam", "role": "Engineer" }
]

JSONB repeaters do not take child collections. Their entryFields are compiled into the UI tree while the target field remains in the database schema. The orchestrator rejects a missing target, a scalar target, or a target without .strategy('jsonb').

For either strategy, your renderer owns the live array of instances and must enforce max_entries. The output metadata tells the submission/persistence adapter where to route that array.

OrCondition

OrCondition(
  {
    showTitle?: boolean,
    helperText?: string,
    button_label?: string,
  },
  branches,
)

Each branch is an option group:

{
  name: 'Existing customer',
  helperText: 'Select an account.',
  nodes: [existingCustomerCollection],
}

Unlike a conditional block, the visitor chooses the path. A renderer should retain one active branch, validate that branch, and omit inactive branch values from the submission payload.

Orchestrator options

OptionDefaultEffect
defaultIdTypeuuidGenerated primary key strategy for collections without an id.
consecutivefalseRequests sequential form navigation.
progressconsecutive ? true : falseExplicitly enables/disables progress.
progress_stylemulti-step when progress is onmulti-step or progress_bar.

Resolved settings are returned as:

{
  consecutive: boolean
  progress: boolean
  progressStyle?: 'multi-step' | 'progress_bar'
}

Notice the input is progress_style, while the resolved output property is progressStyle.

Validation happens at two levels

The unified pipeline uses two complementary validation passes.

Field/schema validation

createSchema() checks field-rule compatibility and relational integrity:

  • invalid length rules,
  • contradictory numeric bounds,
  • incompatible rounding rules,
  • missing relationship targets,
  • incompatible form-element recommendations.

Layout alignment

createFormSchemaOrchestrator() checks the render graph:

  • a render_collection must point to a compiled collection,
  • a conditional fieldRef must point to a compiled field,
  • nested branches and containers are checked recursively.

This catches drift before either output reaches the application.

Database and generated-form output

Default-export the orchestrator result when the CLI should offer its generated form candidates:

export const definition = createFormSchemaOrchestrator([...])

export default definition

Useful methods:

const artifact = definition.databaseSchema.toJSONSchema()
const checksum = await definition.databaseSchema.checksum()
const candidates = definition.generatedForms

Default-export definition.databaseSchema only when this config should propose collections without form candidates.

UI output and live form state

uiFormTree contains structural state: what exists, the order it appears in, when a subtree is eligible to show, and which paths/repeaters a renderer must manage.

It does not contain visitor state:

  • current field values,
  • touched/dirty fields,
  • active branch,
  • repeater instances,
  • field errors,
  • current step,
  • pending uploads,
  • submission lifecycle.

Those values belong to your renderer's store.

createFormController() is the headless runtime behind <BdForm>, but its input is a published dashboard FormSchema. generatedForms are collection-backed dashboard-form drafts; they are not a direct serialization of uiFormTree. Do not pass uiFormTree to createFormController().

When implementing your own store, use the compiler outputs as immutable definitions:

type FormState = {
  values: Record<string, unknown>
  errors: Record<string, string>
  step: number
  selectedBranch: Record<string, string>
  repeatedRows: Record<string, Array<Record<string, unknown>>>
}

Then derive visibility and validation from uiFormTree and the field metadata in databaseSchema.objects.

SchemaBoundary

SchemaBoundary() optionally groups collections under a feature id and populates featureTags. It does not create or require a layout group:

const grouped = createFormSchemaOrchestrator([
  SchemaBoundary(
    {
      featureId: 'crm',
      name: 'CRM',
      description: 'Contact and project data.',
    },
    contacts,
    projects,
  ),
])

In the current 0.9.50 type declarations, SchemaBoundary accepts collections and nested boundaries, not layout containers. Keep layout containers and boundaries as separate supported shapes until the public boundary type is widened; examples that put FormStep(...) directly inside SchemaBoundary(...) are not type-safe today.

Deprecated layout API

The older layout.step(), layout.collection(), layout.conditional(), layout.or(), and layout.multiPopulator() API is exported but deprecated. New 0.9.50 declarations should use layoutContainers.

Granular create, edit, and delete forms

The orchestration tree describes fields and layout; it does not assign a CRUD action to a form. 0.9.50 does not export createSingleResourceForm().

For a category/industry/feature admin surface today:

  1. define each collection() once,
  2. compile the database schema once,
  3. build a focused create/edit/delete view in your application,
  4. use relation metadata to load picker choices,
  5. keep deletion behind an explicit confirmation and apply the declared referential action.
export const categories = collection('categories', {
  fields: {
    name: bd.text().required().formElement('short_text'),
  },
})

export const features = collection('features', {
  fields: {
    name: bd.text().required().formElement('short_text'),
    category: bd
      .relation()
      .references('categories')
      .onDelete('RESTRICT')
      .required()
      .formElement('collection_select'),
  },
})

export const adminSchema = createSchema({
  collections: [categories, features],
})

A future single-resource factory could derive create/edit/delete layouts and lookup dependencies from these collections, but documentation must not present that proposal as a shipped 0.9.50 API.

0.9.50 accuracy boundaries

Several concepts are useful design directions but are not current exports:

  • parseFields() is unnecessary and not exported; collection() already consumes field builders directly.
  • bd.integer() is not exported; use bd.number() for a numeric field, and idType: 'integer' for an auto-incrementing collection primary key.
  • layoutContainers comes from @businessdash/sdk or @businessdash/sdk/forms, not @businessdash/sdk/collections.
  • createSingleResourceForm() is a proposal, not a current runtime function.
  • conditional fieldRef strings are checked at orchestration runtime; the new EvaluationRule type is not a template-literal union in the current public declaration.

See Migrating to 0.9.50 for the mapping.