npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ftisindia/form-builder

v0.2.1

Published

Backend-first dynamic form engine for the FTIS foundation starter: versioned JSON definitions, dual-mode validation, phased action pipeline, transactional outbox, and file/reference handling behind framework-free ports.

Readme

@ftisindia/form-builder

Backend-first dynamic form engine for the FTIS foundation starter (@ftisindia/create-app). Form definitions are data — versioned JSON documents you store in your database — while the behavior they reference (field types, actions, data sources, lifecycle hooks) is code your app registers. The engine validates submissions (separate draft and submit modes), runs a phased action pipeline (validation → one transaction → post-commit side effects via a transactional outbox), evaluates bounded json-logic rules, resolves data sources, and verifies file references — all behind framework-free ports.

  • Package: @ftisindia/form-builder · Version: 0.2.1
  • Module format: ESM + CJS dual build (type: module, import/require conditions). Ships .d.ts types. Node ≥ 20.
  • Runtime deps: ajv + ajv-formats (JSON Schema 2020-12) and json-logic-js.
  • License: PolyForm Noncommercial 1.0.0.

This package is the engine core only

It compiles and tests with no NestJS, Prisma, or CASL present (enforced by an eslint no-restricted-imports rule and a CI job). It is run inside a host app through the src/modules/forms glue module that @ftisindia/create-app scaffolds — that glue implements the engine's ports/seams over the app's Prisma, CASL, audit, and request-context services. The engine is not usable standalone: every service needs you (or the generated glue) to supply persistence and context adapters.

Which path should I use?

| You are… | Start here | |----------|------------| | Using a generated @ftisindia/create-app app | You already have forms — use the scaffolded src/modules/forms glue. This README is reference; you rarely call the engine directly. See Using it inside an FTIS app. | | Integrating the engine into your own backend | Implement the ports and seams, then wire the services as in the quick start. | | Maintaining/publishing this package | See Database setup for the schema/version contract and Scripts. |


Table of contents

  1. Install
  2. Core concepts
  3. The form definition format
  4. Example definitions
  5. Quick start (framework-free)
  6. API reference
  7. Ports you must implement
  8. Seams (ecosystem adapters)
  9. Extending the engine
  10. Using it inside an FTIS app (NestJS glue)
  11. Database setup
  12. Reference tables
  13. Typed errors
  14. Scripts
  15. License

Install

npm install @ftisindia/form-builder
import {
  DefinitionService,
  SubmissionService,
  ValidationEngine,
  FieldTypeRegistry,
  ActionRegistry,
  DataSourceRegistry,
  LifecycleRegistry,
  DataSourceResolver,
  registerCoreFieldTypes,
  registerBuiltinActions,
} from '@ftisindia/form-builder';

The canonical Prisma model snippet is also exported as a package subpath:

// resolvable path string, for tooling that copies the snippet into your schema
import { FORMS_PRISMA_SNIPPET_PATH } from '@ftisindia/form-builder';
// → "@ftisindia/form-builder/prisma/forms.prisma"

Compatibility

This is engine schema version ENGINE_SCHEMA_VERSION = 2. The engine is consumed by the @ftisindia/create-app backend template via the src/modules/forms glue. When using a generated app, the authoritative version pin is the one in your generated app's package.json (copied from the template's _package.json) — match this package to that range rather than upgrading it independently, since the glue module and the engine evolve together. If you bump form-builder yourself, re-run the glue's boot schema check (it compares the live database against EXPECTED_FORMS_SCHEMA / ENGINE_SCHEMA_VERSION and fails fast on a mismatch).


Core concepts

| Idea | What it means | |------|---------------| | Definition-as-data | A FormDefinition is a serializable, versioned JSON document. Published versions are immutable — editing creates a new version. | | Behavior-as-code | Field types, actions, and data sources are looked up by string key from registries you populate at boot. A definition can only reference keys that are registered (enforced at save/publish lint time). | | Four registries | FieldTypeRegistry, ActionRegistry, DataSourceRegistry, LifecycleRegistry (+ OutboxHandlerRegistry for side effects). | | Dual-mode validation | draft relaxes required; submit enforces everything. Both produce the same ValidationErrorItem shape. | | Three-phase pipeline | Actions are partitioned by kind and always run in the order validation → transactional (one DB tx) → post-commit, regardless of how the author listed them. A failed validation leaves no trace; a failed transactional action rolls back the whole transaction; a failed post-commit side effect never rolls back a committed submission. | | Transactional outbox | Post-commit side effects (email, webhooks) are enqueued inside the submission transaction and performed afterwards by a dispatcher with retries/backoff. | | Ports & seams | The engine couples only to interfaces. Your app implements six stores and four seams (plus optional captcha/screening). |


The form definition format

interface FormDefinition {
  key: string;          // stable id, e.g. "abstract-submission" — constant across versions
  version: number;      // bumped on every published change
  status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
  title?: string;
  fields: FieldDef[];   // the field tree
  rules?: Rule[];       // cross-field / conditional logic (bounded json-logic)
  actions?: Record<string, string[]>;  // buttonName -> ordered action names (registry keys)
  settings?: FormSettings;
}

Authoring vs. persisted. Draft/authoring payloads (and the example JSON below) omit status — the engine stamps it. createDraft writes DRAFT, publish/archive move it to PUBLISHED / ARCHIVED. The status you read back on a FormDefinitionRecord is always engine-controlled; you never set it by hand.

