Prisma 6 → 7 migration
This is the operational record of the AIQLick backend migration from Prisma 6.11.1 to 7.8.0. Audience: backend engineers, anyone debugging the dev/prod deploy pipeline, anyone reading the PrismaService source and wondering why it looks the way it does.
Why this needed real work
Prisma 7 is not a drop-in dep bump. Three breaking changes hit us at once:
datasource.urlis no longer supported inschema.prisma. The connection URL has to live in a newprisma.config.tsfile, and the runtimePrismaClientconstructor needs a driver adapter instead of reading the URL itself.- The
$usemiddleware API was removed. Anything that hooked intoprisma.$use(async (params, next) => …)to observe queries has to be reworked into either Client Extensions ($extends) or explicit calls at each call site. prisma db pushCLI flags removed. Both--skip-generate(gone —db pushno longer auto-generates) and--accept-data-loss=false(gone —--accept-data-lossis now a presence-only flag and the default is "do not accept") were dropped. Our CI failed at the SSH-deploy step the first time we shipped Prisma 7 with these flags still in place.
Plus a Docker-level dependency: Prisma 7 transitively pulls @prisma/streams-local@0.1.2, which requires Node ≥22. Our base image was node:20-alpine, so yarn install --frozen-lockfile failed with Expected version ">=22.0.0". Got "20.20.2" until we bumped the Dockerfile.
What changed in code
prisma/schema.prisma
The url line is gone. provider stays as prisma-client-js for now — Prisma 7 still supports the legacy provider, just deprecated. Switching to the new prisma-client provider would force every one of our 398 from '@prisma/client' import sites to change to a custom output path; not worth the churn while the legacy provider still works.
datasource db {
provider = "postgresql"
// url removed — see prisma.config.ts
}
prisma.config.ts (new file at project root)
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed-essential.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
})
The CLI (prisma generate, prisma db push, prisma migrate) reads connection info from this file. import 'dotenv/config' is required because Prisma 7 no longer auto-loads .env.
PrismaService (driver adapter + RDS SSL)
Two structural changes in src/common/prisma/prisma.service.ts:
import { PrismaClient, Prisma } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
@Injectable()
export class PrismaService extends PrismaClient {
constructor(private readonly moduleRef: ModuleRef) {
const databaseUrl = process.env.DATABASE_URL
const isRds = databaseUrl?.includes('.rds.amazonaws.com') ?? false
super({
adapter: new PrismaPg({
connectionString: databaseUrl,
...(isRds && { ssl: { rejectUnauthorized: false } }),
}),
log: /* … */,
})
}
// …
}
The hostname-based isRds check exists because of prisma/prisma#29252 — @prisma/adapter-pg 7.x silently hangs indefinitely when the connection string targets an SSL-required Postgres without explicit ssl in the constructor. RDS uses self-signed certs; the previous Rust query engine ignored them, the new node-pg-based adapter rejects them. We pass ssl: { rejectUnauthorized: false } only for RDS hosts so local Docker Postgres (which runs without SSL) keeps working.
$use middleware → emitUserCreated()
The Prisma 6 codebase had a single global middleware:
// REMOVED — Prisma 7 dropped $use entirely
this.$use(async (params, next) => {
const result = await next(params)
if (params.model === 'User' && params.action === 'create' && result?.id) {
setImmediate(() => this.eventEmitter?.emit('user.created', { userId: result.id }))
}
return result
})
This emitted user.created on every User.create() so CreditSubscriptionListener could grant the initial credits.
In Prisma 7 we replaced it with an explicit PrismaService.emitUserCreated(userId: string) method, called from each of the 5 User.create call sites:
| File | Why a user is created here |
|---|---|
users/users.service.ts | Generic UsersService.create (used by admin / onboarding) |
authentication/auth.service.ts (×2) | Email/password signup + Google OAuth signup |
connections/contacts/contacts.service.ts | Employer adds an external contact who has no AIQLick account yet |
connections/contacts/services/contact-import.service.ts | CSV contact import path |
Test fixtures that mock PrismaService need emitUserCreated: jest.fn() on the mock object — five spec files updated alongside.
The reason we went explicit rather than $extends (Prisma 7's recommended replacement) is that $extends returns a new client instance rather than mutating this. Our 398 import sites all expect the unextended class, so an extension-based path would have required a much larger refactor. The explicit pattern is also more readable: the event is emitted next to the user-creation code, not in a magic global hook.
Test imports — runtime/library subpath dropped
Prisma 7 also removed the @prisma/client/runtime/library subpath export that v6 used to expose error-class types like PrismaClientKnownRequestError directly. Any test file written against v6 with this import:
// BROKEN in Prisma 7 — TS2307: Cannot find module '@prisma/client/runtime/library'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
…fails type-check until rewritten to use the Prisma namespace from the main client export, which is also how exception.filter.ts and the live error-handling specs reach it:
import { Prisma } from '@prisma/client'
const err = new Prisma.PrismaClientKnownRequestError('boom', {
code: 'P2002',
clientVersion: '7.8.0',
})
Caught one stale test on 2026-05-09 (skill.service.spec.ts); the rest of the codebase had already migrated to the Prisma.* pattern. If you're writing a new spec that needs to construct or type-check Prisma errors, use the namespace pattern.
CI workflow (.github/workflows/aws-deploy.yml)
# OLD (Prisma 6) — both flags removed in v7
npx prisma db push --skip-generate --accept-data-loss=false
# NEW (Prisma 7)
npx prisma db push
prisma generate still runs explicitly inside the Dockerfile (both builder and runner stages), so dropping the auto-generate from db push doesn't break anything. --accept-data-loss=false was the v6 way of saying "don't accept data loss"; in v7 that's the default and the flag isn't accepted at all.
Dockerfile
node:20-alpine → node:22-alpine across all three stages (builder, deps, runner). Required by @prisma/streams-local (transitive Prisma 7 dep). Plus a yarn cache mount on both stages so package downloads survive layer-cache misses:
RUN \
yarn install --frozen-lockfile
yarn cache clean is removed on the deps stage — rmdir on the mounted directory mid-RUN fails with EBUSY, and the cache lives outside the layer anyway so cleanup wouldn't shrink the image.
Deploy timeline (what actually happened)
Useful to keep around because the same shape will repeat the next time we touch Prisma:
Lesson worth keeping: the EC2 deploy step has its own auto-rollback to :previous only when the new container fails health check. When the deploy script itself errors before swapping containers (as happened with the prisma db push flag failure), the old container keeps serving but the new image just sits in ECR — no rollback notification fires, so monitoring "is dev still 200?" doesn't catch the regression. Always check the deploy run status, not just the health endpoint.
Docker build placeholder DATABASE_URL
Discovered during the prod cutover on 2026-05-02 (aiqlick-backend@98f0b271 then fb36c1b5). Two related Dockerfile fixes were needed for Prisma 7 to build cleanly:
-
prisma.config.tsmust be in the image. The original Dockerfile only copiedprisma/(the schema directory), soprisma.config.tsat the repo root was missing inside the container. The deploy step'sdocker run --rm … npx prisma db pushthen crashed with:Error: The datasource.url property is required in your Prisma config file when using prisma db push.Add
COPY prisma.config.ts ./in both the builder stage (soprisma generateresolves it during the build) and the runner stage (so the deploy step'sdb pushfinds it). -
A placeholder
DATABASE_URLis required at build time. The config file callsenv('DATABASE_URL'), which throwsPrismaConfigEnvError: Cannot resolve environment variable: DATABASE_URLat config-load time when the var is unset. The build doesn't have the real URL — that's injected atdocker run --env-file …time. Pass a syntactically-valid placeholder:ENV DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
RUN npx prisma generateprisma generatedoesn't actually connect to the database (it just emits the client based on the schema), so the placeholder is never used. ResetENV DATABASE_URL=in the runner stage after the generate step so the placeholder doesn't leak into the running container if--env-fileever forgets to set the real value.
Symptom of skipping either fix: the deploy job goes red at Schema sync completed! step ~30 seconds in. The old container keeps serving because the deploy never swaps. CI/CD's :previous auto-rollback doesn't fire because no new container was started.
What we deferred
- Switching to the
prisma-clientprovider (Prisma 7's recommended generator). Forces a custom output path and changes everyfrom '@prisma/client'import — 398 sites. Legacyprisma-client-jsstill works in v7 with a deprecation warning. Defer until we have a reason. $extendsClient Extensions for theuser.createdhook. The explicit-emit pattern is clearer; revisit if we need to observe more cross-cutting events.- ESM migration. The Prisma 7 docs lean ESM but Prisma still works fine with our CommonJS NestJS setup. Not migrating.