Docs / Packages / @zudojs/database
v1.3.1

@zudojs/database

PostgreSQL data-access layer for Zudo applications, built on Prisma 7 — a lifecycle-aware client, generic repositories with soft delete, a query builder that translates to Prisma where clauses, managed transactions, keyset pagination with signed cursors, migration and seed runners guarded by advisory locks, health checks with reconnect, and a bounded in-memory cache.

PRISMA 7 POSTGRESQL REPOSITORY PATTERN TRANSACTIONS MIGRATIONS

OVERVIEW

Most applications need to store data in a database and read it back. Prisma already generates a typed client for that. What it does not give you is a shared way to open and close the connection, run the same "find by id, create, update, soft delete" code for every table, keep pagination consistent, or turn database failures into errors your HTTP layer understands.

@zudojs/database adds that layer on top of Prisma 7 for PostgreSQL. It wraps your Prisma client in a DatabaseClient that manages the connection, gives you a BaseRepository class you extend once per table, and ships helpers for transactions, query building, pagination, migrations, seeds, locks, caching and health checks.

Prisma stays visible. Repositories wrap a Prisma model (for example prisma.user), and transaction callbacks receive Prisma's own transaction client. The package normalises lifecycle, errors and pagination; it does not hide Prisma from you.

When you need it
  • You use Prisma 7 with PostgreSQL and want one place that connects, checks health and disconnects.
  • Several tables need the same CRUD, soft-delete and pagination behaviour.
  • You want database errors mapped to 404 / 409 / 503 without hand-written switch statements.
  • You need migrations, seeds or locks that are safe when two app instances start at once.
When you don't
  • You do not use Prisma. There is no driver of its own: the client, repositories and transactions all call a Prisma client (or a Prisma driver adapter), so Drizzle, Kysely, TypeORM or a raw pg pool cannot be plugged in.
  • You use MySQL, SQLite or MongoDB. The client works with any Prisma client, but the migration, seed and lock helpers emit PostgreSQL SQL only.
  • You are happy calling prisma.user.findMany() directly and have one or two tables.
  • You need nested transactions or savepoints. Prisma interactive transactions are used as-is.

INSTALLATION

Install the package together with Prisma and the PostgreSQL driver adapter. Prisma 7 talks to the database through an adapter, a small package that owns the connection string, pool size and SSL settings.

$ npm install @zudojs/database @prisma/client @prisma/adapter-pg $ npm install -D prisma $ npx prisma generate

Prisma 7's prisma-client generator writes a TypeScript client into your own source tree, so you import PrismaClient from that folder rather than from @prisma/client. The examples on this page use this generator block in prisma/schema.prisma, which puts the client at src/generated/prisma; if your output differs, change the import path to match.

generator client { provider = "prisma-client" output = "../src/generated/prisma" moduleFormat = "esm" importFileExtension = "js" }
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
Peer dependency. @prisma/client >=7.0.0 <8 is a peer dependency, so you install it yourself. @zudojs/errors is a regular dependency and comes along automatically.
PostgreSQL only. MigrationRunner, SeedRunner and DatabaseLockManager emit PostgreSQL SQL. Passing dialect: "mysql" or "sqlite" throws UnsupportedDialectError; those dialects are typed but not implemented.

Everything is exported from the package root. Three smaller entry points exist if you prefer narrower imports.

Import pathContents
@zudojs/databaseEverything on this page
@zudojs/database/clientDatabaseClient, createDatabaseClient, error helpers
@zudojs/database/repositoriesBaseRepository, mapRepositoryError
@zudojs/database/transactionsTransactionManager, withTransaction, withTransactionRetry

QUICK START

This example assumes your Prisma schema has a User model with id, email, name and a nullable deletedAt column. It builds the Prisma client, wraps it, defines one repository and creates a row.

import { PrismaClient } from "./generated/prisma/client.js"; import { PrismaPg } from "@prisma/adapter-pg"; import { BaseRepository, createDatabaseClient, type RepositoryDelegate } from "@zudojs/database"; interface User { id: string; email: string; name: string; deletedAt: Date | null; } // One repository per model. The delegate is the Prisma model (prisma.user). class UserRepository extends BaseRepository<User> { constructor(delegate: RepositoryDelegate<User>) { super(delegate, { modelName: "User", softDelete: true }); } } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); const client = createDatabaseClient({ prisma }); await client.connect(); const users = new UserRepository(prisma.user); // no cast since v1.3.1 const alice = await users.create({ email: "alice@example.com", name: "Alice" }); console.log(alice.name, await users.count()); // Alice 1 await client.disconnect();

Run it with DATABASE_URL set. The console prints Alice 1: the created row's name and the number of live rows in the table. The client also logs Database connected. and Database disconnected. through its default logger, a @zudojs/logger console logger named @zudojs/database.