FieldDef

interface FieldDef {
  type: string;                 // key into the FieldTypeRegistry ("text", "group", "file", ...)
  name: string;                 // property name in the submission object
  label?: string;
  validators?: Record<string, unknown>;  // interpreted by the field type (required, maxLength, ...)
  dataSource?: { key: string; params?: Record<string, unknown> };
  fields?: FieldDef[];          // present for group types — enables nesting
  repeatable?: boolean;         // a repeatable group becomes an array of objects
  min?: number; max?: number;   // array bounds for repeatable groups
  source?: 'context' | 'computed' | 'default';  // hidden/derived fields
  path?: string;                // for source:'context' — must be 'user.id' | 'org.id' | 'request.id'
  accept?: string[];            // file fields: allowed MIME types
  maxSizeMb?: number;           // file fields: max size in MB
  sensitive?: boolean;          // value never reaches action logs / audit / exports
  reportable?: boolean;         // declared for reporting/export pipelines
  indexHint?: boolean;          // suggests a generated column + index (metadata only)
  ui?: Record<string, unknown>; // OPAQUE to the backend — stored & returned, never interpreted
}

Rule (bounded json-logic)

interface Rule {
  id: string;
  enforceOn?: ('draft' | 'submit')[];  // default ['submit']
  if: unknown;                          // json-logic over the sandboxed { data, ctx } scope
  then: { require?: string[] };         // fields that must be present & non-blank when `if` is truthy
  message?: string;                     // custom violation message
}

FormSettings

interface FormSettings {
  access?: 'private' | 'public';
  captcha?: boolean;
  maxSubmissionsPerIpPerDay?: number;   // per-form override of the org cap
  webhooks?: FormWebhookConfig[];       // max 5 per form; delivered via the outbox, SSRF-gated
  [key: string]: unknown;               // extra keys preserved opaquely
}

A FormWebhookConfig carries { url, events?: ['submitted'], secret?, includeData? }. When secret is set, deliveries carry an x-forms-signature: sha256=<hmac> header; includeData (default true) includes the sensitive-redacted submission payload.


Example definitions

These are the real fixtures shipped in the backend template (packages/template/src/modules/forms/examples/).

Simple — event registration

{
  "key": "registration",
  "version": 1,
  "title": "Event registration",
  "fields": [
    { "type": "text",  "name": "fullName", "label": "Full name",
      "validators": { "required": true, "maxLength": 120 }, "reportable": true },
    { "type": "email", "name": "email", "label": "Email address",
      "validators": { "required": true }, "reportable": true, "indexHint": true },
    { "type": "select", "name": "ticketType", "label": "Ticket type",
      "validators": { "required": true, "options": ["standard", "student", "vip"] }, "reportable": true },
    { "type": "boolean", "name": "newsletter", "label": "Subscribe to updates?" },
    { "type": "hidden", "name": "registeredBy", "source": "context", "path": "user.id" }
  ],
  "actions": {
    "submit": ["validateAll", "persist", "sendConfirmationEmail"],
    "saveDraft": ["persistDraft"]
  }
}

Complex — nested groups, a rule, a file (abstract submission)

{
  "key": "abstract-submission",
  "version": 1,
  "title": "Scientific abstract submission",
  "fields": [
    { "type": "text", "name": "title", "label": "Abstract title",
      "validators": { "required": true, "maxLength": 200 }, "reportable": true },
    { "type": "group", "name": "authors", "label": "Authors",
      "repeatable": true, "min": 1, "max": 10,
      "fields": [
        { "type": "text",    "name": "fullName", "validators": { "required": true } },
        { "type": "email",   "name": "email",    "validators": { "required": true } },
        { "type": "boolean", "name": "isPresenting" }
      ] },
    { "type": "select", "name": "track", "label": "Conference track",
      "dataSource": { "key": "conference-tracks" },
      "validators": { "required": true }, "reportable": true, "indexHint": true },
    { "type": "text", "name": "body", "label": "Abstract body",
      "validators": { "required": true, "maxLength": 3000 }, "ui": { "widget": "richtext" } },
    { "type": "file", "name": "manuscript", "label": "Full manuscript (PDF)",
      "accept": ["application/pdf"], "maxSizeMb": 20 },
    { "type": "boolean", "name": "needsFunding", "label": "Requesting funding?" },
    { "type": "text", "name": "fundingSource", "label": "Funding source",
      "ui": { "showWhen": "needsFunding" } },
    { "type": "hidden", "name": "submittedBy", "source": "context", "path": "user.id" }
  ],
  "rules": [
    { "id": "funding-required", "enforceOn": ["submit"],
      "if": { "==": [{ "var": "data.needsFunding" }, true] },
      "then": { "require": ["fundingSource"] },
      "message": "Funding source is required when requesting funding." }
  ],
  "actions": {
    "submit": ["validateAll", "persist", "lockEditing", "sendConfirmationEmail"],
    "saveDraft": ["persistDraft"]
  }
}

Public form that delegates to a custom action (login)

{
  "key": "login",
  "version": 1,
  "title": "Sign in",
  "settings": { "access": "public" },
  "fields": [
    { "type": "email",    "name": "email",    "label": "Email address",
      "validators": { "required": true } },
    { "type": "password", "name": "password", "label": "Password",
      "validators": { "required": true, "minLength": 8 }, "ui": { "widget": "password" } }
  ],
  "actions": { "submit": ["authenticate"] }
}

