Writing a Custom Adapter
Implement the StorageAdapter contract for any database - invariants, reference schema, skeleton, pitfalls, and a verification checklist.
Only needed on platforms where the official adapters don't fit - Supabase/PostgREST clients, Convex, Firebase, DynamoDB, proprietary AI-builder clouds.
Decide if you even need one
- Demos / tests / a single long-lived process:
@chatpack/adapter-memory. - Any Postgres you can reach with a connection string (Neon, RDS, Railway,
Fly, Replit, Vercel Postgres):
@chatpack/adapter-drizzle. Do not write a custom adapter for these. - Postgres behind an HTTP client only (e.g. Supabase JS), or a non-SQL store (Convex, Firestore, DynamoDB): write a custom adapter - continue below.
The contract
Implement the StorageAdapter interface exported from @chatpack/core and
pass it as chatpack({ storage: yourAdapter() }). Ten async methods:
| Method | Contract |
|---|---|
getOrCreateDirectConversation | Find or atomically create by pairKey - concurrent calls must converge |
getConversation | Fetch by id (with both participants), or null |
listConversations | A user's conversations, most-recently-active first, cursor-paginated |
addMessage | Persist + assign the next strictly-increasing seq for that conversation |
getMessage | Fetch by id, or null |
listMessages | Per-conversation history, newest-first (descending seq), cursor-paginated |
listMessagesAfterSeq | Messages with seq > afterSeq, oldest first (SSE/polling gap-fill) |
updateMessage | Patch body / editedAt / deletedAt in place; throw if id unknown |
updateLastRead | Set a participant's lastReadMessageId |
countUnread | Batched per-conversation unread counts for one viewer (invariant 10) |
Exact TypeScript signatures and per-method TSDoc ship in the package's
.d.ts (@chatpack/core, storage.ts module).
The entities you store are described in Conversations & Messages. Users are opaque string ids - do not add foreign keys into your users table and do not validate user existence in the adapter.
The invariants
These are database-agnostic - satisfy every one of them however your platform does atomicity. Everything else is implementation detail.
- One conversation per
pairKey, even under concurrency. Core computespairKey(sorted user ids joined with":") and hands it to you. Two concurrentgetOrCreateDirectConversationcalls with the samepairKeymust converge on a single conversation (created: truefor at most one caller). A JS-side "select, then insert if missing" is NOT enough - enforce uniqueness in the database (unique index + insert-on-conflict, transaction, or your platform's equivalent). seqis strictly increasing per conversation and assigned atomically with the message insert. Never reused, never decreasing; gaps are allowed. Concurrent sends to the same conversation must serialize. Do NOT computeMAX(seq) + 1in application code - that races.- Adapters generate ids.
getOrCreateDirectConversationandaddMessagecreate theidthemselves (any unique string; the official adapters use prefixed random ids likemsg_<uuid>). Ids must be unique and stable. - Adapters never enforce permissions. Core validates participants and permission hooks before calling you. Return whatever is asked for. Corollary for hosted platforms: the adapter must run server-side with full DB privileges (e.g. the Supabase service-role key), and the Chatpack tables must NOT be readable or writable by browser/anon clients - otherwise users can read each other's messages around Chatpack entirely.
- Cursors are opaque strings that the adapter defines. Core (and the
HTTP layer) round-trip your
nextCursorback intoinput.cursorverbatim, without inspecting it. Pick any encoding that survives a URL query parameter. ReturnnextCursor: nullwhen there are no more results. Reference encodings:listMessages- theseqof the last message on the page;listConversations-"<lastActivityMs>:<conversationId>"(keyset) or simply the last conversation id. - Ordering.
listMessages: newest-first (descendingseq).listMessagesAfterSeq: oldest-first (ascendingseq), onlyseq > afterSeq, capped atlimit.listConversations: most-recently-active first (latest message activity, falling back to conversation creation time), with a stable tiebreak so pagination never skips or repeats. - Return real
Dateinstances, never ISO strings. The interface types sayDateand core does not coerce. Database drivers and HTTP clients (PostgREST, Convex, Firestore) often hand back strings or numbers - convert at the adapter boundary, both directions. JSON serialization is the HTTP handler's job, never yours. - Soft delete is an update, not a removal. Core calls
updateMessage({ body: "", deletedAt }); the row must remain (clients render a tombstone) and must keep itsseq. Never hard-delete.updateMessagepatches only the fields that are defined on the input and throws on unknown ids;updateLastReadoverwriteslastReadMessageIdunconditionally (core has already validated the message). metadatais an arbitrary JSON object. Store and return it losslessly (default{}).countUnreadis exact and batched. For each requested conversation, count messages withseqstrictly greater than the seq of the viewer'slastReadMessageId(nullread-state = 0, i.e. everything) ANDsenderId !== userId(a viewer's own messages are never unread). Soft-deleted messages keep theirseqand DO count; all roles count. Return{ [conversationId]: count }; ids the viewer doesn't participate in may be omitted or 0 (core treats missing as 0). One call covers a whole page of conversations - do it in one query, not one per id. NotelastReadMessageIdis a message id, not a seq: resolve it at query time (SQL: LEFT JOIN the message row +COALESCE(read.seq, 0)).
Reference schema (Postgres)
If your store speaks SQL, use the official schema as-is; if not, translate the
shapes and satisfy the invariants instead. Also available programmatically:
import { migrationSql, chatpackSchema } from "@chatpack/adapter-drizzle"
(migrationSql is plain idempotent DDL - no Drizzle required to execute it).
CREATE TABLE IF NOT EXISTS "chatpack_conversations" (
"id" text PRIMARY KEY,
"pair_key" text NOT NULL,
"created_at" timestamptz NOT NULL,
"metadata" jsonb NOT NULL DEFAULT '{}',
"last_seq" integer NOT NULL DEFAULT 0,
"last_activity_at" timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "chatpack_conversations_pair_key_idx"
ON "chatpack_conversations" ("pair_key");
CREATE INDEX IF NOT EXISTS "chatpack_conversations_activity_idx"
ON "chatpack_conversations" ("last_activity_at", "id");
CREATE TABLE IF NOT EXISTS "chatpack_conversation_participants" (
"conversation_id" text NOT NULL REFERENCES "chatpack_conversations"("id") ON DELETE CASCADE,
"user_id" text NOT NULL,
"joined_at" timestamptz NOT NULL,
"last_read_message_id" text
);
CREATE UNIQUE INDEX IF NOT EXISTS "chatpack_participants_conv_user_idx"
ON "chatpack_conversation_participants" ("conversation_id", "user_id");
CREATE INDEX IF NOT EXISTS "chatpack_participants_user_idx"
ON "chatpack_conversation_participants" ("user_id");
CREATE TABLE IF NOT EXISTS "chatpack_messages" (
"id" text PRIMARY KEY,
"conversation_id" text NOT NULL REFERENCES "chatpack_conversations"("id") ON DELETE CASCADE,
"sender_id" text NOT NULL,
"body" text NOT NULL,
"role" text NOT NULL DEFAULT 'user',
"seq" bigint NOT NULL,
"created_at" timestamptz NOT NULL,
"edited_at" timestamptz,
"deleted_at" timestamptz,
"metadata" jsonb NOT NULL DEFAULT '{}'
);
CREATE UNIQUE INDEX IF NOT EXISTS "chatpack_messages_conv_seq_idx"
ON "chatpack_messages" ("conversation_id", "seq");How the reference implementation satisfies the two hard invariants:
-
Atomic
seq- one statement, row-locked by Postgres:UPDATE chatpack_conversations SET last_seq = last_seq + 1, last_activity_at = $now WHERE id = $conversationId RETURNING last_seq;then insert the message with the returned value. The unique index on
(conversation_id, seq)backstops the invariant. On Supabase/PostgREST you cannot express this UPDATE through the client - wrap it in a SQL function and call it via RPC. -
Idempotent pair creation -
INSERT ... ON CONFLICT (pair_key) DO NOTHING, then re-select bypair_key; zero inserted rows means a concurrent call won and both converge on the same row. -
Batched unread counts - one
GROUP BYquery per page; a LEFT JOIN resolveslast_read_message_idto itsseq(invariant 10). The unique index on(conversation_id, seq)makes each count an index range scan:SELECT m.conversation_id, count(*) FROM chatpack_messages m JOIN chatpack_conversation_participants p ON p.conversation_id = m.conversation_id AND p.user_id = $userId LEFT JOIN chatpack_messages r ON r.id = p.last_read_message_id WHERE m.conversation_id = ANY($conversationIds) AND m.sender_id <> $userId AND m.seq > COALESCE(r.seq, 0) GROUP BY m.conversation_id;Conversations absent from the result have zero unread.
Non-SQL platforms (Convex, Firestore, ...)
Ignore the SQL; keep the three-collection shape and satisfy the invariants
with your platform's transaction primitive. Example - on Convex, each adapter
method becomes a Convex query/mutation (DB access only exists inside Convex
functions) and the StorageAdapter object calls them through the Convex
client; addMessage is a single mutation (read counter → increment → insert),
which is automatically a serializable transaction, satisfying invariant 2 with
no extra machinery. Enforce invariant 1 with an index/lookup on pairKey
inside one mutation.
Skeleton
import type { StorageAdapter } from "@chatpack/core";
export function myAdapter(client: MyDbClient): StorageAdapter {
return {
async getOrCreateDirectConversation({ pairKey, userIds, metadata }) {
// 1. Attempt atomic create (unique on pairKey; generate the id here).
// 2. On conflict, fetch the existing conversation by pairKey.
// return { conversation, created };
},
async getConversation(conversationId) {
// Fetch conversation + BOTH participant rows, or return null.
},
async listConversations({ userId, limit, cursor }) {
// Conversations where userId is a participant, most-recently-active
// first; keyset-paginate; you define the cursor encoding.
// return { conversations, nextCursor }; // nextCursor: string | null
},
async addMessage({ conversationId, senderId, body, role, metadata }) {
// Atomically bump the conversation's seq counter and insert the
// message with that seq (single transaction / RPC / mutation).
// return message; // with adapter-generated id and real Date fields
},
async getMessage(messageId) {
// Fetch by id, or return null.
},
async listMessages({ conversationId, limit, cursor }) {
// Descending seq; cursor = e.g. String(lastMessage.seq).
// return { messages, nextCursor };
},
async listMessagesAfterSeq({ conversationId, afterSeq, limit }) {
// Ascending seq, seq > afterSeq, up to limit. Powers gap-fill.
// return messages;
},
async updateMessage({ messageId, body, editedAt, deletedAt }) {
// Patch ONLY the defined fields; throw if messageId is unknown.
// return updatedMessage;
},
async updateLastRead({ conversationId, userId, messageId }) {
// Set that participant's lastReadMessageId.
},
async countUnread({ userId, conversationIds }) {
// For each conversation: count messages with seq > the seq of this
// user's lastReadMessageId (null -> 0) AND senderId !== userId.
// Tombstones count. One batched query, not one per id (invariant 10).
// return { [conversationId]: count };
},
};
}Pitfalls
Each of these has bitten a real integration:
- Checking pair existence in JS before inserting (races; use DB uniqueness).
MAX(seq) + 1computed in application code (races; use an atomic counter).- Returning ISO strings where the contract says
Date. - Hard-deleting messages, or dropping
seq/row on delete (must tombstone). listMessagesreturned oldest-first (must be newest-first), orlistMessagesAfterSeqreturned newest-first (must be oldest-first).- Stubbing
nextCursoras always-null- pagination silently breaks once history exceeds one page. - Exposing
chatpack_*tables to browser/anon database clients (bypasses Chatpack's permission layer; adapter must be server-only with privileged credentials). - Adding a foreign key to the app's users table (Chatpack never owns users).
- Enforcing "is sender a participant?" in the adapter (core already did).
- Counting the viewer's own messages as unread in
countUnread(they never are), or issuing one count query per conversation (N+1; batch the page).
Verify your adapter
Run these checks before calling it done. All of them go through the normal
Chatpack API (chat.api.* or the HTTP routes) - if any fails, the bug is in
the adapter.
- Round trip. Create a conversation (A→B), send a message, restart the
process (or hit a fresh serverless isolate), list messages - the message is
still there with the same
id,seq: 1, andcreatedAtas a valid timestamp. - Pair idempotency, both directions.
getOrCreateConversation(A→B)then(B→A)- same conversation id both times, and only one row/document exists. - Pair idempotency, concurrent. Fire 5+ parallel
getOrCreateConversationcalls for the same new pair - exactly one conversation exists afterwards. - Seq under concurrent sends. Fire 10 parallel
sendMessagecalls into one conversation - afterwards all 10seqvalues are distinct and strictly increasing; no duplicates, no failures. - Pagination walk. Send 25 messages, list with
limit: 10, follownextCursoruntilnull- you get exactly 25 unique messages, newest-first, no repeats, no gaps. Same walk forlistConversations. - Gap-fill. After sending messages 1..N,
listMessagesAfterSeqwithafterSeq: 3returns 4..N oldest-first. - Edit + soft delete. Edit a message (body changes,
editedAtset); delete it (body"",deletedAtset, still present inlistMessageswith its originalseq); editing the deleted message now fails withMESSAGE_DELETED. - Read-state.
markReadthen re-fetch the conversation - that participant'slastReadMessageIdis updated and survives a restart. Bonus: markRead with an OLDER message afterwards is a no-op (core enforces monotonicity; your adapter just stores what it's given). - Unread counts. B sends 3 messages;
getConversationas A showsunreadCount: 3, as B shows0(own messages never count). A marks the 2nd read - A now sees1. Delete the 3rd as B - A still sees1(tombstones count). - Types at the boundary.
createdAt instanceof Dateistrueon returned conversations and messages (catches ISO-string leakage). - Privilege check (hosted DBs). With the browser/anon credentials, a
direct read of
chatpack_messagesreturns nothing/denied.
Real-time plugins and custom adapters
typing(), presence(), receipts() publish ephemeral events on the SSE
stream - never stored, never replayed - so custom storage adapters need zero
changes to support them.