Tip: RepositoryDelegate<User> is the structural shape BaseRepository needs from a model (findUnique, findFirst, findMany, create, update, delete, count). It accepts any argument list and checks only the return types, so a generated Prisma 7 delegate such as prisma.user is passed as it is, while a delegate whose rows do not match User (say prisma.order) is still a type error. Typing the constructor with it keeps your repository independent from Prisma's generated types.
Changed in v1.3.1: in v1.3.0, new UserRepository(prisma.user) with a generated Prisma 7 client failed strict type-checking with TS2345, because Prisma's generic findFirst<T extends UserFindFirstArgs>(...) could not be assigned to a hand-written argument shape, and these examples used prisma.user as unknown as RepositoryDelegate<User>. RepositoryDelegate now accepts any argument list, so the cast can go; it still compiles if you keep it. Inside a subclass, this.delegate is typed by the new RepositoryDelegateOperations, the arguments the repository passes, so this.delegate.findMany({ where }) compiles as before. Checked with Prisma 7.10.0 and prisma generate.

DATABASE CLIENT

A DatabaseClient is a thin wrapper around a Prisma client. It tracks whether you are connected, de-duplicates concurrent connect() calls, adds timeouts and cancellation to raw queries, and converts every failure into a DatabaseError.

You create it with createDatabaseClient and either a pre-built prisma instance or an adapter. With only an adapter the client constructs the PrismaClient for you. Passing neither throws immediately.

The prisma option is typed PrismaClientLike, a structural type describing the methods the wrapper calls. A client generated by Prisma 7 satisfies it as-is, so you pass it straight in:

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const client = createDatabaseClient({ prisma: new PrismaClient({ adapter }) });
No cast needed. Earlier releases declared a single generic $transaction signature that a generated client's overloaded $transaction could not match, so this call needed prisma as unknown as PrismaClientLike. PrismaClientLike now accepts the generated client; delete the cast if you have one.
OptionWhat it doesDefault
prismaA Prisma client to wrap. Takes precedence over adapter.
adapterA Prisma driver adapter (for example new PrismaPg(...)) used to build a client when prisma is absent.
connectionTimeoutMsHow long connect() waits before failing with ERR_DATABASE_TIMEOUT.10000
loggingLog each query's duration through the logger.false
loggerAny object with debug / info / warn / error. Use noopDatabaseLogger to silence output.@zudojs/logger console transport (logger name @zudojs/database; secret-named metadata redacted; debug/info off when NODE_ENV=production)

This example connects, runs a raw parameterised query with a 2-second deadline, and prints a health check.

import { PrismaClient } from "./generated/prisma/client.js"; import { PrismaPg } from "@prisma/adapter-pg"; import { createDatabaseClient } from "@zudojs/database"; const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); const client = createDatabaseClient({ prisma, connectionTimeoutMs: 5_000 }); await client.connect(); const rows = await client.queryRawUnsafe<{ count: bigint }[]>( 'SELECT COUNT(*) AS "count" FROM "User" WHERE "email" LIKE $1', ["%@example.com"], { timeoutMs: 2_000 }, ); console.log(rows[0]?.count); // 1n console.log(await client.healthCheck()); // { status: "connected", latencyMs: 3, checkedAt: 2026-09-09T10:00:00.000Z } await client.disconnect();

Every raw method accepts the same options object: signal (an AbortSignal that rejects with DatabaseAbortError), timeoutMs and metadata (merged into any error raised). queryRawUnsafe / executeRawUnsafe take a SQL string plus a values array; queryRaw / executeRaw take a Prisma.sql tagged template.

Watch out: timeoutMs is client-side. The client stops waiting, but PostgreSQL keeps running the statement. Set statement_timeout on the database for real cancellation.

If you want one client shared across your whole app, use the facade functions instead of passing the client around: connectDatabase(options) creates and connects a shared Database once, getDatabase() returns it anywhere else, and resetDatabase() disconnects and clears it. Calling getDatabase() with options after it exists throws a TypeError, so you never silently reconfigure it.

REPOSITORIES

A repository is a class that owns all database access for one table. Instead of scattering prisma.user.findFirst(...) across your code, you write users.findById(id). BaseRepository gives you the common methods; you add the ones specific to your model.

The constructor takes a delegate (a Prisma model such as prisma.user, or any object with the same findUnique / findMany / create / update / delete / count methods) and an options object.

OptionWhat it doesDefault
modelNameUsed in error messages and to find the model on a transaction client."DatabaseEntity"
idFieldPrimary-key column used by findById, update, delete."id"
softDeletetrue or { field }. Reads then skip rows whose field is set.off; field "deletedAt"
cursorSecretSecret used to sign cursors from paginateCursor.unsigned
delegateKeyProperty to read from a transaction client in withTransaction.modelName lower-cased

Soft delete means marking a row as deleted by writing a timestamp instead of removing it. With softDelete: true, softDelete(id) sets deletedAt, normal reads skip that row, restore(id) clears it, and withDeleted() returns a copy of the repository that can still see it.

This example continues from the Quick Start and shows the soft-delete cycle.

