Guides
Backend database setup
Choose a supported connection
| Database | Configuration | Optional driver |
|---|---|---|
| PostgreSQL | { dialect: 'postgres', url } | @effect/sql-pg |
| MySQL | { dialect: 'mysql', url } | @effect/sql-mysql2 |
| SQLite | { dialect: 'sqlite', filename } | @effect/sql-sqlite-node |
Install the driver version listed in your backend release's peer dependencies.
The v3 backend takes database, not the old ORM adapter configuration.
Applications already using Effect can supply a compatible SQL client layer.
Keep credentials on the server
This fragment only configures storage. Add origins and policy rules as shown in backend setup. Never expose the database URL through public environment variables or client provider props.
PostgreSQL also supports schema for isolating c15t tables. For MySQL, choose the
database through the connection URL. For SQLite, choose a dedicated durable file.
An in-memory database is suitable for disposable tests, not retained history.
Connect through a PostgreSQL pooler
@effect/sql-pg caches named prepared statements on each connection. A pooler
in transaction mode, such as PgBouncer or a provider's pooled connection URL,
can route the next query to a connection that never prepared the statement, and
the query fails. Connect directly, or pass a client layer with prepared
statements turned off:
A client layer bypasses the schema option and the UTC session that c15t sets
on its own connections. To keep c15t's tables in their own schema, add
startupOptions: '-c search_path=c15t' to the layer, and check that your pooler
forwards startup options.
Store timestamps in UTC
The backend stores every timestamp, including each consent's givenAt, as UTC
in columns without a time zone. Connections built from a database config set
this up themselves and override any time zone in the URL:
- PostgreSQL sessions use
timezone=UTC. - MySQL connections set the mysql2
timezoneoption toZ. - SQLite stores epoch milliseconds, so it has no time zone to set.
A client layer you build yourself needs the same setting. Without it, a server
outside UTC stores every time shifted by its offset. For PgClient.layer, pass
startupParameters: { timezone: 'UTC' }, as in the pooler example. For
MysqlClient.layer, add timezone=Z to the connection URL.
A v2 backend writing to the same database stores times in its Node.js process's
time zone. Run it with TZ=UTC, or the two backends read different times from
the same rows.
Convert timestamps written outside UTC
Earlier backends stored times in whatever zone their connection used. Once the
backend reads every timestamp as UTC, rows written in another zone read as
shifted by that zone's offset, and a retried save can be stored twice because
its givenAt no longer matches. Convert those rows before deploying this
version if any of these applied to a backend that wrote to the database:
- PostgreSQL, v3: the database session time zone was not UTC. Check with
show timezone;on a connection made the same way as the backend's. - PostgreSQL or MySQL, v2: the Node.js process ran without
TZ=UTCon a host outside UTC. - MySQL, v3: the Node.js process ran outside UTC and the URL did not set
timezone.
SQLite needs no conversion. The steps below assume every backend used the same zone. They cannot fix a database that backends in different zones wrote to, because a row does not record which backend wrote it.
A zone with daylight saving time repeats an hour when clocks go back, so a stored time inside that hour matches two instants. The script picks one of them, and rows saved during the repeated hour can stay an hour off. Check those rows against another record of the time, such as request logs, before deploying.
- Stop every backend that writes to the database, v2 and v3.
- Back up the database.
- Run the script for your database, with
Europe/Berlinreplaced by the zone the backends used. - Deploy this backend version, and start any v2 backend with
TZ=UTC.
For PostgreSQL, run it with search_path set to the schema option if you use
one:
A database adopted from the pre-2.0 schema also keeps
consentPolicy.expirationDate and the consentRecord table. Convert them in the
same transaction, before commit:
For MySQL, convert_tz returns NULL when the server has no time zone tables.
Check that select convert_tz('2026-01-01 12:00:00', 'Europe/Berlin', '+00:00');
returns a time before running:
For a database adopted from the pre-2.0 schema, add these before commit:
Run the conversion once. A second run shifts every row again.
Apply and verify migrations
Back up existing data first and review the plan. The plan also warns about
schema changes made outside the migrator that the backend depends on, such as a
runtimePolicyDecision table without a unique index on dedupeKey alone. The
migrator does not repair these; each warning includes the statement to run.
Run the app against the same database, schema and credentials, or the server can connect to a different, unmigrated database. A server that starts has not proven that writes and reads work; save a choice and read it back.
Run migrations from a deployment script
createMigrator() owns a connection pool. Plan before applying, stop when
blocked is set, and dispose the pool when the script finishes:
This script applies the reported migration immediately after planning. Run it as an intentional deployment step after reviewing a plan against a restored copy of an existing database. The CLI provides an interactive review instead.
plan() is read-only. Reports include adoption, pending, retained,
blocked, applied and drift. drift lists schema problems the migrator
found but does not fix. Adoption recognizes supported older SQL schemas before
applying numbered migrations. A blocked report is a refusal to migrate an
unrecognized or unsafe state, not permission to delete the ledger and retry.
The package's c15t_migrations ledger tracks applied migrations.
MySQL DDL is not transactional, so migration steps use checkpoints rather than relying on rolling back the whole batch. Keep backups and inspect a failed run before retrying. PostgreSQL schema creation belongs to migration; the runtime and migration configuration must name the same schema.
Upgrade a v2 backend
Replace the v2 adapter option with database. The Drizzle, Prisma, TypeORM
and Kysely adapters are gone. Keep your PostgreSQL, MySQL or SQLite database and
point database at it with the matching driver. MongoDB has no migration path
to the v3 backend.
The migrator recognizes the v2 schema, adopts it, and then applies the v3
migrations. Run the plan against a restored copy of production first. After
migrating, check a consent write, a subject read and an authenticated
external-identity lookup. /status and /manifest do not write, so they do not
prove that persistence works.
Deploy the v3 backend together with v3 clients; the backend upgrade guide explains why.