The backend repo has two Prisma packages. Both describe the same public tables. Only one of them is the type source for the running API, and only one of them owns migrations.

Why two

The split comes from the server consolidation. The backend started as a domain API with a classic single-schema Prisma setup in packages/db. Auth and billing later moved from the Next.js app into the backend, and they brought the SaaS starter’s packages/database with them: a newer generator, multi-schema support, and the better-auth tables in the auth schema. Rather than move the migration history, the project kept packages/db as the migration owner and made packages/database the runtime client. So the two files are not alternatives. They are two views of one database:
  • packages/database/prisma/schema.prisma is the complete picture: auth models tagged @@schema("auth"), and every domain model and enum tagged @@schema("public").
  • packages/db/prisma/schema.prisma is the public half only, with no @@schema attributes, and it is the file Prisma Migrate reads.

How imports resolve

Core API code imports from @perform/db:
tsconfig.base.json maps @perform/* to packages/*/src/index.ts, so this resolves to source. That file is a pure re-export:
@repo/database is consumed through its built dist. This is the practical consequence: after you change a model, the API does not see the new types until @repo/database is rebuilt. packages/db does run prisma generate in its own build, but that client is only what the Prisma CLI needs. Nothing in the API compiles against it.

The runtime client

packages/database/prisma/client.ts exports createPrismaClient(options):
  • It builds a pg Pool with max: 10, keep-alive on, a 30 second idle timeout and a 10 second connection timeout, and passes it to the PrismaPg driver adapter.
  • It classifies transient connection errors (codes P1001, P1002, P1008, P1017, and messages such as “connection closed”) and retries with delays of 200, 500, 1000 and 2000 ms.
  • It logs warn and error events through the logger you pass in. Transient errors and expected unique-constraint violations are downgraded so they do not flood the logs.
  • The module also exports a shared db instance built from DATABASE_URL. It throws at import time if DATABASE_URL is not set.
Multi-schema matters here. With schemas declared, Prisma emits fully qualified SQL such as "auth"."user", so no search_path or per-connection schema option is needed.

Differences between the two files today

A diff of the public half shows the files have already drifted in a few places: Treat the AutoFit tables as a known gap: a database built only from migrations does not have them.

How schema changes reach each environment

There are three paths, and they are not the same. The production step is wrapped so a failure only prints a warning: WARN: prisma db push did not apply (data-loss or error). db push is run without --accept-data-loss, so any change Prisma considers destructive is skipped, the deploy continues, and the API starts against the old tables. That is why renames are avoided. For example ExerciseStudioOverride keeps the physical table name exercise_video_overrides because renaming it would be a drop and create.
pnpm db:migrate runs prisma migrate deploy against whatever DATABASE_URL or DIRECT_URL your shell has. If that points at the live database through a tunnel, it migrates the live database. Check the URL before you run it.
Hand-written migrations in this repo are written to be idempotent (IF NOT EXISTS, CREATE INDEX IF NOT EXISTS) because the same object may already exist from an earlier db push. The header of 20260904120000_query_performance_indexes/migration.sql explains this for the indexes it adds.

Changing a model

1

Edit the migration schema

Change the model in packages/db/prisma/schema.prisma.
2

Write the migration SQL by hand

Add packages/db/prisma/migrations/<timestamp>_<name>/migration.sql. Follow Prisma’s naming so a later migrate diff stays clean:
  • Indexes: <table>_<col>_idx, unique indexes <table>_<col>_key.
  • Foreign keys: <table>_<col>_fkey.
  • Quote camelCase columns: "studioId".
  • Enum types are quoted PascalCase: "FormCadence".
  • Make it idempotent where you can.
3

Mirror the change

Apply the same change to packages/database/prisma/schema.prisma. Keep the @@schema("public") line on every model and enum.
4

Rebuild the runtime client

This runs prisma generate, tsup and tsc. It does not need a database.
5

Verify on a scratch database first

Replay the migrations on a throwaway local Postgres before touching a shared database.

Stale dist symptoms

After pulling someone else’s schema change, type errors like Property 'foodBarcodeProduct' does not exist on type 'PrismaClient' mean the @repo/database dist is stale, not that the code is broken. Rebuild it. The same applies to @perform/queue and @perform/smartsend when a queue name or client method appears to be missing.

Constraints that live only in SQL

Prisma cannot express these, so they do not appear in either schema file:

Auth tables and migrations

The auth models are declared only in packages/database. packages/db does not model them, but a migration there can still touch them with raw SQL. 20260823120000_user_phone_number alters "auth"."user" to add the phone columns and the unique index on phoneNumber. If you add a column to an auth table, add it to packages/database/prisma/schema.prisma and write a raw SQL migration in packages/db.

Generated Zod models

prisma-zod-generator writes a single packages/database/prisma/zod/index.ts (config in zod-generator.config.json: custom mode, pure models only, optional fields treated as nullish). These are exported from @repo/database. API request validation does not use them. Each module has its own hand-written *.schema.ts.