Techniques
Database
Connect Prisma 7 to a Knead application, query through repositories that join an ambient transaction, answer database errors with problem responses, and test against a rolled-back transaction.
On this page 19 sections
- Installing
- The schema and the generated client
- Registering the client
- Typing the client
- Building options from other providers
- Bringing your own client
- Connection failures
- Querying through repositories
- Transactions
- Transactional methods
- Running a block in a transaction
- Propagation
- What the ambient transaction cannot do
- Errors as HTTP responses
- Custom mappings
- Shutdown
- Testing
- With a fake client
- Against a real database
@knead-js/prisma integrates Prisma ORM 7 with Knead. It gives you:
PrismaModule, which creates, connects and disconnects your Prisma client;PrismaService, whosedbproperty is the client to query through;TransactionHostand@Transactional(), which run work in a transaction that every repository joins automatically, without passing a transaction client around;@knead-js/prisma/http, which turns Prisma errors into problem responses (409for a unique violation,404for a missing record, and so on);@knead-js/prisma/testing, whosewithRollback()runs a test inside a transaction that is always rolled back.
The package never imports @prisma/client, not even its types, and has no runtime dependencies.
Your generated client lives in your project, and you register its type once. The
package reference lists every export.
#Installing
Prisma 7 talks to the database through a driver adapter. For Postgres, use @prisma/adapter-pg:
bun add @knead-js/prisma @prisma/client @prisma/adapter-pg pg
bun add -d prisma@knead-js/core is a peer dependency. @knead-js/http is an optional peer, needed only for
@knead-js/prisma/http. The package requires Bun 1.4.3 or later (see
@Transactional() for why).
#The schema and the generated client
The examples on this page use this schema. Prisma 7's prisma-client generator writes the client
into your source tree, here src/generated/prisma:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
orders Order[]
}
model Order {
id Int @id @default(autoincrement())
userId Int
total Int
user User @relation(fields: [userId], references: [id])
}
model AuditEntry {
id Int @id @default(autoincrement())
action String
}Run bunx prisma generate after every schema change. The connection URL for the Prisma CLI
(migrations, db push) lives in prisma.config.ts in Prisma 7, not in the schema; see the Prisma
documentation. At run time, your application passes the URL to the driver adapter.
The generated client exports PrismaClient and a type per model. For this schema, the records
look like this:
export interface User {
id: number;
email: string;
name: string | null;
}
export interface Order {
id: number;
userId: number;
total: number;
}
export interface AuditEntry {
id: number;
action: string;
}#Registering the client
Import PrismaModule.forRoot() in the root module and give it a client factory:
@Module({
imports: [
PrismaModule.forRoot({
client: () =>
new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
}),
}),
PrismaHttpModule.forRoot(),
UsersModule,
],
})
export class AppModule {}Because client is a factory, the module owns the client it builds: it calls $connect() while
the application bootstraps and $disconnect() when it shuts down. The factory may be async. The
module is global and exports PrismaService, TransactionHost and the PRISMA_CLIENT token.
PrismaHttpModule is covered in errors as HTTP responses.
Options are checked when forRoot() is called, so a mistake fails before the application starts:
the options need exactly one of client and instance, and the client must have $connect(),
$disconnect() and $transaction() methods. An instance is checked at that call too; the
client a client factory returns is checked when the application bootstraps, which then fails.
Each problem is a KneadError such as
PrismaModule options need exactly one of \client` or `instance`.`
#Typing the client
Tell the package which client you use, once, in any module of your project:
import type { PrismaClient } from './generated/prisma/client.js';
declare module '@knead-js/prisma' {
interface PrismaClientRegistry {
client: PrismaClient;
}
}This merges your PrismaClient into the package's PrismaClientRegistry interface. From then on,
PrismaService.client is your PrismaClient, PRISMA_CLIENT injects it, and PrismaService.db
is a TxClient: your client without $connect, $disconnect, $on, $transaction and
$extends, which matches what Prisma hands an interactive transaction. If you use a client
extended with $extends(), register the extended type. Without the declaration, both fall back to
the structural PrismaLike type, which has no models.
#Building options from other providers
PrismaModule.forRootAsync({ imports, useFactory }) resolves the options at bootstrap. The factory
runs in an injection context, so it can inject() providers exported by imports, and it may be
async:
@Injectable()
export class DatabaseConfig {
readonly url = process.env.DATABASE_URL;
readonly schema = process.env.DATABASE_SCHEMA ?? 'public';
}
@Module({ providers: [DatabaseConfig], exports: [DatabaseConfig] })
export class DatabaseConfigModule {}
@Module({
imports: [
PrismaModule.forRootAsync({
imports: [DatabaseConfigModule],
useFactory: () => {
const config = inject(DatabaseConfig);
return {
client: () =>
new PrismaClient({
adapter: new PrismaPg({ connectionString: config.url }, { schema: config.schema }),
}),
};
},
}),
],
})
export class AsyncAppModule {}The resolved options are validated the same way, at bootstrap.
#Bringing your own client
forRoot({ instance }) takes a client you built. The module connects it at bootstrap but leaves
it connected at shutdown, because its owner (you) may still use it. Pass
disconnectOnShutdown: true to have the module disconnect it too:
// You built it, so you own it: the module connects it at bootstrap and disconnects it at
// shutdown only because disconnectOnShutdown is true.
const client = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
@Module({
imports: [PrismaModule.forRoot({ instance: client, disconnectOnShutdown: true })],
})
export class InstanceAppModule {}#Connection failures
If $connect() fails, bootstrap aborts with a KneadError whose message is always
Prisma failed to connect to the database and whose cause is the original error. A client the
module built from client is disconnected first; an instance is left to its owner.
Driver error messages can contain connection strings and host names, so the failure is logged
through the injected LOGGER differently by mode:
- Outside development, only the error's name and its
codeorerrorCode, for examplePrisma failed to connect: PrismaClientInitializationError (P1001). Never the message. - In development (
NODE_ENV=development), the full error, with credentials redacted.
#Querying through repositories
Inject PrismaService and query through db:
@Injectable()
export class UsersRepository {
readonly #prisma = inject(PrismaService);
findById(id: number) {
// db is the ambient transaction's client inside a transaction, the base client otherwise.
return this.#prisma.db.user.findUnique({ where: { id } });
}
create(email: string, name: string | null) {
return this.#prisma.db.user.create({ data: { email, name } });
}
count() {
return this.#prisma.db.user.count();
}
}db is the client of the active transaction when there is one in the current async context, and
the base client otherwise. Written this way, a repository works both inside and outside a
transaction without knowing which.
Repositories and the services that use them are ordinary providers:
import { Module } from '@knead-js/core';
import { UsersController } from './users.controller.js';
import { AuditLog, OrdersRepository, UsersRepository } from './users.repository.js';
import { ImportService, SignupService } from './users.service.js';
@Module({
controllers: [UsersController],
providers: [UsersRepository, OrdersRepository, AuditLog, SignupService, ImportService],
})
export class UsersModule {}PrismaService.client is the base client. Use it for work that must not join the ambient
transaction, and for $on() and $extends(). Inside a transaction, a query through client runs
on another connection and cannot see the transaction's uncommitted rows.
#Transactions
#Transactional methods
@Transactional() runs an async method inside a transaction. Every repository call it makes
through PrismaService.db joins that transaction:
@Injectable()
export class SignupService {
readonly #users = inject(UsersRepository);
readonly #orders = inject(OrdersRepository);
readonly #audit = inject(AuditLog);
// Both writes commit together, or neither does.
@Transactional()
async signUp(input: SignUp) {
const user = await this.#users.create(input.email, input.name);
await this.#orders.create(user.id, input.firstOrderTotal);
await this.#audit.record(`user.created:${user.id}`);
return user;
}
}The transaction commits when the method resolves. When it rejects, the transaction rolls back and the rejection reaches the caller unchanged.
Rules that are checked for you:
- The decorated method must return a
Promise. A synchronous method is a type error. - The class must be built by the DI container, because the decorator takes
TransactionHostwithinject()while the instance is constructed.new SignupService()throwsInjectionContextErrorinstead of quietly running without a transaction. - A static method, or a
propagationorisolationLevelthat is not one of the values below, is aKneadErrorwhen the class is defined.
#Running a block in a transaction
TransactionHost.run(fn, options?) is the same thing as a function call. Use it when the
transaction should cover only part of a method, or when you need options computed at run time:
@Injectable()
export class ImportService {
readonly #host = inject(TransactionHost);
readonly #users = inject(UsersRepository);
readonly #audit = inject(AuditLog);
importUsers(emails: string[]) {
return this.#host.run(
async () => {
for (const email of emails) await this.#users.create(email, null);
return this.#users.count();
},
{ isolationLevel: 'Serializable', timeout: 10_000, maxWait: 2_000 },
);
}
// The audit entry commits on its own transaction, even if the caller's transaction rolls back.
recordAttempt(action: string) {
return this.#host.run(() => this.#audit.record(action), { propagation: 'requiresNew' });
}
}The options:
| Option | Type | Default | Description |
|---|---|---|---|
isolationLevel | IsolationLevel | the database default | 'ReadUncommitted', 'ReadCommitted', 'RepeatableRead', 'Serializable' or 'Snapshot'. Which ones work depends on the database. |
timeout | number | Prisma's default (5000 ms) | Longest time, in milliseconds, the transaction may run before Prisma rolls it back. |
maxWait | number | Prisma's default | Longest time, in milliseconds, to wait for a connection. |
propagation | Propagation | 'required' | 'required' or 'requiresNew'; see below. |
isolationLevel, timeout and maxWait are passed to Prisma's $transaction(); options you
leave out are not passed at all. host.isActive tells you whether the current async context is
inside an open transaction.
#Propagation
'required' (the default) joins the active transaction if there is one and opens a new one
otherwise. A joined call's own options are ignored, since the transaction already exists; in
development, joining with different options logs a warning through LOGGER.
A joined call that rejects marks the shared transaction rollback-only. The rejection still reaches
the inner caller unchanged. But if the owner then resolves anyway, because it caught that
rejection, the transaction is rolled back rather than committed, and the owner's run() rejects
with a KneadError (TransactionHost: transaction marked rollback-only by an inner failure)
whose cause is the first inner rejection. Catching an inner failure therefore cannot commit half
of the work.
'requiresNew' always opens a separate transaction, as recordAttempt() does above: the audit
entry commits even if the caller's transaction later rolls back, and its failure does not mark the
outer transaction. Use it sparingly:
- It runs on a second connection, so the connection pool needs at least 2 connections.
- It can deadlock against the outer transaction when both touch the same rows: the outer one holds its locks while the inner one waits for them.
#What the ambient transaction cannot do
The transaction is carried by an AsyncLocalStorage that belongs to the application's
TransactionHost, so two applications in one process never share a transaction. That has a few
consequences:
- The transaction lasts as long as the callback. Work started inside it but awaited after it
settles (a fire-and-forget promise) finds the transaction closed, and Prisma fails the query
instead of letting it run outside the transaction.
awaiteverything you start. - Do not start long-lived background work inside a transaction: intervals, batch loops, or a lazily created singleton that starts its own loop on first use. It captures the transaction context and keeps seeing a closed transaction forever. Create such things at bootstrap.
- Work that outlives a
requiresNewrun sees the closed inner transaction, not the still-open outer one, and fails rather than silently joining the outer transaction. - Concurrent queries on one interactive transaction (
Promise.allinsiderun()) are serialized or rejected, depending on the driver adapter. Await them one at a time.
#Errors as HTTP responses
Without help, a Prisma error thrown from a controller is an unhandled error: a 500. Import
PrismaHttpModule.forRoot() from @knead-js/prisma/http (as the root module above does) to
answer Prisma errors with the right status:
@Controller('/users')
export class UsersController {
readonly #users = inject(UsersRepository);
readonly #signup = inject(SignupService);
@Get('/:id', { params: UserId })
async get({ params }: Ctx<{ params: v.InferOutput<typeof UserId> }>) {
const user = await this.#users.findById(params.id);
if (!user) throw new NotFoundException(`User ${params.id} does not exist`);
return user;
}
// A second sign-up with the same email fails with P2002, answered as 409 Conflict.
@Post('/', { body: CreateUser, status: 201 })
create({ body }: Ctx<{ body: v.InferOutput<typeof CreateUser> }>) {
return this.#signup.signUp(body);
}
}A second sign-up with the same email now gets:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
{"type":"about:blank","title":"Conflict","status":409,"detail":"A record with these values already exists.","instance":"/users"}The transaction around signUp() rolled back, so nothing was written.
The mapping:
| Prisma error | Status | detail |
|---|---|---|
P2002 (unique constraint) | 409 | A record with these values already exists. |
P2003 (foreign key constraint) | 409 | The operation conflicts with a related record. |
P2025 (record not found) | 404 | The requested record was not found. |
P2000 (value too long) | 400 | A value is too long for its field. |
PrismaClientValidationError | 400 | The request could not be processed. |
P1001, P1002, P2024 (database unreachable, timed out, pool timeout) | 503 | The database is temporarily unavailable. |
PrismaClientInitializationError | 503 | The database is temporarily unavailable. |
| any other Prisma error | 500 | none |
Codes are matched first, then error names. Errors are recognized by their exact name
(PrismaClientKnownRequestError and the other four Prisma error classes), not with instanceof,
so a client generated into another copy or realm still matches. Every other error is declined and
goes on to later filters and the default error handling.
What the response contains:
- Prisma's
messageis never copied into a response or a production log: it can contain query fragments, column values and connection details. The original error travels only as the exception'scause. - 5xx responses follow the default error handling: outside development they carry only
type,title,status,instanceand acorrelationIdthat matches the logged error, so the fixed 503detailis dropped too. - In development, the built-in mappings add
prismaCode(the error code) and, when the error's metadata names the constraint's fields,fields(field names only, never values). Prisma 7 driver adapters put them atmeta.driverAdapterError.cause.constraint.fields;meta.targetis used otherwise.@prisma/adapter-pg7.10 and later name a violated unique index (constraint.index) rather than its fields, so a Postgres unique violation carries nofields.
See exception filters for how filters are ordered.
#Custom mappings
map overrides or adds mappings, keyed by error code or by error name. A mapper receives the
Prisma error and returns an HttpException:
@Module({
imports: [
PrismaHttpModule.forRoot({
map: {
// P2034: a write conflict or deadlock; ask the client to retry.
P2034: () =>
new ServiceUnavailableException('Retry the request.', {
headers: { 'retry-after': '1' },
}),
},
}),
],
})
export class PrismaErrorsModule {}- Keys are looked up as own properties only, code first, then name.
- A mapper that throws, or returns something that is not an
HttpException, produces a masked500. mapmust be a plain object whose values are all functions; anything else is aKneadErrorfromforRoot(). The module stores a frozen copy, so changing the object later has no effect.- The response follows the usual rules: in this example the
503keeps itsretry-afterheader in production, but itsdetailis shown only in development.
For GraphQL resolvers or your own filters, toHttpException(error, { map, development }) returns
the same HttpException (or undefined for errors that are not Prisma's), and isPrismaError()
tells you whether an error is one.
#Shutdown
Call app.enableShutdownHooks() in your entry point so that SIGTERM and SIGINT close the
application: the HTTP server drains in-flight requests first, then modules are destroyed and
PrismaService disconnects the client. Without it a signal kills the process with transactions
still open. See lifecycle.
#Testing
#With a fake client
For unit tests, replace the client with a fake:
Test.createModule({ imports: [AppModule] }).overrideProvider(PRISMA_CLIENT).useValue(fake).
PrismaService and TransactionHost both read the client from that token, so one override
covers both. The fake needs $connect(), $disconnect(), a $transaction(fn, options) that calls
fn with a transaction client, and the models your code uses. Often it is simpler to override
your repositories instead. See testing.
#Against a real database
withRollback(moduleRef, fn, options?) from @knead-js/prisma/testing runs fn inside a
transaction that is always rolled back, and resolves with fn's value. Everything fn does
through PrismaService.db joins that transaction and is rolled back with it, except
requiresNew runs (see the caution below):
// A disposable database: never point tests at DATABASE_URL.
const url = process.env.TEST_DATABASE_URL;
describe.skipIf(url === undefined)('SignupService', () => {
let moduleRef: TestingModule;
beforeAll(async () => {
moduleRef = await Test.createModule({
imports: [
PrismaModule.forRoot({
client: () => new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }),
}),
],
providers: [UsersRepository, OrdersRepository, AuditLog, SignupService],
}).compile();
});
afterAll(() => moduleRef.close());
test('signs a user up', async () => {
await withRollback(moduleRef, async () => {
const user = await moduleRef.get(SignupService).signUp({
email: 'ada@example.com',
name: 'Ada',
firstOrderTotal: 1200,
});
expect(await moduleRef.get(UsersRepository).findById(user.id)).toEqual(user);
});
// Rolled back: nothing persists.
});
});moduleRefis anything withget(token), such as aTestingModuleor anApplicationContext.- Errors thrown by
fnpropagate unchanged. - Inner
@Transactional()calls join the test's transaction, exactly as they join the outermost transaction in production. If one of them rejects, the transaction is rollback-only: even whenfncatches the error,withRollback()rejects with theKneadErrorTransactionHost: transaction marked rollback-only by an inner failure(itscauseis the inner rejection), as the owning@Transactional()method would in production. - The default
timeoutis 30 seconds, because Prisma's 5-second default is short for tests. Pass{ timeout }to change it. - It opens the transaction with
requiresNew, so the pool needs at least 2 connections. - It fails closed: if the client never runs
fn, or the transaction does not end in the rollback it asked for, it throws aKneadError(withRollback: rollback not confirmed) instead of resolving.
Point these tests at a disposable database through a variable of their own, never at
DATABASE_URL, so a misconfigured environment cannot touch real data. This repository's own
Postgres suite does the same with KNEAD_PRISMA_TEST_DATABASE_URL:
docker run -d --rm --name knead-prisma-pg -p 5433:5432 -e POSTGRES_PASSWORD=knead postgres:17-alpine
KNEAD_PRISMA_TEST_DATABASE_URL=postgresql://postgres:knead@localhost:5433/postgres pnpm --filter @knead-js/prisma test:dbtest:db exits with an error when the variable is unset, then runs prisma generate and
prisma db push --force-reset against that database, which erases it, before running the suite.