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 twelve tables (chatpack_conversations,
chatpack_conversation_participants, chatpack_messages,
chatpack_message_search_tokens, chatpack_message_reactions,
chatpack_message_mentions, chatpack_conversation_invites,
chatpack_join_requests, chatpack_user_blocks,
chatpack_conversation_mutes, chatpack_moderation_reports,
chatpack_user_bans). Users are
referenced by id only - there is no foreign key into your users table.
Upgrading an existing database? Re-run the migration before deploying the upgrade. Groups
added type / name columns on chatpack_conversations, a role column on
chatpack_conversation_participants, and made pair_key nullable behind a partial unique
index; earlier releases added chatpack_message_reactions and reply_to_message_id. Then run
backfillMessageSearchTokens once for existing messages. Every statement is idempotent, so
re-running the whole script is safe and preserves your data and seq counters.
The invite migration is gentler than the group one: chatpack_conversation_invites and
chatpack_join_requests are pure table additions - no column changes, no index swaps on
existing tables - so that part is safe to apply before deploying the new code.
The channel migration is gentle too: visibility and join_policy are added to
chatpack_conversations with NOT NULL DEFAULT values, so every existing conversation becomes a
private, approval-gated one without a backfill - and old code that never selects the columns keeps
working. It also adds chatpack_conversations_public_idx, a partial index (WHERE visibility = 'public') so the directory query doesn't index every private conversation you own.
Mentions and forwarding are gentle too: chatpack_message_mentions is a pure table addition, and
forwarding adds three nullable columns (forwarded_from_message_id,
forwarded_from_conversation_id, forwarded_from_sender_id) to chatpack_messages with no
defaults to backfill - every existing message reads back as un-forwarded. The provenance columns
deliberately have no foreign key: a forward is a copy living in a conversation the source's
participants may have no part in, so a cascade from the source would delete their messages. Its
index is partial (WHERE forwarded_from_message_id IS NOT NULL), because a btree indexes NULLs
and almost every message is not a forward.
Moderation is gentle as well: chatpack_user_blocks, chatpack_conversation_mutes,
chatpack_moderation_reports and chatpack_user_bans are pure table additions with no
changes to existing tables, so they too are safe to apply before deploying the new code.
The group migration rewrites the pair-key index rather than adding one: a partial unique index
(WHERE pair_key IS NOT NULL) lets unlimited null-keyed groups coexist with one-DM-per-pair.
Postgres can't swap a total index for a partial one under the same name, so the migration drops
chatpack_conversations_pair_key_idx and creates chatpack_conversations_pair_key_unique_idx.
Existing DM participants are backfilled to admin - a DM has no hierarchy, so neither participant
should be able to out-rank the other.
Re-export the schema and generate a migration like any other table you own:
export * from "@chatpack/adapter-drizzle"; // conversations, participants, messages, reactions, invites, moderationdrizzle-kit generate && drizzle-kit migrateNeon and serverless runtimes
Chatpack message writes use db.transaction(). Use Neon's WebSocket Pool, not
the Neon HTTP driver:
import { Pool, neonConfig } from "@neondatabase/serverless";
import { attachDatabasePool } from "@vercel/functions";
import { drizzle } from "drizzle-orm/neon-serverless";
import ws from "ws";
neonConfig.webSocketConstructor = ws;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
attachDatabasePool(pool);
export const db = drizzle({ client: pool });The Neon HTTP driver cannot run the transactions required for message ordering and group mutations. Current Chatpack deployments must use a Node.js runtime or another transaction-capable Drizzle Postgres driver.
Real-time on serverless: the default SSE transport is in-process, so on Workers/Lambda-style
platforms poll instead of /stream - @chatpack/client does that
automatically. 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) WHERE pair_key IS NOT NULL DO NOTHING+ re-select against the partial uniquepair_keyindex, so concurrent find-or-create calls converge. The repeatedWHEREis required: Postgres only matches anON CONFLICTtarget to a partial index when the predicate is restated. - Groups are created, never found - a group has
pairKey: null, so it takes a plainINSERTwith no conflict target, and the conversation row plus every participant row are written in onedb.transaction. A half-created group would be unreadable and therefore unrepairable. - Idempotent membership -
addParticipantsusesON CONFLICT (conversation_id, user_id) DO NOTHING, neverDO UPDATE, so a retried request can't demote an admin back tomember. Like reactions, membership writes issue noUPDATEon the conversation. - Idempotent reactions - the same shape:
ON CONFLICT (message_id, user_id, emoji) DO NOTHINGagainst a unique index on the triple, so five concurrent identical reactions collapse to one row. Reacting deliberately issues noUPDATEon the conversation, so it can't advancelast_seq/last_activity_ator reorder the conversation list. - A use cap that actually caps -
consumeInvitechecks usability and increments in one statement (UPDATE ... SET uses = uses + 1 WHERE code = $1 AND (max_uses IS NULL OR uses < max_uses) AND (expires_at IS NULL OR expires_at > now()) RETURNING *), so five simultaneous redemptions of amaxUses: 1link admit exactly one person. Zero rows back means "spent", which core turns into410. Join requests are the one place that does useDO UPDATE- a re-ask has to replace a stale denial with a freshpendingrow. - Channel joins ride the same idempotency - a self-join into an
"open"channel goes throughaddParticipants, so eight concurrent joins by one user leave one participant row. The directory query filters ontype = 'group' AND visibility = 'public'(both, not just visibility) and readsvisibility/join_policythrough a narrowing coercion, so a legacyNULLor a hand-edited value comes back as"private"/"approval"instead of leaking out of the union.
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?
Supabase projects that expose PostgREST through the Supabase JS client should use the first-party Supabase adapter. Convex, Firestore, and other stores without a first-party adapter 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.