authenticate here is an app-provided action marked dangerous: true — it never persists a submission, it delegates to the app's auth service. Wiring a dangerous action requires the forms.wireDangerous permission and the org allowlisting the action name.


Quick start (framework-free)

The engine needs adapters for persistence and request context. The snippet below uses trivial stubs to show the wiring end to end — replace them with your real stores (in an FTIS app the glue module does this over Prisma).

import {
  FieldTypeRegistry, ActionRegistry, DataSourceRegistry, LifecycleRegistry,
  registerCoreFieldTypes, registerBuiltinActions,
  ValidationEngine, DataSourceResolver,
  DefinitionService, SubmissionService,
  type FormsContext, type FormsAuthorization, type FormsAuditSink, type TxRunner,
  type FormDefinitionStore, type SubmissionStore, type FileStore,
  type OutboxStore, type ActionLogStore, type FormDefinition,
} from '@ftisindia/form-builder';

// Supply these — your real implementations (see "Ports you must implement").
// In a generated FTIS app the glue module provides them over Prisma.
declare const definitionStore: FormDefinitionStore;
declare const submissionStore: SubmissionStore;
declare const fileStore: FileStore;
declare const outboxStore: OutboxStore;
declare const actionLogStore: ActionLogStore;
declare const registrationDef: FormDefinition;

// 1. Registries — behavior, by string key.
const fieldTypes = new FieldTypeRegistry();
registerCoreFieldTypes(fieldTypes);   // text, number, boolean, select, date, email, password, file, hidden, group

const dataSources = new DataSourceRegistry();
dataSources.register({
  key: 'conference-tracks',
  async fetch(_params, _ctx) {
    return [{ value: 'ai', label: 'AI' }, { value: 'bio', label: 'Bioinformatics' }];
  },
});

const lifecycles = new LifecycleRegistry();
const resolver = new DataSourceResolver(dataSources, { ttlMs: 30_000 });
const validation = new ValidationEngine(fieldTypes, { cacheSize: 500 });

const actions = new ActionRegistry();
registerBuiltinActions(actions, {     // registers validateAll, persist, persistDraft, lockEditing
  validate: (def, data, mode) => validation.validate(def, data, mode),
  submissions: submissionStore,       // your SubmissionStore (see "Ports you must implement")
});

// 2. Seams + stores (implement these — stubbed here).
const txRunner: TxRunner = { run: (fn) => fn({ __engineTx: true } as never) };
const authz: FormsAuthorization = { can: () => true };
const audit: FormsAuditSink = { async write() {} };
const ctx: FormsContext = {
  requestId: () => 'req-1', source: () => 'http',
  userId: () => 'user-1', orgId: () => 'org-1',
  permissionKeys: () => [], isOwner: () => true,
  assertOrgScope: (orgId) => { if (orgId !== 'org-1') throw new Error('out of scope'); },
  resolve: (path) => (path === 'user.id' ? 'user-1' : path === 'org.id' ? 'org-1' : 'req-1'),
  ipAddress: () => undefined, userAgent: () => undefined,
};
const policyFor = async () => ({
  allowedDangerousActions: [], enableRuleIteration: false, captchaConfigured: false,
});

// 3. Services.
const definitions = new DefinitionService({
  store: definitionStore, authz, audit, txRunner,
  fieldTypes, actions, dataSources, resolver, policyFor,
});
const submissions = new SubmissionService({
  definitions: definitionStore, submissions: submissionStore, files: fileStore,
  outbox: outboxStore, actionLog: actionLogStore, audit, txRunner, authz,
  fieldTypes, actions, lifecycles, validation, resolver, policyFor,
});

// 4. Use it.
const draft = await definitions.createDraft({ orgId: 'org-1', definition: registrationDef }, ctx);
await definitions.publish({ orgId: 'org-1', key: 'registration', version: draft.version }, ctx);

const result = await submissions.submit({
  orgId: 'org-1', formKey: 'registration',
  data: { fullName: 'Alice', email: '[email protected]', ticketType: 'standard' },
}, ctx);
// → { submissionId, outputs: { validateAll: {...}, persist: { submissionId }, ... } }

API reference

DefinitionService

Definition lifecycle: create/update drafts, publish (with save/publish-time linting and prior-version archiving), archive, gated public-access flips, and render-time data-source resolution. Every method takes a FormsContext and re-checks permissions through the FormsAuthorization seam (defense in depth).

new DefinitionService(deps: DefinitionServiceDeps)

interface DefinitionServiceDeps {
  store: FormDefinitionStore;
  authz: FormsAuthorization;
  audit: FormsAuditSink;
  txRunner: TxRunner;
  fieldTypes: FieldTypeRegistry;
  actions: ActionRegistry;
  dataSources: DataSourceRegistry;
  resolver: DataSourceResolver;
  policyFor(orgId: string): Promise<OrgFormsPolicy>;
  ruleLimits?: RuleLimits;   // defaults to DEFAULT_RULE_LIMITS
}

