Chatpack
Storage

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 pg

drizzle-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:

db/schema.ts
export * from "@chatpack/adapter-drizzle"; // conversations, participants, messages
drizzle-kit generate && drizzle-kit migrate

Serverless / 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 - seq is assigned by an atomic UPDATE ... 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 unique pair_key index, 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.

On this page