Open Form

Storage

The SQLite database, Postgres, the StorageAdapter interface and its built-in adapters.

By default responses live in a SQLite file in your project, written through Node's built-in node:sqlite — no database server, no native addon. Set a connection string and they go to Postgres instead. Everything goes through one interface, StorageAdapter, so the handler doesn't care where the data lives.

Which database

Every open-form command and the Next.js route pick the first of:

  1. OPEN_FORM_DATABASE_URL: Postgres.
  2. storage.url in open-form.config.ts: Postgres. Read it from process.env; never write it into the file.
  3. OPEN_FORM_DB, then storage.path, then .open-form/open-form.db: SQLite.

open-form db status shows which one is in use.

Where the data lives

.open-form/
├── open-form.db      responses, form versions, rate limits, Studio overrides, signed-out sessions
├── open-form.db-wal
├── open-form.db-shm
└── secret            signs timing tokens, Studio sessions and visitor hashes

The database path is, in order: OPEN_FORM_DB, then storage.path in open-form.config.ts, then .open-form/open-form.db. Relative paths resolve from the project root. open-form dev and open-form start on the same machine share the file.

The generated secret is written next to the database, so a database on a volume (OPEN_FORM_DB=/data/open-form.db) keeps its secret there too, in /data/secret. OPEN_FORM_SECRET replaces the file, and keeps the secret out of copies of the volume. Up to 0.1.1 the secret always stayed in .open-form/secret; when the database lives elsewhere and that file is still there, the first start after upgrading copies it next to the database, so Studio sessions, open form pages and uniqueBy: 'device' keep working.

Responses are personal data. Keep .open-form/ out of git — the .gitignore that open-form init writes already does — and back the file up like any database. SQLite needs a persistent disk: a VPS, Docker or a platform volume, not a serverless function's filesystem.

Protection on disk

  • The .open-form/ folder is 0700; the database, its -wal and -shm files, and secret are 0600 — readable by the owner only. The database is created that way, and the database files and secret are re-tightened every time they're opened.
  • PRAGMA secure_delete is on, and the write-ahead log is truncated after a delete, so a response deleted in Studio is overwritten rather than left in free pages. Postgres keeps deleted rows until VACUUM; see Deleting responses.
  • The dev server refuses to serve the database files, other *.db / *.sqlite files, .env*, keys and exported responses, so they can't be fetched around Studio's sign-in.
  • Responses search skips a stored answer it can't read rather than failing.

Tables

TableHolds
form_versionsOne row per schema version of each form: id, form_id, version, schema (JSON), created_at. Unique per form_id + version.
submissionsResponses: id, form_id, number, form_version_id, data (JSON), visitor_hash, unique_key, locale, created_at, updated_at. Unique per form_id + unique_key.
form_countersThe last response number per form. Numbers only go up, so they're never reused after a delete.
form_overridesSchedule, time zone and Published changes saved in Studio on a running server, per form.
rate_limitsFixed-window counters for submissions and Studio sign-ins, keyed by hashed IPs (IPv6 by /64).
revoked_sessionsStudio sessions ended by signing out, kept until they would have expired.
submission_filesReserved for file uploads; unused today.
metaThe applied migration version.

Migrations run in order when the database opens. Every response records the schema version it was submitted under, so Responses and exports keep columns for fields you've since removed.

Postgres

The same tables in their own open_form schema, with answers stored as json. Studio's filters and column sorts run the same JavaScript as on SQLite, over the form's responses read from the database, so both agree on every result; search, paging without a filter, and counts run in SQL. Each response to a form takes a lock on that form's row in form_counters, so maxResponses and uniqueBy hold across servers. See Postgres for setup, encryption and permissions.

import { createPostgresStorage } from '@open-form-build/core/postgres';

