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

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

Neon 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 - 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) WHERE pair_key IS NOT NULL DO NOTHING + re-select against the partial unique pair_key index, so concurrent find-or-create calls converge. The repeated WHERE is required: Postgres only matches an ON CONFLICT target to a partial index when the predicate is restated.
  • Groups are created, never found - a group has pairKey: null, so it takes a plain INSERT with no conflict target, and the conversation row plus every participant row are written in one db.transaction. A half-created group would be unreadable and therefore unrepairable.
  • Idempotent membership - addParticipants uses ON CONFLICT (conversation_id, user_id) DO NOTHING, never DO UPDATE, so a retried request can't demote an admin back to member. Like reactions, membership writes issue no UPDATE on the conversation.
  • Idempotent reactions - the same shape: ON CONFLICT (message_id, user_id, emoji) DO NOTHING against a unique index on the triple, so five concurrent identical reactions collapse to one row. Reacting deliberately issues no UPDATE on the conversation, so it can't advance last_seq / last_activity_at or reorder the conversation list.
  • A use cap that actually caps - consumeInvite checks 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 a maxUses: 1 link admit exactly one person. Zero rows back means "spent", which core turns into 410. Join requests are the one place that does use DO UPDATE - a re-ask has to replace a stale denial with a fresh pending row.
  • Channel joins ride the same idempotency - a self-join into an "open" channel goes through addParticipants, so eight concurrent joins by one user leave one participant row. The directory query filters on type = 'group' AND visibility = 'public' (both, not just visibility) and reads visibility / join_policy through a narrowing coercion, so a legacy NULL or 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.

On this page