| Method | Signature → returns | Notes / permission | |--------|---------------------|--------------------| | createDraft | ({ orgId, definition }, ctx)FormDefinitionRecord | Lints (save stage), assigns the next version, status DRAFT. forms.create. | | updateDraft | ({ orgId, key, version, definition }, ctx)FormDefinitionRecord | Only DRAFT versions; published are immutable (FormsStateError). forms.update. | | publish | ({ orgId, key, version }, ctx)FormDefinitionRecord | Runs publish-stage lint, archives any prior PUBLISHED version of the key, freezes this one. A public form additionally needs forms.managePublicAccess. forms.publish. | | archive | ({ orgId, key, version }, ctx)FormDefinitionRecord | Marks ARCHIVED. forms.archive. | | setPublicAccess | ({ orgId, key, version, access }, ctx)FormDefinitionRecord | The only op allowed to change access on an (otherwise immutable) published version. Rejects public + file fields, and captcha-enabled forms with no verifier. Always audited (previous → next). forms.managePublicAccess. | | getByKeyVersion | ({ orgId, key, version }, ctx)FormDefinitionRecord | forms.read. | | getLatest | ({ orgId, key, status? }, ctx)FormDefinitionRecord | Highest version, optionally by status. forms.read. | | list | ({ orgId, status?, cursor?, limit? }, ctx)FormDefinitionRecord[] | Default limit 50. forms.read. | | getForRender | ({ orgId, key, version?, public? }, ctx)RenderResult | Loads the published definition and resolves all data-source options. The public path requires settings.access === 'public' and otherwise answers "not found". |

interface RenderResult {
  record: FormDefinitionRecord;
  definition: FormDefinition;
  options: Record<string, DataSourceOption[]>;  // keyed by dotted field path (no indices)
}

SubmissionService

Submission orchestration: authorize → load published definition → coerce + inject context fields → lifecycle hooks → full validation (schema + rules + data-source membership + file references) → phased action pipeline → afterPersist. The engine runs the complete validation pass itself before the pipeline, whether or not the author listed validateAll.

new SubmissionService(deps: SubmissionServiceDeps)

interface SubmissionServiceDeps {
  definitions: FormDefinitionStore;
  submissions: SubmissionStore;
  files: FileStore;
  outbox: OutboxStore;
  actionLog: ActionLogStore;
  audit: FormsAuditSink;
  txRunner: TxRunner;
  authz: FormsAuthorization;
  fieldTypes: FieldTypeRegistry;
  actions: ActionRegistry;
  lifecycles: LifecycleRegistry;
  validation: ValidationEngine;
  resolver: DataSourceResolver;
  captcha?: CaptchaVerifier;                          // optional Tier-2 abuse seam
  policyFor(orgId: string): Promise<OrgFormsPolicy>;
  ruleContext?: (ctx: FormsContext) => Record<string, unknown>;  // allowlisted ctx.* exposed to rules
}

| Method | Signature → returns | Notes | |--------|---------------------|-------| | submit | (args: SubmitArgs, ctx)PipelineResult | Submit mode (strict). Default pipeline ['validateAll','persist']. Throws FormsValidationError on invalid data. formSubmissions.create (or the public path). | | saveDraft | (args: SubmitArgs, ctx)PipelineResult | Draft mode (relaxed required). Default pipeline ['validateAll','persistDraft']. | | validateOnly | ({ orgId, formKey, version?, data, mode }, ctx)ValidationResult | Live validation, no side effects — for frontend feedback. | | get | ({ orgId, submissionId }, ctx)SubmissionRecord | formSubmissions.read. | | list | ({ orgId, formKey, filters }, ctx)SubmissionRecord[] | formSubmissions.read. |

interface SubmitArgs {
  orgId: string;
  formKey: string;
  version?: number;            // defaults to the latest PUBLISHED version
  data: Record<string, unknown>;
  button?: string;             // key in the definition's `actions` map (default 'submit'/'saveDraft')
  submissionId?: string;       // present when editing an existing draft submission
  input?: unknown;             // action-specific extra input, threaded to the pipeline
  public?: boolean;            // anonymous path — definition must be settings.access 'public'
  captchaToken?: string;
}

interface PipelineResult {
  submissionId?: string;
  outputs: Record<string, unknown>;  // keyed by action name
}

Registries

FieldTypeRegistry, ActionRegistry, and DataSourceRegistry share the same shape (keyed by type / name / key):

register(def): void   // throws FormsConflictError on a duplicate key
get(key): T | undefined
require(key): T        // throws FormsStateError when missing
has(key): boolean
keys(): string[]

OutboxHandlerRegistry (keyed by type) has the same shape except there is no require() — use register / get / has / keys.

LifecycleRegistry is different — hooks are not keyed and many can apply to one form:

class LifecycleRegistry {
  register(hook: FormLifecycle): void;
  forKey(formKey: string): FormLifecycle[];  // hooks whose formKey is unset, '*', or === formKey
}

Core field types

registerCoreFieldTypes(registry) registers all ten built-ins; CORE_FIELD_TYPES is the array, and each is exported individually (textFieldType, numberFieldType, …, groupFieldType).

| type | Validates to | Notes | |--------|--------------|-------| | text | string | validators: required, minLength, maxLength, pattern. | | number | number | validators: required, minimum, maximum. | | boolean | boolean | | | select | enum | options from validators.options or a dataSource binding (re-validated at submit). | | date | string (format: date) | | | email | string (format: email) | | | password | string | alwaysSensitive — always redacted in logs/audit. | | file | file reference { fileId, ... } | accept, maxSizeMb; verified & linked in-tx. | | hidden | server-sourced value | source: 'context' overwrites the client value from path. | | group | object (array if repeatable) | fields[] nests; min/max bound the array. |

