The two schemas
packages/db/src/index.ts is a thin re-export:
PrismaClient from @perform/db, the types it gets were generated from packages/database/prisma/schema.prisma. The schema in packages/db exists to drive migrations.
Both schemas exist because the server consolidation merged two codebases. The merged schema in packages/database was proven to produce an empty diff against the live database before the runtime client was switched over. The history is in backend/docs/server-consolidation.
The runtime client
createPrismaClient in packages/database/prisma/client.ts builds the client the API uses:
- A
pgPoolwith at most 10 connections, keep-alive on, a 30 second idle timeout and a 10 second connection timeout, wrapped in thePrismaPgdriver adapter. - Prisma
warnanderrorevents are forwarded to the logger. Transient connection errors and expected unique constraint violations are logged atdebuglevel. - A
$extendsquery extension retries every operation on transient connection errors (P1001,P1002,P1008,P1017, or a matching message) with delays of 200, 500, 1000 and 2000 ms.
Commands
Run these from thebackend root.
migrate deploy only applies migration folders that are not yet recorded in the _prisma_migrations table. It creates no shadow database and does not regenerate the client, which makes it fast and predictable.
Changing a model, step by step
1
Edit the migration schema
Change the model in
packages/db/prisma/schema.prisma.2
Write the migration SQL by hand
Create
packages/db/prisma/migrations/<timestamp>_<name>/migration.sql. Folder names use a fixed 14 digit timestamp, for example 20261007120000_checkin_review_dismissed. Migrations are applied in name order, so the timestamp must sort after the newest existing folder.Match Prisma’s naming so a later migrate dev sees no drift:- Table names come from
@@map, for example"products". - Column names are quoted camelCase:
"studioId". - Indexes are
<table>_<column>_idx. Unique indexes are<table>_<column>_key. Foreign keys are<table>_<column>_fkey. - Enum types are quoted PascalCase:
"FormCadence".
ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.3
Mirror the change in the client schema
Make the same change in
packages/database/prisma/schema.prisma. Keep the @@schema("public") line on the model. Every index you declare in one schema must be declared in the other.4
Rebuild the client
5
Type-check and test
6
Prove the migration on a scratch database
See the next section. Do this for any migration that moves, rewrites or deletes data.
7
Apply it
8
Commit both schemas and the migration together
Say in the pull request whether the migration has been applied to the shared database.
Proving a migration on a scratch database
Hand-written SQL has no safety net. Replay it on a throwaway Postgres before it touches real data.- Start an empty local Postgres 16 cluster or container.
- Apply every
migration.sqlunderpackages/db/prisma/migrationsin name order withpsql. - Expect a small number of old migrations to fail on an empty database, because they depend on objects outside the
publicmigration history.20260823120000_user_phone_numberaltersauth.user, which only exists once the better-auth tables do. Note which ones fail before you add yours, so you can tell old failures from new ones. - Run your new migration with
psql -1. That wraps the file in one transaction, asprisma migrate deploydoes, and surfaces problems with temporary tables and statement order. - Load a few rows that represent the awkward cases (nulls, duplicates, old shapes) and run it again from a fresh copy.
- Stop and delete the scratch database.
How deployments touch the schema
The two deploy workflows do not runmigrate deploy. They run prisma db push.
This matters in three ways.
db pushmakes the database match the schema file. It does not run yourmigration.sql. Any data backfill, data move or custom SQL in a migration is not executed by a deploy.db pushdoes not record anything in_prisma_migrations. After a deploy has pushed a column, a latermigrate deploystill considers that migration unapplied and runs its SQL. This is why migration SQL should useIF NOT EXISTSand be safe to run against a database that already has the change.db pushrefuses changes it considers destructive. Indeploy-do.ymlthat refusal is swallowed and the new API version starts anyway, possibly against a schema it does not match.
Safety rules
- Expand, then contract. Add the new column or table first, deploy code that writes both, backfill, then remove the old one in a later release. The mobile app in the field keeps calling old endpoints for weeks.
- New columns are nullable or have a default. The currently running API version keeps inserting rows without the column until the new version is live.
- Never edit a migration that has been applied anywhere. Add a new one.
- Never run
migrate devormigrate resetagainst a shared database.migrate devcan ask to reset the database when it detects drift. - Run migrations over a direct connection, not a pooler.
prisma migratetakes a session-level advisory lock. A transaction pooler can keep that lock after the process exits, and later migrations then time out waiting for it.packages/db/prisma.config.tssetsDIRECT_URLfor this reason. - Back up before a data-moving migration, and write down the SQL that would undo it.
- Rolling back code does not roll back the schema. Design every migration so the previous API version still runs against the new schema.
- Keep the two schema files identical for domain models. Nothing in CI compares them. After editing, diff the model in both files by eye.
Checking for drift between the two schemas
When a type error says a model or field does not exist onPrismaClient, the usual causes are, in order:
packages/databasewas not rebuilt after a pull. Runpnpm --filter @repo/database build.- The change was made in
packages/db/prisma/schema.prismaonly. - The migration was written but not applied, so the query fails at runtime with a missing column while types pass.
Prisma CLI notes
- Prisma 7 reads the datasource URL from
prisma.config.ts, not from the schema file. prisma.config.tsreadsprocess.envdirectly, on purpose. The pre-commit hook lints staged files one by one and reports theno-direct-process-envrule on this file, even thoughpnpm lintdoes not cover it. Expect that when you edit it.prisma db executeonly reports success. To read rows, usepsqlor a short script withpg.