Skip to content

Custom store & adapter

The ChatStore and StorageAdapter interfaces are the complete persistence contract. Any implementation that satisfies them — Prisma, DynamoDB, S3/R2, a test double — is interchangeable with the built-in Drizzle and Supabase defaults. Same handler, same security model.

The factory shape

Both contracts follow the same pattern: a factory function that accepts the server-verified userId and returns a bound instance.

code
import type { ChatStore, ChatStoreFactory } from '@mordn/chat-widget/server';
import type { StorageAdapter, StorageAdapterFactory } from '@mordn/chat-widget/server';
 
const myStore: ChatStoreFactory = (userId) => ({
  userId,
 
  // Conversations
  listConversations: async () => { /* … */ },
  getConversation: async (id) => { /* … */ },
  ensureConversation: async (id, init) => { /* … */ },
  renameConversation: async (id, title) => { /* … */ },
  deleteConversation: async (id) => { /* … */ },
 
  // Messages
  listMessages: async (conversationId, opts) => { /* … */ },
  saveTurn: async (input) => { /* … */ },
});

Pass the factory — not an instance — to createChatHandler:

code
export const { GET, POST, DELETE } = createChatHandler({
  getUserId: getChatUserId,
  model: anthropic('claude-sonnet-4-5'),
  store: myStore,
  storage: myStorage, // optional; omit to disable uploads
});

Why no userId on the methods

Invariants you must uphold

These are load-bearing security and correctness contracts, not advisory guidelines. The full signatures and per-method contracts are at ChatStore and StorageAdapter.

ChatStore invariants

  • getConversation returns null for a missing conversation AND for one owned by a different user — the two cases must be indistinguishable. Never return another user's row.
  • ensureConversation throws ConversationOwnershipError (imported from @mordn/chat-widget/server) if the id exists but belongs to a different user. This is the single chokepoint that makes "write into someone else's conversation" impossible.
  • listMessages scopes to the bound user. A foreign conversationId returns [], not the other user's messages.
  • saveTurn verifies ownership independently — defence in depth even though the handler already called ensureConversation. Idempotent on message id: a message whose id already exists is skipped, not duplicated.
  • listMessages clamps limit to a server-side ceiling (max 100 is conventional) so a hostile client cannot request an unbounded page.

StorageAdapter invariants

  • Private at rest. Never write to a public bucket or return a permanent public URL.
  • Signed, short-lived reads. upload and resign return expiring signed URLs only.
  • User-namespaced paths. Derive the storage path from the bound userId; never let callers inject a path. resign and remove must verify the path belongs to the bound user's namespace before acting on it.
  • resign returns null (never throws) when the object is missing or outside the namespace — one broken blob must not take down a conversation history load.
  • remove is idempotent. Deleting a missing object is a no-op.

Example skeleton

code
lib/my-chat-store.ts
import {
  ConversationOwnershipError,
  type ChatStore,
  type ChatStoreFactory,
} from '@mordn/chat-widget/server';
 
export const myStore: ChatStoreFactory = (userId) => ({
  userId,
 
  async listConversations() {
    return db.conversations.findMany({
      where: { userId },
      orderBy: { updatedAt: 'desc' },
    });
  },
 
  async getConversation(id) {
    const row = await db.conversations.findFirst({ where: { id, userId } });
    return row ?? null; // null for missing OR foreign — same result
  },
 
  async ensureConversation(id, init) {
    const existing = await db.conversations.findFirst({ where: { id } });
    if (existing && existing.userId !== userId) {
      throw new ConversationOwnershipError(id);
    }
    return db.conversations.upsert({
      where: { id },
      create: { id, userId, title: init?.title ?? 'New Chat' },
      update: {},
    });
  },
 
  async renameConversation(id, title) {
    await db.conversations.updateMany({ where: { id, userId }, data: { title } });
  },
 
  async deleteConversation(id) {
    const deleted = await db.conversations.deleteMany({ where: { id, userId } });
    return deleted.count > 0;
  },
 
  async listMessages(conversationId, opts) {
    const owned = await this.getConversation(conversationId);
    if (!owned) return [];
    const limit = Math.min(opts?.limit ?? 100, 100);
    return db.messages.findMany({ where: { conversationId }, take: limit });
  },
 
  async saveTurn({ conversationId, messages, model }) {
    const owned = await this.getConversation(conversationId);
    if (!owned) throw new ConversationOwnershipError(conversationId);
    for (const msg of messages) {
      await db.messages.upsert({
        where: { id: msg.id },
        create: { ...msg, conversationId, model: msg.role === 'assistant' ? model : null },
        update: {}, // idempotent — existing id wins
      });
    }
    await db.conversations.update({ where: { id: conversationId }, data: { updatedAt: new Date() } });
  },
});

Next steps