A field type implements the FieldTypeDef contract — a JSON Schema fragment builder plus optional coercion/defaults:

interface FieldTypeDef {
  type: string;
  alwaysSensitive?: boolean;
  buildValidator(field: FieldDef, ctx: FieldValidatorContext): JsonSchema;  // ctx.compose(fields) recurses
  coerce?(raw: unknown, field: FieldDef): unknown;
  resolveDefault?(field: FieldDef): unknown;
}

Validation

class ValidationEngine {
  constructor(registry: FieldTypeRegistry, options?: { cacheSize?: number });  // default 500
  validate(def, data, mode: 'draft' | 'submit', cache?: { orgId: string }): ValidationResult;
  validateDraft(def, data, cache?): ValidationResult;
  validateSubmit(def, data, cache?): ValidationResult;
}
  • One Ajv 2020-12 instance per engine — construct once and reuse.
  • When a cache key is passed and the definition is PUBLISHED (immutable), the compiled validator is reused via an LRU keyed by orgId:key:version:mode.
  • Ajv keywords are mapped onto stable codes (required → REQUIRED, maxLength → MAX_LENGTH, additionalProperties → UNEXPECTED_PROPERTY, …).

Lower-level helpers:

composeSchema(def, registry, mode): JsonSchema      // build the JSON Schema for a definition
coerceData(fields, data, registry): Record<string, unknown>  // run field-type coercion

ValidationResult / ValidationErrorItem:

interface ValidationResult { valid: boolean; errors: ValidationErrorItem[]; }
interface ValidationErrorItem {
  path: string;     // dotted, e.g. "authors.0.email"; "" = whole document
  code: string;     // see ValidationErrorCodes
  message: string;
  ruleId?: string;  // present for RULE_VIOLATION
}

Rules (bounded json-logic)

Rules are untrusted data, so the operator set is an allowlist and expressions are size/depth-capped at save time, then evaluated in a fresh sandbox containing only { data, ctx }.

// operator allowlist
BASE_ALLOWED_OPERATORS: ReadonlySet<string>   // var, missing, ==, <, and, or, if, in, +, -, min, max, ...
ITERATION_OPERATORS: ReadonlySet<string>      // map, reduce, filter, all, some, none (org-gated)
allowedOperators(enableIteration: boolean): ReadonlySet<string>

// save-time linting
lintRules(rules: Rule[], opts: { limits: RuleLimits; enableIteration: boolean }): LintIssue[]

// runtime evaluation (fail-closed: an un-evaluable rule reports a violation)
evaluateRules(rules: Rule[], args: {
  data: Record<string, unknown>;
  ctx: Record<string, unknown>;   // the allowlisted projection from SubmissionServiceDeps.ruleContext
  mode: 'draft' | 'submit';
}): ValidationErrorItem[]

Default limits (DEFAULT_RULE_LIMITS): maxDepth: 10, maxNodes: 100, maxRulesPerForm: 50. Iteration operators are off unless the org policy sets enableRuleIteration: true.

Action pipeline & builtins

You usually drive the pipeline through SubmissionService, but ActionPipeline is exported for direct use:

class ActionPipeline {
  constructor(deps: PipelineDeps);
  run(args: PipelineRunArgs): Promise<PipelineResult>;
}

interface PipelineDeps {
  actions: ActionRegistry;
  txRunner: TxRunner;
  outbox: OutboxStore;
  actionLog: ActionLogStore;
  ctx: FormsContext;
  redactForLog: (value: unknown) => unknown;  // e.g. (v) => redactSensitiveData(def, isSensitiveType, v)
}

interface PipelineRunArgs {
  definitionRecord: FormDefinitionRecord;
  pipelineName: string;       // the button name (diagnostic only)
  actionNames: string[];      // exactly as listed on the button; partitioned by kind internally
  data: Record<string, unknown>;
  mode: 'draft' | 'submit';
  input?: unknown;
  submissionId?: string;
  hooks?: { afterTransactional?: (tx, state) => Promise<void> };  // runs inside the tx (file linking)
}

An action implements FormAction:

type ActionKind = 'validation' | 'transactional' | 'post-commit';

interface FormAction<In = unknown, Out = unknown> {
  name: string;
  kind: ActionKind;
  dangerous?: boolean;  // needs forms.wireDangerous + org allowlist to be attached
  execute(ctx: ActionContext<In>): Promise<Out>;
}

interface ActionContext<In> {
  definition: FormDefinition;
  definitionRecord: FormDefinitionRecord;
  data: Record<string, unknown>;
  mode: 'draft' | 'submit';
  input: In;
  prev: Record<string, unknown>;   // outputs of earlier actions, by name
  ctx: FormsContext;
  tx?: EngineTx;                    // present in the transactional window only
  submissionId?: string;
  setSubmissionId(id: string): void;
  enqueue(job: OutboxJobInput): Promise<void>;  // writes the outbox row through tx
}

Built-in action factories (and the bulk registrar):

