The backend has two Prisma setups that describe the same database. A model change must be made in both, or the types and the database drift apart.

The two schemas

packages/db/src/index.ts is a thin re-export:
So when API code imports 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 pg Pool with at most 10 connections, keep-alive on, a 30 second idle timeout and a 10 second connection timeout, wrapped in the PrismaPg driver adapter.
  • Prisma warn and error events are forwarded to the logger. Transient connection errors and expected unique constraint violations are logged at debug level.
  • A $extends query 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 the backend 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.
Every one of these commands acts on the database in DATABASE_URL. There is no confirmation prompt. Print the host of the URL you are about to use before you run db:migrate.

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".
Write additive, repeatable SQL where you can: 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.
  1. Start an empty local Postgres 16 cluster or container.
  2. Apply every migration.sql under packages/db/prisma/migrations in name order with psql.
  3. Expect a small number of old migrations to fail on an empty database, because they depend on objects outside the public migration history. 20260823120000_user_phone_number alters auth.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.
  4. Run your new migration with psql -1. That wraps the file in one transaction, as prisma migrate deploy does, and surfaces problems with temporary tables and statement order.
  5. Load a few rows that represent the awkward cases (nulls, duplicates, old shapes) and run it again from a fresh copy.
  6. Stop and delete the scratch database.

How deployments touch the schema

The two deploy workflows do not run migrate deploy. They run prisma db push. This matters in three ways.
  • db push makes the database match the schema file. It does not run your migration.sql. Any data backfill, data move or custom SQL in a migration is not executed by a deploy.
  • db push does not record anything in _prisma_migrations. After a deploy has pushed a column, a later migrate deploy still considers that migration unapplied and runs its SQL. This is why migration SQL should use IF NOT EXISTS and be safe to run against a database that already has the change.
  • db push refuses changes it considers destructive. In deploy-do.yml that refusal is swallowed and the new API version starts anyway, possibly against a schema it does not match.
Apply a migration with pnpm db:migrate yourself, before the code that needs it is deployed. Do not rely on the deploy workflow to change the schema. This is a gap between the intended workflow (hand-written migrations applied with migrate deploy) and what CI does, and it has not been reconciled in the repository.

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 dev or migrate reset against a shared database. migrate dev can ask to reset the database when it detects drift.
  • Run migrations over a direct connection, not a pooler. prisma migrate takes 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.ts sets DIRECT_URL for 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 on PrismaClient, the usual causes are, in order:
  1. packages/database was not rebuilt after a pull. Run pnpm --filter @repo/database build.
  2. The change was made in packages/db/prisma/schema.prisma only.
  3. The migration was written but not applied, so the query fails at runtime with a missing column while types pass.
See Troubleshooting.

Prisma CLI notes

  • Prisma 7 reads the datasource URL from prisma.config.ts, not from the schema file.
  • prisma.config.ts reads process.env directly, on purpose. The pre-commit hook lints staged files one by one and reports the no-direct-process-env rule on this file, even though pnpm lint does not cover it. Expect that when you edit it.
  • prisma db execute only reports success. To read rows, use psql or a short script with pg.