Drizzle / Postgres
Production persistence with the Drizzle ORM adapter - table creation, serverless drivers, and correctness guarantees.
@chatpack/adapter-drizzle is the production storage adapter: Postgres via
Drizzle ORM. It works with any Drizzle Postgres driver - node-postgres,
postgres.js, PGlite, Neon, Vercel Postgres.
Install
npm install @chatpack/core @chatpack/adapter-drizzle drizzle-orm pgdrizzle-orm is a peer dependency - the adapter plugs into the Drizzle
instance your app already has.
Use
import { drizzle } from "drizzle-orm/node-postgres";
import { chatpack } from "@chatpack/core";
import { drizzleAdapter } from "@chatpack/adapter-drizzle";
const db = drizzle(process.env.DATABASE_URL!);
export const chat = chatpack({
storage: drizzleAdapter(db),
auth: async (req) => getSessionUser(req),
});Creating the tables
Chatpack needs three tables (chatpack_conversations,
chatpack_conversation_participants, chatpack_messages). Users are
referenced by id only - there is no foreign key into your users table.
Re-export the schema and generate a migration like any other table you own:
export * from "@chatpack/adapter-drizzle"; // conversations, participants, messagesdrizzle-kit generate && drizzle-kit migrateServerless / edge runtimes
TCP-based drivers like pg (node-postgres) don't run on edge runtimes - use
an HTTP/WebSocket driver instead. The adapter itself is driver-agnostic, so
only the drizzle() line changes. Neon on Cloudflare Workers:
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import { chatpack } from "@chatpack/core";
import { drizzleAdapter } from "@chatpack/adapter-drizzle";
const db = drizzle(neon(env.DATABASE_URL));
export const chat = chatpack({
storage: drizzleAdapter(db),
auth: async (req) => getSessionUser(req),
});
export default { fetch: chat.handler().fetch };The same pattern works with drizzle-orm/vercel-postgres on Vercel Edge.
Real-time on serverless: the default SSE transport is in-process, so on Workers/Lambda-style
platforms poll GET /conversations/:id/messages instead of /stream - see
Deployment.
Correctness guarantees
The two things a chat backend must get right under concurrency, and how this adapter does them (ADR 0007):
- Monotonic message ordering -
seqis assigned by an atomicUPDATE ... SET last_seq = last_seq + 1 ... RETURNING; Postgres row locking serializes concurrent sends. A unique index on(conversation_id, seq)enforces the invariant at the schema level too. - One conversation per user pair - creation uses
ON CONFLICT (pair_key) DO NOTHING+ re-select against the uniquepair_keyindex, so concurrent find-or-create calls converge.
Testing
The integration suite runs the full Chatpack engine against this adapter on
PGlite - real Postgres compiled to WASM - so
pnpm test needs no Docker or external database, locally or in CI. The same
trick works for your own tests:
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { drizzleAdapter, migrationSql, type DrizzlePgDatabase } from "@chatpack/adapter-drizzle";
const pglite = new PGlite();
await pglite.exec(migrationSql);
const db = drizzle(pglite) as unknown as DrizzlePgDatabase;
const chat = chatpack({ storage: drizzleAdapter(db) });(The cast bridges a type mismatch between the drizzle-orm/pglite driver's
return type and the adapter's driver-agnostic DrizzlePgDatabase - it's what
the adapter's own test suite does.)
No connection string?
Platforms that only expose a database client (Supabase's JS client, Convex, most AI-builder clouds) are supported through a custom adapter. If you can reach Postgres with a connection string (Neon, RDS, Railway, Fly, Replit, Vercel Postgres), use this adapter - don't write a custom one.