createValidateAllAction(validate): FormAction          // kind: 'validation'
createPersistAction(submissions, name?): FormAction    // 'persist' | 'persistDraft', kind: 'transactional'
createLockEditingAction(submissions): FormAction       // 'lockEditing', kind: 'transactional'
registerBuiltinActions(registry, { validate, submissions }): void

Data sources

class DataSourceResolver {
  constructor(registry: DataSourceRegistry, options?: {
    ttlMs?: number;     // default 30_000
    maxEntries?: number;// default 1024 (LRU)
    now?: () => number; // injectable clock
  });
  resolveOptions(binding: { key; params? }, ctx: DataSourceContext): Promise<DataSourceOption[]>;
  validateMembership(def, data, ctx: DataSourceContext): Promise<ValidationErrorItem[]>;  // NOT_IN_DATASOURCE
  clearCache(): void;
}

Options are resolved at render time and re-validated at submit so a stale frontend can never sneak a removed value into a submission. The cache key is org- and user-scoped. stableStringify(value) is exported for matching params objects regardless of key order.

interface DataSourceDef<P = Record<string, unknown>> {
  key: string;
  fetch(params: P, ctx: DataSourceContext): Promise<DataSourceOption[]>;
}
interface DataSourceOption { value: string | number; label?: string; [key: string]: unknown; }
interface DataSourceContext { orgId: string | undefined; userId: string | undefined; source: 'http' | 'worker'; }

Outbox dispatch

The engine owns the per-job state machine; your app owns the polling loop.

dispatchJob(job, handlers, store, opts?): Promise<'done' | 'retried' | 'failed' | 'stale'>
runDispatchCycle(store, handlers, opts?): Promise<DispatchCycleResult>

interface DispatchCycleResult { claimed: number; done: number; retried: number; failed: number; stale: number; }

runDispatchCycle claims a batch (default 10) of due jobs and dispatches them sequentially. opts.wrapJob lets the glue restore worker request context around each dispatch; opts.backoff tunes retry timing. A job with no registered handler is parked (failed) immediately. Handlers implement OutboxJobHandler:

interface OutboxJobHandler { type: string; handle(job: OutboxJobRecord): Promise<void>; }

Register them on an OutboxHandlerRegistry (register / get / has / keys).

Files & redaction

Submissions carry file ids, never bytes. Upload happens before submit; the engine verifies ownership/status/type/size and links the file in-tx.

FILE_STATUS_TRANSITIONS    // TEMPORARY → [SCANNING, CLEAN, LINKED]; SCANNING → [CLEAN, INFECTED]; CLEAN → [LINKED]; INFECTED/LINKED terminal
canTransition(from, to): boolean
acceptSatisfied(accept: string[] | undefined, mimeType: string): boolean   // exact, type/* wildcard, csv↔plain
verifyFileReference(args: FileReferenceCheckArgs): ValidationErrorItem | undefined  // pre-link check
collectFileReferences(def, data): CollectedFileReference[]                  // every well-formed file ref
isGcEligible(file, now, ttlMs): boolean                                     // abandoned TEMPORARY uploads

Redaction for action logs / audit / webhook payloads:

REDACTED_PLACEHOLDER  // "[REDACTED]"
redactSensitiveData(def, isSensitiveType: (type: string) => boolean, value): unknown

redactSensitiveData returns a deep copy with sensitive fields (and types where isSensitiveType is true, e.g. password) replaced — plus a credential keyword guard (/password|secret|token|api[_-]?key|authorization|credential/i) for keys the field tree doesn't describe. The input is never mutated. A dependency-free magic-byte MIME sniffer ships too — import the MimeSniffer interface and the DefaultMagicByteSniffer implementation from the package root (@ftisindia/form-builder); there is no files/mime-sniff subpath export.

Definition linting

FORM_DEFINITION_META_SCHEMA: JsonSchema                 // the definition's JSON Schema (shape gate)
validateDefinitionShape(input: unknown): LintIssue[]    // stage-1 shape check
lintDefinition(def: FormDefinition, deps: DefinitionLintDeps): LintIssue[]  // full save/publish lint

lintDefinition runs the shape gate first; if the document is well-formed it runs all semantic checks (duplicate names, unknown field/action/data-source keys, group sanity, context paths, file constraints, rule linting via the injected lintRules, action phase order, dangerous-action wiring, and — for stage: 'publish' — captcha and public-file gates). DefinitionService calls this for you; call it directly only if you lint outside the service.

interface DefinitionLintDeps {
  fieldTypes: FieldTypeRegistry;
  actions: ActionRegistry;
  dataSources: DataSourceRegistry;
  policy: OrgFormsPolicy;
  ruleLimits?: RuleLimits;
  lintRules: (rules, opts: { limits; enableIteration }) => LintIssue[];
  stage: 'save' | 'publish';
  canWireDangerous: boolean;
}

Ports you must implement

The engine reads/writes through these repository interfaces. Every read takes an explicit orgId and implementations must filter by it (a cross-org id behaves like a missing one). Every write that participates in the pipeline accepts the opaque EngineTx so Phase-2 atomicity holds across stores.