import { PrismaClient } from "./generated/prisma/client.js"; import { PrismaPg } from "@prisma/adapter-pg"; import { BaseRepository, createDatabaseClient, type RepositoryDelegate } from "@zudojs/database"; interface User { id: string; email: string; name: string; deletedAt: Date | null } class UserRepository extends BaseRepository<User> { constructor(delegate: RepositoryDelegate<User>) { super(delegate, { modelName: "User", softDelete: true }); } findByEmail(email: string) { return this.findOne({ email }); } } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); const client = createDatabaseClient({ prisma }); await client.connect(); const users = new UserRepository(prisma.user); // no cast since v1.3.1 const alice = await users.create({ email: "alice@example.com", name: "Alice" }); await users.softDelete(alice.id); console.log(await users.findByEmail("alice@example.com")); // null (normal reads skip soft-deleted rows) console.log((await users.withDeleted().findById(alice.id))?.name); // Alice await users.restore(alice.id); console.log(await users.exists({ email: "alice@example.com" })); // true await client.disconnect();
MethodReturnsNotes
findById(id), findOne(filter)Entity | nullfilter is a Prisma where object.
findMany(filter?)Entity[]No pagination; use with care on big tables.
findPaginated(filter?, { pagination, sort })PaginatedResultPage / limit style. paginate is an alias. sort field names and directions are validated (asc/desc, identifier-only names) before any query runs.
paginateCursor(filter?, { cursor, limit, sort })CursorPaginatedResultKeyset style; see Pagination.
findByQuery(builder)Entity[]Runs a QueryBuilder; see Query Builder.
create(input), createMany(inputs)Entity, number
update(id, input), upsert(where, create, update)EntityMissing row throws a 404 DatabaseError.
delete(id), deleteMany(filter)void, numberHard delete, ignores soft-delete scope.
softDelete(id), restore(id), findDeleted(filter?), withDeleted()variesThrow unless softDelete is on.
exists(filter), count(filter?)boolean, numberexists uses count, never loads a row.
withTransaction(tx)copy of the repositoryRuns later calls inside the transaction.

Every method takes an optional last argument { signal, timeoutMs, metadata }, the same options the client accepts.

Common mistake: calling softDelete() on a repository built without softDelete: true. It throws DatabaseError: User softDelete requires the softDelete option. Turn the option on, or use delete() for a hard delete.

TRANSACTIONS

A transaction groups several writes so they either all succeed or all roll back. If the second write fails, the first is undone too. Use one whenever two rows must change together, such as moving money between accounts.

withTransaction(client, callback) opens a Prisma interactive transaction and passes you tx, a transaction-bound Prisma client. Repositories created from the root prisma object do not know about tx. Call repo.withTransaction(tx) to get a copy that does.

import { PrismaClient } from "./generated/prisma/client.js"; import { PrismaPg } from "@prisma/adapter-pg"; import { BaseRepository, createDatabaseClient, withTransaction, type RepositoryDelegate } from "@zudojs/database"; interface User { id: string; email: string; name: string; deletedAt: Date | null } class UserRepository extends BaseRepository<User> { constructor(delegate: RepositoryDelegate<User>) { super(delegate, { modelName: "User", softDelete: true }); } } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); const client = createDatabaseClient({ prisma }); await client.connect(); const users = new UserRepository(prisma.user); // no cast since v1.3.1 const alice = await users.create({ email: "alice@example.com", name: "Alice" }); const renamed = await withTransaction(client, async (tx, context) => { const txUsers = users.withTransaction(tx); // reads tx.user await txUsers.update(alice.id, { name: "Alice Doe" }); return { name: (await txUsers.findById(alice.id))?.name, id: context.transactionId }; }, { isolationLevel: "Serializable", timeoutMs: 10_000 }); console.log(renamed); // { name: "Alice Doe", id: "6b1f0c2e-9a41-4f5b-b0c3-7e2d1a3f8b90" } (a UUID per transaction) await client.disconnect();

If the callback throws, the transaction is rolled back and the error comes out in one of two ways. Since v1.3.0 a @zudojs/errors BaseError that is not a DatabaseError (a NotFoundError, ValidationError, DomainError, ...) is rethrown as the same instance, so its status code and message reach your HTTP layer unchanged; it is logged at debug level only. Anything else (a driver or database failure, a plain Error, any other thrown value) is normalised into a DatabaseError that carries transactionId and transactionStatus: "failed", and getTransactionContextFromError(error) recovers that context. The same rule applies to withTransactionRetry, client.transaction(), TransactionManager.run() / execute() and a unit of work. isNonDatabaseBaseError(error) is the guard the package uses to tell the two apart.

