Skip to main content

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:

  1. datasource.url is no longer supported in schema.prisma. The connection URL has to live in a new prisma.config.ts file, and the runtime PrismaClient constructor needs a driver adapter instead of reading the URL itself.
  2. The $use middleware API was removed. Anything that hooked into prisma.$use(async (params, next) => …) to observe queries has to be reworked into either Client Extensions ($extends) or explicit calls at each call site.
  3. prisma db push CLI flags removed. Both --skip-generate (gone — db push no longer auto-generates) and --accept-data-loss=false (gone — --accept-data-loss is 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:

FileWhy a user is created here
users/users.service.tsGeneric UsersService.create (used by admin / onboarding)
authentication/auth.service.ts (×2)Email/password signup + Google OAuth signup
connections/contacts/contacts.service.tsEmployer adds an external contact who has no AIQLick account yet
connections/contacts/services/contact-import.service.tsCSV 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-alpinenode: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 --mount=type=cache,target=/usr/local/share/.cache/yarn,sharing=locked \
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:

  1. prisma.config.ts must be in the image. The original Dockerfile only copied prisma/ (the schema directory), so prisma.config.ts at the repo root was missing inside the container. The deploy step's docker run --rm … npx prisma db push then 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 (so prisma generate resolves it during the build) and the runner stage (so the deploy step's db push finds it).

  2. A placeholder DATABASE_URL is required at build time. The config file calls env('DATABASE_URL'), which throws PrismaConfigEnvError: Cannot resolve environment variable: DATABASE_URL at config-load time when the var is unset. The build doesn't have the real URL — that's injected at docker run --env-file … time. Pass a syntactically-valid placeholder:

    ENV DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
    RUN npx prisma generate

    prisma generate doesn't actually connect to the database (it just emits the client based on the schema), so the placeholder is never used. Reset ENV DATABASE_URL= in the runner stage after the generate step so the placeholder doesn't leak into the running container if --env-file ever 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-client provider (Prisma 7's recommended generator). Forces a custom output path and changes every from '@prisma/client' import — 398 sites. Legacy prisma-client-js still works in v7 with a deprecation warning. Defer until we have a reason.
  • $extends Client Extensions for the user.created hook. 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.