| Port | Responsibility | |------|----------------| | FormDefinitionStore | CRUD + versioning for definitions (create, update, findByKeyVersion, findLatest, findAllByKeyStatus, list, maxVersion). | | SubmissionStore | Store/read submissions stamped with formVersion (create, update, findById, list, countByIpSince, listForExport). | | OutboxStore | Transactional outbox lifecycle (enqueue in-tx, claimDue with a lease token, touchProcessing, markDone, markRetry, markFailed). | | FileStore | Uploaded-file records (create, findById, updateStatus, listGcEligible, delete). | | FileStoragePort | Byte storage behind files (put, get, delete) — local disk, S3, … | | ActionLogStore | High-volume action telemetry (write) — input/output already redacted by the engine. |

Representative signature (the rest follow the same pattern — see src/types/ports.ts):

interface SubmissionStore {
  create(record: NewSubmissionRecord, tx?: EngineTx): Promise<SubmissionRecord>;
  update(orgId: string, id: string, patch: SubmissionPatch, tx?: EngineTx): Promise<SubmissionRecord>;
  findById(orgId: string, id: string, tx?: EngineTx): Promise<SubmissionRecord | null>;
  list(orgId: string, formKey: string, filters: SubmissionListFilters): Promise<SubmissionRecord[]>;
  countByIpSince(orgId: string, formKey: string, ipAddress: string, since: Date): Promise<number>;
  listForExport(orgId: string, formKey: string, filters): Promise<SubmissionRecord[]>;
}

Seams (ecosystem adapters)

The engine couples to four request/identity seams; the glue binds them to the app's real services. They never throw framework exceptions — only the engine's typed errors.

interface FormsContext {
  requestId(): string | undefined;
  source(): 'http' | 'worker';
  userId(): string | undefined;
  orgId(): string | undefined;
  permissionKeys(): string[];
  isOwner(): boolean;
  assertOrgScope(orgId: string): void;            // throws when orgId ≠ active scope
  resolve(path: 'user.id' | 'org.id' | 'request.id'): string | undefined;  // closed union by design
  ipAddress(): string | undefined;
  userAgent(): string | undefined;
}

interface FormsAuthorization { can(required: FormPermissionKey[], ctx: FormsContext): boolean; }

interface FormsAuditSink { write(entry: AuditEntry, tx?: EngineTx): Promise<void>; }

interface TxRunner { run<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T>; }

Optional abuse-control seams: CaptchaVerifier (Tier 2 — verify(token, ctx)) and SubmissionScreener (Tier 3 — declared, not yet consumed by the engine).

The org policy passed via policyFor(orgId):

interface OrgFormsPolicy {
  allowedDangerousActions: string[];   // allowlist for dangerous actions (audited typed setting)
  enableRuleIteration: boolean;        // json-logic map/filter/... (default false)
  captchaConfigured: boolean;
  maxFileSizeMb?: number;
  virusScanRequired?: boolean;         // files must reach CLEAN before linking
  maxSubmissionsPerIpPerDay?: number;  // public-form abuse cap
  webhookAllowedHosts?: string[];      // SSRF gate; empty ⇒ webhooks effectively disabled
}

DEFAULT_RULE_LIMITS and DEFAULT_ORG_FORMS_POLICY are exported as sensible starting points.


Extending the engine

Everything is "implement an interface, register the provider." These examples use the framework-free API (plain register(...) calls).

Custom field type

fieldTypes.register({
  type: 'rating',
  buildValidator(field) {
    const max = (field.validators?.max as number) ?? 5;
    return { type: 'integer', minimum: 1, maximum: max };
  },
  coerce: (raw) => Number(raw),
});

Custom action (pick the kind by when it should run)

// post-commit side effect — only enqueues; the dispatcher delivers later
actions.register({
  name: 'sendConfirmationEmail',
  kind: 'post-commit',
  async execute(ctx) {
    await ctx.enqueue({ type: 'email', payload: { to: ctx.data.email, template: 'confirm' } });
    return { queued: true };
  },
});

// dangerous transactional action — needs forms.wireDangerous + org allowlist
actions.register({
  name: 'authenticate',
  kind: 'transactional',
  dangerous: true,
  async execute(ctx) { return authService.login(ctx.data.email, ctx.data.password); },
});

Custom data source

dataSources.register({
  key: 'conference-tracks',
  async fetch(_params, ctx) {
    const rows = await db.track.findMany({ where: { orgId: ctx.orgId, active: true } });
    return rows.map((r) => ({ value: r.id, label: r.name }));
  },
});

Lifecycle hook (coercion / derived fields — not side effects)

lifecycles.register({
  formKey: 'registration',                 // omit or '*' to apply to all forms
  beforeValidate: (data) => ({ ...data, email: String(data.email).trim().toLowerCase() }),
  afterPersist: async (_data, ctx) => { /* read-only post-commit notification */ },
});

FormLifecycle exposes beforeValidate, afterValidate, beforePersist (all return the possibly-transformed data) and afterPersist (read-only, post-commit).


Using it inside an FTIS app (NestJS glue)

In a scaffolded app you don't wire the registries by hand. The src/modules/forms glue module:

  • Implements every port over Prisma (FormDefinitionStore, SubmissionStore, OutboxStore, FileStore, ActionLogStore) and the four seams over the app's RequestContextService (FormsContext), CASL ability factory (FormsAuthorization), AuditService (FormsAuditSink), and PrismaService.$transaction (TxRunner).
  • Auto-discovers providers via decorators instead of manual register() calls: @FormFieldType(), @FormActionHandler(), @FormDataSource() (defined in infrastructure/registry/form-extension.decorators.ts). A registry builder collects every decorated provider at boot and populates the engine registries.
  • Maps engine errors onto the app's HTTP envelope (FormsValidationError → 422, FormsNotFoundError → 404, FormsAuthzDeniedError → 403, FormsConflictError → 409, …).
  • Carries the permission keys as route guards. The full taxonomy is exported as FORM_PERMISSION_KEYS; a template test asserts set-equality so the two never drift.
  • Verifies the database against ENGINE_SCHEMA_VERSION / EXPECTED_FORMS_SCHEMA at boot and fails fast on mismatch.

