Database
Prisma against Postgres, with tenant scoping built in.
@kreogen/database is Prisma talking to Postgres through
@prisma/adapter-pg — the standard node-postgres driver. That works against
any Postgres: the docker-compose service, a managed instance, or one running
beside the app.
There is no serverless database provider involved, and no vendor driver to
replace when the project moves. If you do want Neon's
serverless driver, swap the adapter for @prisma/adapter-neon and set
neonConfig.webSocketConstructor; nothing else changes.
The package imports server-only. It is the only package in the repository
with a main field, so @kreogen/database resolves to index.ts rather than
to a subpath — and it must never be reachable from a client component.
Usage
import { database } from '@kreogen/database';
const Page = async () => {
const users = await database.user.findMany();
};That client is unscoped. For anything belonging to an organization, use the scoped one instead — see tenant scoping below.
Connection pooling
Next runs one pool per server instance, and the driver's default of ten
connections is easy to multiply past Postgres' max_connections once you scale
replicas. The pool is therefore stated rather than inherited:
| Setting | Value |
|---|---|
max | DATABASE_POOL_MAX, default 10 |
idleTimeoutMillis | 30,000 |
connectionTimeoutMillis | 10,000 |
DATABASE_POOL_MAX is read straight from the environment rather than through
keys(), because it is an operational dial rather than a feature switch.
Lower it further behind PgBouncer, where the app's pool is in front of a pool.
The client is constructed lazily and cached on globalThis outside production.
Creating the adapter eagerly on every module evaluation leaked a connection
pool per hot reload in development.
Schema
packages/database/prisma/schema.prisma. Nine models ship: User, Session,
Account, Verification, Organization, Member and Invitation belong to
Better Auth, plus two of the template's
own:
Page— a stub. Delete it and add your own. It carries anorganizationIdon purpose, because that is the shape every model you add should have.WebhookEvent— the idempotency ledger for inbound webhooks. Its primary key is the provider's event id, so recording an event before handling it makes a redelivery a no-op.
Seven of those nine are generated from the auth configuration. Running
bun run auth:generate rewrites schema.prisma and does not restore
hand-written directives — the @unique on stripeCustomerId is the one that
has been lost before. Review the diff in full every time.
Adding a model
model Post {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
title String
content Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@map("post")
}Then add it to TENANT_SCOPED_MODELS in packages/database/index.ts. Forget
that and forOrganization silently does nothing for the new model — which is
why a test derives the list from the schema and fails when the two drift.
Migrations
A baseline migration is committed at
packages/database/prisma/migrations/20260815000000_init. A fresh clone
therefore gets a working database from migrate deploy alone, with no
db push step and no schema that exists only in someone's local Postgres.
| Command | Does |
|---|---|
bun run migrate | Format, regenerate the client, create and apply a migration |
bun run migrate:deploy | Apply existing migrations only. Safe in CI and production |
bun run db:push | Push the schema with no migration file. Prototyping only |
db:push maintains no history and can drop columns without asking. It is for
the hour before you know what the model should be, not for anything with data
in it.
CI proves the two stay in step: the migrations job applies the committed SQL
to an empty Postgres, then runs prisma migrate diff --exit-code, which fails
if schema.prisma was edited without a migration to match.
Seeding
bun run db:seedTwo organizations — Northwind and Initech — each with an owner, a member, a pending invitation and a few rows of the stub model. It is idempotent, so re-running against a populated database is safe.
The second organization is the point. A single one proves nothing about scoping; two mean the tenancy example has rows that must never appear in the other's views.
Seeded users have no credentials. Better Auth owns password hashing, and reproducing its parameters here would couple the seed to its internals — sign up through the UI for an account you can actually log in with.
Tenant scoping
A query that forgets where: { organizationId } returns another tenant's rows,
and nothing in Prisma will stop it. forOrganization returns a client that
cannot make that mistake:
import { forOrganization } from '@kreogen/database';
const db = forOrganization(orgId);
await db.page.findMany(); // only this organization's rows
await db.page.create({ data: { name } }); // organizationId forced inUsually you will not call it directly —
requireOrganization() hands you the
scoped client already:
const { db, orgId } = await requireOrganization();Returning the scoped client rather than a bare id is deliberate. An id that has
to be threaded into every where clause by hand is one somebody eventually
forgets.
What it does
Every operation against a model in TENANT_SCOPED_MODELS gets
organizationId forced into its filter, and creates get it forced into their
data — overriding whatever the caller passed, so a request body cannot choose
its own tenant.
findUnique and findUniqueOrThrow accept only unique fields in where, so
adding organizationId to them is a validation error. They are rewritten to
findFirst and findFirstOrThrow, which keeps the same semantics — one row or
null — while allowing the filter.
Passing a falsy id throws rather than proceeding. A blank value would scope every query to nothing, or, once a filter is dropped, to everything.
What it does not do
$queryRaw and $executeRaw bypass Prisma extensions entirely and are your
responsibility. This is a safety net, not a licence to stop thinking.
Nor does it help with the auth tables. User, Organization and friends are
deliberately unscoped — the unscoped database export exists for them and for
admin work.
Editing data
Prisma Studio is a visual editor for whatever is in the database:
bun dev --filter studio