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 inpackages/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.prismais the complete picture: auth models tagged@@schema("auth"), and every domain model and enum tagged@@schema("public").packages/db/prisma/schema.prismais thepublichalf only, with no@@schemaattributes, 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
pgPoolwithmax: 10, keep-alive on, a 30 second idle timeout and a 10 second connection timeout, and passes it to thePrismaPgdriver 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
warnanderrorevents 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
dbinstance built fromDATABASE_URL. It throws at import time ifDATABASE_URLis not set.
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 thepublic 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.
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
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 likeProperty '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
Theauth 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.