See packages/template/docs/FORMS.md and FORMS_CHECKLIST.md for the full glue wiring, plus the example definitions under packages/template/src/modules/forms/examples/.


Database setup

The app owns schema.prisma and the migration history; the engine ships the canonical model snippet. Copy prisma/forms.prisma into your schema and migrate:

  • Models: FormDefinition, FormSubmission, FormOutboxJob, UploadedFile, FormActionLog (with the FormDefinitionStatus, SubmissionStatus, OutboxStatus, FileStatus enums).
  • The glue's boot check compares the live database against EXPECTED_FORMS_SCHEMA (the load-bearing column subset — additive app columns are fine) and the current ENGINE_SCHEMA_VERSION (currently 2).
import { FORMS_PRISMA_SNIPPET_PATH, ENGINE_SCHEMA_VERSION, EXPECTED_FORMS_SCHEMA } from '@ftisindia/form-builder';

Reference tables

Validation error codes (ValidationErrorCodes)

REQUIRED, TYPE, FORMAT, ENUM, MAX_LENGTH, MIN_LENGTH, RANGE, ARRAY_BOUNDS, PATTERN, UNEXPECTED_PROPERTY, RULE_VIOLATION, RULE_TIMEOUT, NOT_IN_DATASOURCE, FILE_REFERENCE, SUBMISSION_CAP, CAPTCHA.

Permission keys (FORM_PERMISSION_KEYS)

forms.read, forms.create, forms.update, forms.publish, forms.archive, forms.wireDangerous, forms.managePublicAccess, formSubmissions.read, formSubmissions.create, formSubmissions.update, formSubmissions.export, formDataSources.manage.

Action kinds (ActionKind, run in this order)

validationtransactionalpost-commit.

File status lifecycle (FileStatus, from FILE_STATUS_TRANSITIONS)

  • TEMPORARYSCANNING, CLEAN, or LINKED (scanning is optional — a file may go straight to CLEAN/LINKED when no scanner is configured)
  • SCANNINGCLEAN or INFECTED
  • CLEANLINKED
  • INFECTED and LINKED are terminal

Typed errors

The engine never throws framework exceptions — only these (the glue maps them to HTTP status):

| Class | code | Typical mapping | |-------|--------|-----------------| | FormsValidationError (carries errors[]) | FORMS_VALIDATION | 422 | | FormsRuleViolationError (extends the above) | FORMS_VALIDATION | 422 | | FormsDefinitionLintError (carries issues[]) | FORMS_DEFINITION_INVALID | 422 | | FormsAuthzDeniedError | FORMS_FORBIDDEN | 403 | | FormsNotFoundError | FORMS_NOT_FOUND | 404 | | FormsConflictError | FORMS_CONFLICT | 409 | | FormsStateError | FORMS_STATE | 409 / 422 |

All extend FormsError ({ code, message }).


Troubleshooting

| Symptom | Cause & fix | |---------|-------------| | Unknown field type / action / data source | The definition references a key that isn't registered. Caught at save/publish as a FormsDefinitionLintError (lint issue per offending key); if it reaches runtime it surfaces as a FormsStateError from require(). Register the provider (registerCoreFieldTypes, registerBuiltinActions, or your own register(...)) so the registry key resolves. | | Schema version / table mismatch at boot | The live database doesn't match EXPECTED_FORMS_SCHEMA / ENGINE_SCHEMA_VERSION. Copy the current prisma/forms.prisma snippet into your schema and run a migration; see Database setup. | | Dangerous action not allowed | Attaching an action with dangerous: true needs the forms.wireDangerous permission and the action name in the org policy's allowedDangerousActions. Add the name to the typed setting and ensure the actor holds the permission, or it fails the wire-time lint / authorization check. | | Captcha enabled without a verifier | A definition sets settings.captcha = true but the org policy reports captchaConfigured: false. Publishing / going public fails the lint (CAPTCHA_NOT_CONFIGURED). Bind a CaptchaVerifier and set captchaConfigured: true, or remove settings.captcha. | | Webhook host not allowed | A webhook URL targets a host outside the org's webhookAllowedHosts (SSRF gate). Add the host to that typed setting (and use https, except loopback). An empty/absent list disables webhooks for the org. | | Public form rejected at publish | Public forms can't contain file fields (PUBLIC_FILE_FIELD). Remove the file field or keep the form private. Flipping access also requires forms.managePublicAccess. |


Scripts

npm run build      # bundle with tsup (ESM + CJS + .d.ts)
npm run test       # vitest run --pool=threads
npm run lint       # eslint (enforces the no-Nest/Prisma/CASL boundary)
npm run typecheck  # tsc --noEmit

License

PolyForm Noncommercial 1.0.0 — free for noncommercial use; commercial use requires a license from ftisindia.com.