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

page.tsx
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:

SettingValue
maxDATABASE_POOL_MAX, default 10
idleTimeoutMillis30,000
connectionTimeoutMillis10,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 an organizationId on 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

packages/database/prisma/schema.prisma
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.

CommandDoes
bun run migrateFormat, regenerate the client, create and apply a migration
bun run migrate:deployApply existing migrations only. Safe in CI and production
bun run db:pushPush 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:seed

Two 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 in

Usually 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