import { NotFoundError } from "@zudojs/errors"; try { await withTransaction(client, async (tx) => { const user = await users.withTransaction(tx).findById("missing-id"); if (!user) throw new NotFoundError("User not found"); // ...more writes, all rolled back by the throw }); } catch (error) { console.log(error instanceof NotFoundError, (error as NotFoundError).statusCode); // true 404 }
Changed in v1.3.0: before this release every error from the callback, your own included, was wrapped in a 500 DatabaseError (code: "ERR_DATABASE", expose: false) with the original in .cause, so a NotFoundError thrown in a transaction surfaced as an internal server error. If you added an unwrap step for that, it is now dead code and can go.
OptionWhat it does
isolationLevel"ReadUncommitted", "ReadCommitted", "RepeatableRead" or "Serializable".
timeoutMs, maxWaitMsForwarded to Prisma: how long the transaction may run, and how long to wait for a connection.
transactionId, metadataAttached to the context and to any error thrown.
signalAbort the transaction from outside. The abort is raised inside the Prisma callback, so the transaction is rolled back and the caller rejects with DatabaseAbortError.

PostgreSQL sometimes aborts a Serializable transaction because another one touched the same rows. withTransactionRetry has the same signature and re-runs the callback (default 3 retries, doubling delay from 100 ms, each delay capped at maxRetryDelayMs, 30 s by default; pass jitter: "full" to spread contending retries) when isRetryableTransactionError says the failure is temporary.

Danger: retries re-run the whole callback. Anything you do inside it that is not a database write, such as sending an email, can happen more than once.

createUnitOfWork(client) is a small wrapper with the same idea: uow.execute(callback, options) runs one transaction and tags failures with unitOfWork: true.

QUERY BUILDER

A query builder lets you describe a query step by step, in plain method calls, instead of hand-writing a nested Prisma where object. It does not run anything. build() returns a frozen description, toPrismaArgs() turns it into Prisma findMany arguments, and repo.findByQuery(builder) executes it.

The type parameter lists the field names you are allowed to use, so a typo is a compile error.

import { createQueryBuilder, equals } from "@zudojs/database"; const query = createQueryBuilder<"email" | "role" | "createdAt">() .where("role", "admin") .whereContains("email", "@example.com") .or(equals("role", "owner"), equals("role", "admin")) .orderByDesc("createdAt") .select("email", "role") .paginate({ page: 2, limit: 20 }); console.log(query.toPrismaArgs()); // { // where: { AND: [ { role: { equals: "admin" }, email: { contains: "@example.com" } }, // { OR: [ { role: { equals: "owner" } }, { role: { equals: "admin" } } ] } ] }, // orderBy: [ { createdAt: "desc" } ], // skip: 20, take: 20, // select: { email: true, role: true } // }

Pass the builder to a repository to run it: const admins = await users.findByQuery(query). The repository adds its own soft-delete scope on top.

Builder methodsPurpose
where, whereNot, whereIn, whereNotIn, whereLessThan, whereGreaterThan, whereContains, whereStartsWith, whereEndsWith, whereNull, whereNotNull, whereOperatorAdd one condition on a field. All conditions are combined with AND.
and(...), or(...), not(filter)Group standalone filters made with the helper functions below.
orderBy, orderByAsc, orderByDesc, sort([...])Sort order.
select(...fields), include(...relations)Columns to return; relations to load.
page, limit, paginate({ page, limit }), offsetPagination.
build(), toPrismaArgs(), clone(), reset()Finish, translate, copy or clear.

The standalone helper functions build filter objects you can store, combine and reuse. toPrismaWhere(filter) translates one into a Prisma where.

GroupHelpers
Comparisonequals, notEquals, lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual, between, inList, notInList, isNull, isNotNull, isEmpty, isNotEmpty
Textcontains, startsWith, endsWith, matchesPattern
DatesisBefore, isAfter, isBetween, dateRange, dateOnly
Combiningand, or, not, allOf, anyOf, fromObject, optionalEquals, optionalContains
Relationsrelational(relation, filter, "some" | "every" | "none" | "is" | "isNot")
Common mistake: expecting build() to run the query. It only returns a description. Call users.findByQuery(query), or pass query.toPrismaArgs() to Prisma yourself.

PAGINATION

Pagination means returning a list in chunks instead of all at once. There are two styles. Offset pagination uses page numbers ("page 3 of 12") and is easy to display. Cursor (keyset) pagination hands the client an opaque token pointing at the last row seen and stays fast and stable even when rows are inserted while someone is browsing.

Both are methods on the repository. This fragment assumes the users repository from the Repositories section.

// Offset: page 2, 25 rows, newest first const page = await users.findPaginated(undefined, { pagination: { page: 2, limit: 25 }, sort: [{ field: "createdAt", direction: "desc" }], }); console.log(page.meta); // { page: 2, limit: 25, total: 60, totalPages: 3, hasNextPage: true, hasPreviousPage: true, hasNext: true, hasPrev: true } // Cursor: first page, then the next one using the returned cursor const sort = [{ field: "createdAt", direction: "desc" as const }]; const first = await users.paginateCursor(undefined, { limit: 25, sort }); const second = await users.paginateCursor(undefined, { cursor: first.meta.nextCursor, limit: 25, sort }); console.log(first.data.length, first.meta.hasNextPage, second.data.length); // 25 true 25

Page and limit are normalised for you: NaN, negative or missing values fall back to page 1 and limit 20, and the limit is capped at 100 (MAX_LIMIT). The repository always adds the id column as a tiebreaker to cursor sorts so the order is stable.

Cursor pagination goes both ways. Since v1.3.0 any non-empty page requested with a cursor sets meta.previousCursor; pass it back as cursor to fetch the page before. Rows still come back in the requested sort order. Backward cursors carry a reserved $before: true marker (KEYSET_BACKWARD_KEY). The first page has no previousCursor, and a page reached by going back reports hasPreviousPage: false when it is the first one.

// ...continuing from above: step back from the second page const back = await users.paginateCursor(undefined, { cursor: second.meta.previousCursor, limit: 25, sort }); console.log(back.data[0]?.id === first.data[0]?.id, back.meta.hasPreviousPage); // true false

A bad cursor is a client error. A missing, forged, tampered or malformed cursor makes paginateCursor (and decodeCursor, validateCursorPayload, decodeKeysetCursor) throw a ValidationError from @zudojs/errors: status 400, expose: true, with one issue on cursor whose code is cursor_required, cursor_signature, cursor_malformed, cursor_payload or cursor_field. Your HTTP layer can pass it straight through; no wrapping is needed. Messages never quote the offending field name. An invalid cursorSecret or sort definition is a programming error and still throws TypeError. (Before v1.3.0 a bad cursor was a plain TypeError that surfaced as a 500, and previousCursor was never set.)

Watch out: a cursor is a base64 string the browser sends back. Without a cursorSecret on the repository anyone can forge one. Set the secret on any repository whose cursors leave your server; they are then HMAC-signed and rejected if edited.

MIGRATIONS AND SEEDS

A migration is a numbered script that changes the database structure, such as adding a table. The runner remembers which versions have run in a _migrations table and applies only the new ones. A seed is a named script that inserts starting data, tracked the same way in _seeds.

Both runners take a PostgreSQL advisory lock (a database-wide named lock) before working, so two app instances booting at the same time never run the same script twice. Each script runs in its own transaction by default.

import { PrismaClient } from "./generated/prisma/client.js"; import { PrismaPg } from "@prisma/adapter-pg"; import { createDatabaseClient, createMigrationRunner, createSeedRunner } from "@zudojs/database"; const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); const client = createDatabaseClient({ prisma }); await client.connect(); const migrations = createMigrationRunner(client, [ { version: 1, name: "create-roles", up: async (tx) => { await tx.$executeRawUnsafe('CREATE TABLE "roles" ("name" TEXT PRIMARY KEY)'); }, down: async (tx) => { await tx.$executeRawUnsafe('DROP TABLE "roles"'); }, }, ], { transaction: { timeoutMs: 60_000 } }); const result = await migrations.migrate(); console.log(result.applied.map((m) => m.name), (await migrations.status()).currentVersion); // [ "create-roles" ] 1 const seeds = createSeedRunner(client, [ { name: "default-roles", order: 0, run: async (tx) => { await tx.$executeRawUnsafe('INSERT INTO "roles" ("name") VALUES ($1)', "admin"); }, }, ]); console.log((await seeds.run()).applied.length); // 1 await client.disconnect();

Run the script a second time and both print 0 applied: the runners see the recorded versions and skip them. migrations.rollback() reverts the newest migration using its down; rollback(3) reverts three; rollbackAll() reverts everything.

Runner optionWhat it doesDefault
tableNameTracking table."_migrations" / "_seeds"
lockKeyAdvisory lock name."database:migrations" / "database:seeds"
transaction{ timeoutMs, maxWaitMs, isolationLevel } for every transaction the runner opens.Prisma defaults (5 s)
perItemTransactiontrue: one transaction per script. false: the whole batch is all-or-nothing.true
dialectOnly "postgresql" works; others throw UnsupportedDialectError."postgresql"
Common mistake: a big migration hits Prisma's 5-second transaction timeout. Pass transaction: { timeoutMs: 60_000 } (or more) to the runner.

HEALTH CHECKS AND LOCKS

A health check is a quick "is the database reachable?" probe, usually exposed on a /health route so a load balancer can stop sending traffic to a broken instance. It runs SELECT 1 with a timeout (default 5 s) and reports degraded when latency is above 75% of that timeout.

import { PrismaClient } from "./generated/prisma/client.js"; import { PrismaPg } from "@prisma/adapter-pg"; import { assertDatabaseHealth, checkDatabaseHealth, createDatabaseClient, createLockManager, } from "@zudojs/database"; const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); const client = createDatabaseClient({ prisma }); await client.connect(); console.log(await checkDatabaseHealth(client, { timeoutMs: 2_000 })); // { status: "healthy", healthy: true, latencyMs: 4, // checkedAt: 2026-09-09T10:00:00.000Z, message: "Database connection is healthy." } await assertDatabaseHealth(client); // throws DatabaseUnhealthyError (503) when it is not // Advisory lock: only one process at a time runs this block const locks = createLockManager(client); await locks.withAdvisoryLock("reports:nightly", async (tx) => { await tx.$executeRawUnsafe("REFRESH MATERIALIZED VIEW nightly_report"); }, { timeoutMs: 10_000 }); await client.disconnect();

Other health helpers: isDatabaseHealthy(client) returns a boolean and checkDatabaseReadiness(client) returns { ready, latencyMs, checkedAt } for a readiness route. For scheduled checks with automatic reconnect, wrap the client in createConnectionManager({ client, healthCheckIntervalMs: 15_000 }) and call manager.connect(); it emits error and reconnecting events through manager.on(listener). The reconnect policy (reconnect: { failureThreshold, maxAttempts, baseDelayMs, maxDelayMs }, defaults 1, 5, 500 ms, 30 s; false turns it off) backs off between attempts. Since v1.3.0 that back-off wait is not unref'd, so a script whose only pending work is a reconnect stays alive until it finishes, and disconnect() / destroy() cancel a reconnect in progress, wait included. The scheduled health-check timer itself is still unreferenced.

A lock stops two processes from doing the same work at once. withAdvisoryLock(key, callback) holds a named PostgreSQL lock for the length of a transaction. withRowLock(table, id, callback) locks one row with SELECT ... FOR UPDATE instead. timeoutMs becomes SET LOCAL lock_timeout and must be at least 1 ms — PostgreSQL treats lock_timeout = 0 as disabled, so 0 throws a TypeError; use noWait to fail immediately. skipLocked / noWait control what happens when someone else holds the lock.

ERRORS

Every failure from this package is a DatabaseError from @zudojs/errors. Prisma's cryptic codes such as P2002 are mapped to an HTTP status and an error code, and messages for connection failures are fixed strings so a host name never leaks into a response.

Prisma codeMeaningStatusDetect with
P2002, P2003, P2004, P2014Unique or foreign-key violation409isConflictError
P2025, P2015, P2018Row not found404isNotFoundError
P2034, P2028, P1017, SQL 40001 / 40P01Temporary; safe to retry409 / 503isRetryableTransactionError
P2024, P1002, P1008Timeout503getDatabaseErrorKind(e) === "timeout"
P1000, P1001, P1003, P1010, P1011Cannot connect503getDatabaseErrorKind(e) === "connection"

This fragment shows the usual pattern in a request handler, using the users repository from the Repositories section.

import { isConflictError, isNotFoundError, toDatabaseErrorInfo } from "@zudojs/database"; try { await users.create({ email: "alice@example.com", name: "Alice" }); } catch (error) { if (isConflictError(error)) { console.log("That email is already taken"); // respond 409 } else if (isNotFoundError(error)) { console.log("No such row"); // respond 404 } else { console.error(toDatabaseErrorInfo(error)); // { code: "P2002", message: "User already exists.", operation: "insert", // model: "User", constraint: "email" } (plus cause and metadata) throw error; } }

API REFERENCE

Everything below is exported from @zudojs/database. Internal helpers (cursor encoding, key hashing, error normalisation) are also exported but omitted here.

Functions

NameWhat it doesNotes
createDatabaseClient(options)Builds a DatabaseClient.Needs prisma or adapter.
createDatabase, getDatabase, connectDatabase, disconnectDatabase, resetDatabaseManage one shared Database facade.getDatabase throws if given options after creation.
createConnectionManager(options)Scheduled health checks and reconnect around a client.Pass client to wrap an existing one.
withTransaction(client, cb, options?)Runs cb(tx, context) in one transaction.
withTransactionRetry(client, cb, options?)Same, retrying temporary failures.retries, retryDelayMs, maxRetryDelayMs, jitter, shouldRetry.
createTransactionManager(client), createUnitOfWork(client), executeUnitOfWork(client, cb)Object-style transaction helpers.manager.run() also returns the context.
getTransactionContextFromError(error)Reads the failed transaction's context from an error.
createQueryBuilder<Fields>()New empty builder.
toPrismaWhere(filter), toPrismaArgs(state), toPrismaOrderBy, toPrismaSelect, toPrismaSkipTakeTranslate filters and builder state into Prisma arguments.
Filter helpers (equals, and, or, ...)Build QueryFilter values.Full list in Query Builder.
normalizePagination, createPaginationMeta, createPaginatedResult, paginateCollectionOffset pagination helpers for data you already have in memory.
encodeCursor, decodeCursor, createKeysetPage, buildKeysetWhere, getKeysetDirection, keysetFetchSort, reverseKeysetSort, createInvalidCursorErrorCursor pagination building blocks used by paginateCursor.Pass allowedFields to decodeCursor for untrusted input. createKeysetCursor takes an optional direction and createKeysetPage a direction option; buildKeysetWhere honours backward cursors. createInvalidCursorError builds the 400 ValidationError.
createMigrationRunner(client, migrations, options?)Builds a MigrationRunner.PostgreSQL only.
createSeedRunner(client, seeds, options?)Builds a SeedRunner.PostgreSQL only.
createLockManager(client), acquireAdvisoryLock(tx, key), lockRow(tx, table, id)Advisory and row locks.PostgreSQL only.
checkDatabaseHealth, checkDatabaseReadiness, assertDatabaseHealth, isDatabaseHealthyHealth probes with a timeout.Default 5000 ms.
createDatabaseCache(options?), createCacheKey(ns, ...parts), getOrSet(cache, key, loader), invalidateByPrefix(cache, prefix)Process-local LRU cache with TTL.Not transaction-aware.
oneToOne, oneToMany, manyToOne, manyToMany, createRelationRegistry, includeRelation, toPrismaIncludeDescribe relations and validate include trees.Depth-limited (default 5).
isConflictError, isNotFoundError, isRetryableTransactionError, isNonDatabaseBaseError, getDatabaseErrorKind, getDatabaseErrorCode, toDatabaseErrorInfo, normalizeDatabaseErrorInspect and convert errors.

Classes

NameWhat it doesNotes
DatabaseClientConnection lifecycle, raw queries, transactions.connect, disconnect, ping, healthCheck, getStatus, transaction, queryRawUnsafe, executeRawUnsafe, queryRaw, executeRaw, getPrisma.
DatabaseFacade over one client.Same lifecycle methods plus getClient().
DatabaseConnectionManagerEvents, scheduled checks, reconnect.on, off, startHealthChecks, stopHealthChecks, getLastHealth, destroy.
BaseRepositoryAbstract CRUD base class.Extend it; see Repositories.
TransactionManager, DatabaseUnitOfWorkTransaction runners.execute, run.
QueryBuilderFluent query description.Prefer createQueryBuilder().
MigrationRunner, SeedRunnerTracked script runners.status, migrate/run, runOne (seeds), rollback, rollbackAll, getHistory.
DatabaseLockManagerwithAdvisoryLock, withRowLock.
MemoryDatabaseCacheget, set, has, delete, clear, prune, dispose, getStats.Call dispose() when using pruneIntervalMs.
RelationRegistryregister, get, forParent, forChild.

Errors

NameWhenNotes
DatabaseAbortErrorAn AbortSignal fired.Code ERR_ABORTED, status 499.
DatabaseUnhealthyErrorassertDatabaseHealth failed.Status 503; .health holds the report.
UnsupportedDialectErrorA runner was given mysql or sqlite.

Types and constants

NameWhat it is
DatabaseClientOptions, BaseRepositoryOptions, TransactionOptions, ManagedTransactionOptions, TransactionRetryOptions, MigrationRunnerOptions, SeedRunnerOptions, DatabaseLockOptions, MemoryCacheOptionsOption objects for the matching factories.
DatabaseOperationOptions{ signal?, timeoutMs?, metadata? } accepted by every operation.
PaginatedResult, PaginationMeta, CursorPaginatedResult, CursorPaginationMeta, SortInput, QueryOptionsPagination shapes.
Migration, MigrationRecord, MigrationStatus, Seed, SeedRecordRunner inputs and outputs.
DatabaseEntity, SoftDeletableEntity, AuditableEntityOptional base shapes for your entity interfaces.
PrismaClientLike, RepositoryDelegate, RepositoryDelegateOperations, DatabaseTransactionContextStructural types; a stub object satisfying them works in tests. A Prisma 7 client generated with the prisma-client generator satisfies PrismaClientLike, and its model delegates satisfy RepositoryDelegate, without a cast. RepositoryDelegateOperations types this.delegate inside a repository subclass.
DatabaseLogger, noopDatabaseLoggerLogger interface and a silent implementation.
DEFAULT_PAGE (1), DEFAULT_LIMIT (20), MAX_LIMIT (100), DEFAULT_HEALTH_TIMEOUT_MS (5000), DEFAULT_MIGRATION_TABLE, DEFAULT_SEED_TABLE, SUPPORTED_ISOLATION_LEVELSConstants.

COMMON MISTAKES

  • Creating a client with no prisma and no adapter. createDatabaseClient({}) throws DatabaseError: DatabaseClient requires either a pre-built prisma client or a Prisma driver adapter. Pass one of them.
  • Using the root repository inside a transaction. users.update(...) inside withTransaction runs on the normal connection and is not rolled back with the rest. Call users.withTransaction(tx) and use that copy.
  • Wrong modelName for the Prisma model. withTransaction(tx) looks up tx.user from modelName: "User". If your model is UserAccount, set modelName: "UserAccount" or pass delegateKey: "userAccount", otherwise it throws Transaction client has no "user" delegate.
  • Passing getDatabase(options) twice. The second call throws a TypeError because the shared instance already exists. Configure it once at startup with connectDatabase(options); everywhere else call getDatabase() with no arguments.
  • Expecting timeoutMs to cancel the SQL. The promise rejects, but the statement keeps running on PostgreSQL. Configure statement_timeout on the connection for server-side cancellation.
  • Filling the cache inside a transaction. If the transaction rolls back, other callers read a value that never existed. Populate MemoryDatabaseCache only after the transaction commits.

COMPLETE EXPORT INDEX

Every name @zudojs/database exports from its package root at v1.2.1 — 275 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 275 exports
Classes (15)
BaseRepository Database DatabaseAbortError DatabaseClient DatabaseConnectionManager DatabaseLockManager DatabaseUnhealthyError DatabaseUnitOfWork MemoryDatabaseCache MigrationRunner QueryBuilder RelationRegistry SeedRunner TransactionManager UnsupportedDialectError
Functions (147)
acquireAdvisoryLock allOf and anyOf assertDatabaseHealth between buildKeysetWhere buildLockClause buildPrismaTransactionOptions calculateOffset calculateTotalPages checkDatabaseHealth checkDatabaseReadiness cloneFilter condition connectDatabase contains createAbortError createCacheKey createConnectionManager createCursorPaginatedResult createCursorPaginationMeta createDatabase createDatabaseCache createDatabaseClient createKeysetCursor createKeysetPage createLockManager createMigrationRunner createPaginatedResult createPaginationMeta createQueryBuilder createRelationRegistry createSeedRunner createTransactionContext createTransactionId createTransactionManager createUnitOfWork dateOnly dateRange decodeCursor decodeKeysetCursor disconnectDatabase encodeCursor endsWith equals escapeCachePart executeUnitOfWork flattenAnd fnv1a64 fromObject getCurrentVersion getDatabase getDatabaseErrorCode getDatabaseErrorKind getHealthCheckCause getItemRange getLatestVersion getNextPage getOrSet getPreviousPage getSqlDialect getTransactionContextFromError greaterThan greaterThanOrEqual hasConditions hashLockKey includeRelation includeRelations inList invalidateByPrefix isAfter isBefore isBetween isCollectionRelation isConflictError isDatabaseErrorLike isDatabaseHealthy isEmpty isNotEmpty isNotFoundError isNotNull isNull isPrismaError isPrismaErrorLike isRelationType isRetryableTransactionError isSingleRelation isSqlDialectName isTransactionActive isTransactionCommitted isTransactionFailed isValidPage lessThan lessThanOrEqual lockRow manyToMany manyToOne mapRepositoryError matchesPattern noneOf normalizeAdvisoryKey normalizeAdvisoryKeyPair normalizeCursorPagination normalizeDatabaseError normalizeLimit normalizeMigrations normalizePage normalizePagination normalizeSeeds not notCondition notEquals notInList oneOf oneToMany oneToOne optionalContains optionalEquals or paginateCollection quoteIdentifier raceAbort relational resetDatabase resolveLockTransactionOptions serializeCachePart startsWith throwIfAborted toDatabaseErrorInfo toDatabaseOperation toPrismaArgs toPrismaInclude toPrismaOrderBy toPrismaSelect toPrismaSkipTake toPrismaWhere validateCursorPayload validateIdentifier validateInclude validateLockKey validateMigration validateRelation validateSeed withDatabaseErrorMetadata withTransaction withTransactionRetry
Interfaces (73)
AuditableEntity BaseRepositoryOptions CacheEntry CacheOptions CacheStats CursorPaginatedResult CursorPaginationInput CursorPaginationMeta CursorQueryOptions DatabaseCache DatabaseClientHealth DatabaseClientOptions DatabaseConnectionEventDetails DatabaseConnectionManagerOptions DatabaseConnectionOptions DatabaseEntity DatabaseErrorInfo DatabaseHealthOptions DatabaseLockOptions DatabaseLockResult DatabaseLogger DatabaseOperationOptions DatabaseReadiness DatabaseReconnectOptions DecodeCursorOptions EncodeCursorOptions KeysetPageOptions ManagedTransactionOptions MemoryCacheOptions Migration MigrationRecord MigrationResult MigrationRunnerOptions MigrationStatus NormalizeDatabaseErrorOptions NormalizedPagination PaginatedResult PaginationInput PaginationMeta PrismaClientLike PrismaDriverAdapterLike PrismaErrorLike PrismaQueryArgs PrismaQueryEvent PrismaTransactionOptions QueryBuilderState QueryCondition QueryFilter QueryOptions RelationDefinition RelationInclude RelationLoadOptions Repository RepositoryDelegate RepositoryErrorContext Seed SeedRecord SeedResult SeedRunnerOptions SeedStatus SoftDeletableEntity SoftDeletableRepository SoftDeleteOptions SortInput SqlDialect ToPrismaArgsOptions ToPrismaIncludeOptions TransactionContext TransactionOptions TransactionOutcome TransactionRetryOptions UnitOfWork UnitOfWorkOptions
Type aliases (25)
CursorPayload DatabaseConnectionEvent DatabaseConnectionListener DatabaseErrorKind DatabaseHealth DatabaseHealthInfo DatabaseHealthStatus DatabaseLockMode DatabaseOperation DatabaseStatus DatabaseTransactionContext KeysetWhere PrismaWhere QueryOperator RawQueryOptions RelationOperator RelationType RepositoryOperation RunnerTransactionOptions SortDirection SqlDialectName TransactionCallback TransactionClientLike TransactionIsolationLevel TransactionStatus
Constants (15)
CACHE_KEY_SEPARATOR DEFAULT_HEALTH_TIMEOUT_MS DEFAULT_INCLUDE_DEPTH DEFAULT_LIMIT DEFAULT_MIGRATION_LOCK DEFAULT_MIGRATION_TABLE DEFAULT_PAGE DEFAULT_SEED_LOCK DEFAULT_SEED_TABLE DEFAULT_SQL_DIALECT MAX_LIMIT noopDatabaseLogger RETRYABLE_DATABASE_CODES SQL_IDENTIFIER_PATTERN SUPPORTED_ISOLATION_LEVELS