Migrations
Migrations are plain SQL files in db/migrations, applied in lexical order by a small
Node runner. There is no ORM-generated migration step — the hand-authored SQL is the
source of truth, because the RLS + role model can't be expressed coherently by
drizzle-kit's generator (the Drizzle definitions in db/src/schema are the typed query
builder only).
Naming & layout
Files are numbered NNNN_short_name.sql, zero-padded so lexical sort = apply order:
db/migrations/
0001_init.sql # tenant/identity/survey/response/compliance schemas
0002_rls.sql # roles + the _apply_tenant_rls helper + policies
0003_auth.sql
…
0117_privacy_barrier_and_transcription_cascade.sql
0118_make_deletion_records_append_only.sql # latest
The latest migration is 0118. Each migration is self-contained: when it adds a
tenant-owned table it must also grant the table to app_tenant and call
_apply_tenant_rls(...) (the 0002 grant was a one-time snapshot). For example:
-- db/migrations/0005_alerts.sql
create table if not exists response.alert (
id bigint generated always as identity primary key,
public_id uuid not null unique,
client_account_id bigint not null,
-- …
);
grant select, insert, update, delete on response.alert to app_tenant;
grant usage, select on all sequences in schema response to app_tenant;
select _apply_tenant_rls('response.alert');
The runner — migrate.mjs
db/scripts/migrate.mjs (run via npm run db:migrate --workspace db) applies pending
files. Its contract:
-
Reads
DATABASE_URLfrom the environment orapi/.dev.varsand connects to the direct (non-pooled) endpoint for DDL. It never prints the connection string. -
Ensures the ledger table exists:
create table if not exists public.schema_migrations (filename text primary key,applied_at timestamptz not null default now()); -
Lists
*.sqlfiles sorted lexically, skips any already inschema_migrations, and applies the rest. Each file runs in its own transaction alongside theinsert … into schema_migrations, so a file is all-or-nothing and the ledger stays consistent. -
The connection uses
prepare: false, so a migration file may contain multiple statements (simple protocol) and is pooler-safe.
The run is idempotent — re-running applies nothing new:
· skip 0001_init.sql (already applied)
→ apply 0118_make_deletion_records_append_only.sql … done
✓ applied 1 migration(s)
Verifying tenant isolation
npm run db:migrate:rehearse --workspace db first executes every pending file against the
target schema in one transaction and deliberately rolls it back. Apply only after that rehearsal
passes. npm run db:verify-rls --workspace db then proves the RLS regime empirically after migrating
(default-deny, FORCE, and that a wrong-tenant query returns zero rows). It is part of the
green gate before any commit — see Multi-tenancy & RLS.
Adding a migration
- Create the next-numbered file, e.g.
db/migrations/0119_my_change.sql. Keep the numbering contiguous and the name a short imperative phrase. - Follow the schema conventions:
bigintidentity PK,uuid public_idfor external refs,client_account_idon every tenant-owned row,timestamptz,jsonbfor open shapes, a tenant-scopedidempotency_keyunique index on externally-driven write tables. - If it adds a tenant-owned table,
grantonly the required operations toapp_tenant, grant the schema's sequences, and callselect _apply_tenant_rls('schema.table');. (For a table whose ownidis the discriminator, pass the column:_apply_tenant_rls('tenant.foo', 'id').) - Every foreign key between tenant-owned tables must carry
client_account_idon both sides and be validated. A scalar internal-id foreign key is not a tenant-ownership proof. - If you also want the Drizzle query builder to see the table, add/adjust the matching
definition in
db/src/schema. - Run
npm run db:migrate:rehearse --workspace db, thennpm run db:migrate --workspace db, thennpm run db:verify-rls --workspace db, and confirm the file appears once inpublic.schema_migrations.
Migrations are forward-only; there is no down-migration mechanism (this is a greenfield platform with no data to roll back). To change something, write a new migration.