const storage = await createPostgresStorage({ url: process.env.OPEN_FORM_DATABASE_URL });
OptionDescription
urlThe connection string. Needs the pg package.
poolA pool of your own instead of url: a pg Pool, or anything with its query, connect and end, such as Neon's serverless Pool. close() leaves it open.
schemaWhere the tables go. Default open_form. Lowercase letters, digits and _.

createSqliteStorage

import { createSqliteStorage } from '@open-form-build/core/sqlite';

const storage = await createSqliteStorage({ path: '.open-form/open-form.db' });

It takes { path } — a file, or ':memory:' — creates missing folders, applies migrations and resolves to a StorageAdapter. Pass it to createOpenFormHandler or createOpenFormRoute({ storage }).

createMemoryStorage

import { createMemoryStorage } from '@open-form-build/core/server';

const storage = createMemoryStorage();

An in-memory adapter for tests and throwaway previews. Data disappears with the process.

StorageAdapter

Every method is async, so remote databases can implement it; the SQLite adapter simply resolves right away. Import the type from @open-form-build/core/server, along with the ones its methods use: StoredSubmission, CreateSubmissionInput, CreateSubmissionResult, ListSubmissionsOptions, SubmissionPage, FormVersionRecord, FormOverrides, StoredOverrides, ResponseFilter and ResponseSort.

MethodWhat
ensureFormVersion(formId, version, schema)Record a schema version if it's new; return its record. Called for every stored response, so keep it cheap and idempotent.
getFormVersion(id) / listFormVersions(formId)Read recorded versions.
createSubmission(input, constraints?)Insert a response. Checks constraints.maxResponses and the unique key atomically with the insert; resolves { ok: false, reason: 'full' | 'duplicate' } when they fail.
getSubmission(formId, id)One response, or null.
listSubmissions(formId, opts?){ items, total }, newest first unless order: 'asc'. Options: limit, offset, order; search — a case-insensitive match against answers, not field names; and filter — Studio's filter rules, with now for "in the past N days"; and sort — Studio's column sort, which replaces order. total counts what matches both search and filter. Run filter with responseFilterTest(filter, now) from @open-form-build/core/server, which returns a test for { data, createdAt }. Sort with responseSortKey(sort) (a key per answer) and compareResponseSortKeys: empty answers last, numbers before text, ties newest first. Using them keeps every adapter in the same order.
countSubmissions(formId) / countsByForm()Response counts.
latestByForm()Optional. Newest response time per form, for sorting in Studio.
deleteSubmission(formId, id)Delete one response; false if it didn't exist.
deleteForm(formId)Erase everything stored for a form — responses, versions, overrides and its response numbering — and return how many responses it held. Used when a form is deleted.
hasUniqueKey(formId, uniqueKey)Whether a response already holds this key, e.g. device:<hash>.
getFormOverrides / setFormOverrides / listFormOverridesStudio's overrides: schedule, time zone and published. setFormOverrides takes FormOverrides, or null to clear them; the getters return StoredOverrides, which add updatedAt.
hitRateLimit(key, windowMs, now?)Increment a fixed-window counter and return the count.
revokeSession(id, expiresAt, now?) / isSessionRevoked(id)Record a signed-out Studio session until expiresAt (epoch ms), and check for one. Expired entries may be dropped.
info()Optional. { engine, location?, schemaVersion } for open-form db status.
dump() / restore(dump)Optional, for open-form db copy. dump returns a StorageDump: form versions, responses with their unique keys, response counters, Studio overrides and unexpired signed-out sessions. restore writes one in a single transaction, keeping ids and numbers, and throws if the database already has responses, response numbers or overrides.
close()Release the connection.

An adapter that can't run Studio's filters inside its database can read a form's responses and hand them to pageSubmissions(rows, opts) from @open-form-build/core/server, which applies filter, sort, order, limit and offset exactly as the built-in adapters do. Search is left to the adapter.

open-form start uses SQLite or Postgres. For anything else, implement StorageAdapter and pass it where you build the handler yourself — with createOpenFormHandler, or createOpenFormRoute in Next.js.

On this page