logicspike/docs

Forms

Forms Service — Database

Last Updated: 2026-06-28 Status: Active Source of truth: apps/forms-service/src/db/schema.ts · migrations in apps/forms-service/drizzle/ Companion docs: architecture.md · api-spec.md · billing.md

Single Neon PostgreSQL database (FORMS_DATABASE_URL). Every table is tenant-scoped via a tenant_id text column. Credit metering reads from CORE_DATABASE_URL (a separate database — see billing.md).


1. forms

The form definition. The id (form_<uuid>) is the public submit identifier embedded in the endpoint URL.

Column Type Notes
id text PK form_<uuid> — public, safe in client HTML
tenant_id text NOT NULL Owning workspace
name text NOT NULL Internal label (1–200 chars)
slug text NOT NULL URL-friendly; unique per tenant among live rows
schema jsonb NOT NULL { fields: FormField[] }; default {"fields":[]} — empty = accept any fields (Web3Forms parity)
settings jsonb NOT NULL { successMessage?, redirectUrl?, theme?: { accent, buttonLabel }, turnstileSiteKey? }; default {}
notify jsonb NOT NULL { email?: { enabled, to? }, newsletterListId? }; default {}
webhook_url text Webhook fan-out target; generates webhook_secret when set
webhook_secret text Auto-generated HMAC secret on first webhook_url save; used for X-Vlozi-Signature. See webhooks.md.
allowed_origins jsonb NOT NULL string[] CORS allowlist; default [] (empty = any origin)
honeypot_field text NOT NULL Hidden field name a bot fills; default botcheck
captcha_required boolean NOT NULL Require a valid Turnstile token; default false
status text NOT NULL active | paused | archived; default active
created_by text User id from the gateway (nullable)
created_at timestamp now()
updated_at timestamp now(), bumped on every update
deleted_at timestamp Soft-delete tombstone; NULL = live

Indexes

  • forms_tenant_slug_uniquepartial unique on (tenant_id, slug) WHERE deleted_at IS NULL (deleted forms free their slug). isSlugUniqueViolation() matches on this index name.
  • forms_tenant_idx on (tenant_id)
  • forms_tenant_status_idx on (tenant_id, status)

2. form_submissions

One row per submission attempt, including spam (so the dashboard can surface / hide it).

Column Type Notes
id text PK sub_<uuid>
form_id text NOT NULL FK forms.id, ON DELETE CASCADE
tenant_id text NOT NULL Denormalized for tenant-scoped queries
data jsonb NOT NULL User field values (control fields stripped). File fields stored as { __file: true, mediaId, name, size, type, url }.
meta jsonb NOT NULL { referer?, origin?, utm_source?, utm_medium?, utm_campaign?, utm_term?, utm_content? }; default {}
status text NOT NULL new | seen | handled | spam; default new
spam_reason text honeypot | captcha; null when not spam
note text Admin triage comment (set via PATCH /admin/submissions/:id)
source_ip text cf-connecting-ip from request headers
user_agent text Request User-Agent
submitted_at timestamp now()
handled_at timestamp Set automatically when status transitions to handled

Indexes

  • form_submissions_tenant_form_submitted_idx on (tenant_id, form_id, submitted_at) — hot path: list a form's submissions newest-first
  • form_submissions_tenant_status_idx on (tenant_id, status) — status filter (hide spam / show new)
  • form_submissions_form_idx on (form_id) — cascade / join support

data JSONB — file field shape

When a submission includes file uploads, each file field is replaced with a structured reference after upload to Media Service:

{
  "resume": {
    "__file": true,
    "mediaId": "med_01j…",
    "name": "resume.pdf",
    "size": 184320,
    "type": "application/pdf",
    "url": "https://cdn.vlozi.app/…/resume.pdf"
  }
}

NOTE

If the Media Service upload fails transiently, the field stores only the original filename as a plain string (fail-soft). CSV export renders file fields as the url when present, or the raw string otherwise.


3. form_webhook_deliveries

One row per webhook delivery attempt. Created for every submission to a form with a webhook_url.

Column Type Notes
id text PK wd_<uuid>
submission_id text NOT NULL FK form_submissions.id, ON DELETE CASCADE
tenant_id text NOT NULL
url text NOT NULL Delivery target (snapshot of form.webhook_url at delivery time)
attempt integer NOT NULL Attempt number (1, 2, or 3)
status_code integer HTTP response status from target
response_body text First 1000 chars of response body
status text NOT NULL pending | success | failed; default pending
delivered_at timestamp Set on success
created_at timestamp now()

Indexes: form_webhook_deliveries_submission_idx on (submission_id); form_webhook_deliveries_tenant_status_idx on (tenant_id, status).

NOTE

Manual retry (via MCP retry-webhook-delivery) inserts a new delivery row rather than updating the existing failed row. The failed row remains as a historical record. See webhooks.md for delivery flow details.


4. form_views_daily

Daily view counter per form, updated on every GET /f/:form_id/schema or GET /f/:form_id/page request.

Column Type Notes
form_id text NOT NULL FK forms.id
tenant_id text NOT NULL Denormalized for tenant-wide analytics
day date NOT NULL UTC calendar date
views integer NOT NULL Cumulative count for that day

Primary key: (form_id, day) — upsert increments views atomically.

Used by the analytics endpoints to compute views, conversion rate (submissions ÷ views), and daily view series.


5. tenant_forms_usage

Fractional credit accumulator for submission billing. See billing.md for the full metering model.

Column Type Notes
tenant_id text PK One row per tenant
accumulator_thousandths integer NOT NULL Unbilled thousandths (0–999). Each submission adds 250. When ≥ 1000, charge 1 credit and reset.
settlement_seq integer NOT NULL Optimistic lock — incremented on each settlement to prevent double-charge
updated_at timestamp Last metering event

6. JSONB shapes (TypeScript)

type FormField = {
  name: string;
  type: "text" | "email" | "tel" | "url" | "number" | "textarea"
      | "select" | "checkbox" | "date" | "file";
  label?: string;
  placeholder?: string;
  help?: string;
  required?: boolean;
  min?: number;
  max?: number;
  pattern?: string;      // regex string for text/email/tel
  options?: string[];    // for select fields (max 100 options)
};
 
type FormSchema = { fields: FormField[] };  // max 100 fields
 
type FormSettings = {
  successMessage?: string;    // max 500 chars
  redirectUrl?: string;       // max 2000 chars, valid URL
  theme?: {
    accent?: string;          // hex color, max 32 chars
    buttonLabel?: string;     // max 60 chars
  };
  turnstileSiteKey?: string;  // max 100 chars (widget-side key)
};
 
type FormNotify = {
  email?: { enabled: boolean; to?: string };
  newsletterListId?: string | null;   // Phase 4 — stored, not yet triggered
};
 
type SubmissionFileMeta = {
  __file: true;
  mediaId: string;
  name: string;
  size: number;
  type: string;
  url: string;
};

7. Migrations

Migrations live in apps/forms-service/drizzle/ and are generated by drizzle-kit:

npm run generate   # drizzle-kit generate → new SQL file
npm run migrate    # apply pending migrations against FORMS_DATABASE_URL
npm run db:push    # push schema directly (dev only)

The forms database can share a Neon project with other services or be isolated — it has no cross-service foreign keys. All external relationships (credits, tenant plans) are resolved at runtime via CORE_DATABASE_URL queries, not FK constraints.

Forms