DEV Community

Cover image for Your AI SDK chat table breaks every six months
Nikhil Rajput
Nikhil Rajput

Posted on AI-assisted

Your AI SDK chat table breaks every six months

Open lib/db/schema.ts in Vercel's ai-chatbot template and look at line 42:

export const message = pgTable("Message_v2", {
Enter fullscreen mode Exit fullscreen mode

The _v2 is a scar. When ai 5 changed the message shape, migrating the existing table in place was harder than adding a new one beside it, so the template added Message_v2 and Vote_v2, backfilled, and eventually deleted the originals. The old tables are gone now. The names still carry the version number, permanently, in the schema every new project forks.

I have done that migration by hand. I did not want to do it again.

The 25 lines everyone writes

Persisting a chat with the AI SDK is not hard, exactly. It is just fiddly in a way that produces the same file in every project:

const { id, messages } = await req.json();

const existing = await store.loadMessages(id);
const known = new Set(existing.map((m) => m.id));
const fresh = messages.filter((m) => m.role === "user" && !known.has(m.id));
if (fresh.length > 0) await store.appendMessages(id, fresh);

const history = [...existing, ...fresh];
const result = streamText({
  model: openai("gpt-5"),
  messages: await convertToModelMessages(history),
});

let persisted = false;
const persist = async ({ responseMessage }) => {
  if (persisted || responseMessage.parts.length === 0) return;
  persisted = true;
  await store.appendMessages(id, [responseMessage]);
};

return result.toUIMessageStreamResponse({
  generateMessageId: generateId,
  onEnd: persist,
  onFinish: persist,
});
Enter fullscreen mode Exit fullscreen mode

Four things in there are load-bearing and easy to get wrong.

Drop generateMessageId and your rows arrive with empty ids. Skip the known set and every reload appends the same user message again. Forget the persisted guard and a stream that ends twice writes the reply twice.

The fourth one cost me an evening. I registered only onEnd, which is ai 7's callback name. On ai 6 the name is onFinish, and 6 does not warn you that nothing is listening - it just streams a perfect answer to the user and writes nothing to the database. Passing both names is the fix, and a CI job that runs the whole suite against the older major is how I found out.

Why the obvious answers did not fit

The AI SDK's own persistence guide is good, and it is a pattern rather than a package: you copy it into each app and maintain your copy. That is fine until the copy is in four apps.

assistant-ui cloud and Convex both solve persistence properly, and both solve it by holding the data. If the conversation has to live in the database you already run - for joins, for compliance, or because your Postgres is right there - that is the wrong trade.

Forking the template gets you an app, not a dependency. You inherit its auth stack, its blob storage and its choices.

Two tables and a typed store

So: ai-sdk-threads. Threads and messages in your Postgres or SQLite. Two tables you re-export, one store you build once:

// db/schema.ts - plain drizzle objects, so your own migration tooling picks them up
export { messages, threads } from "ai-sdk-threads/drizzle";

// lib/threads.ts - from the drizzle instance you already have
export const store = createThreadStore(db);
Enter fullscreen mode Exit fullscreen mode

After npm install ai-sdk-threads drizzle-orm and a migration, the whole chat route becomes this:

export const POST = chatHandler({
  store,
  execute: ({ modelMessages }) =>
    streamText({
      model: openai("gpt-5"),
      messages: modelMessages,
    }),
});
Enter fullscreen mode Exit fullscreen mode

It loads the thread, stores the incoming message once, streams the answer, and stores the reply - with both callback names registered, so it does not silently do nothing on ai 6.

Message parts go into the database as the SDK produced them, so what comes back out is what useChat rendered: tool calls with their outputs, reasoning parts, the lot. There is no lossy projection in the middle to debug at 2am.

One thing that route is still missing before production is authorization. Thread ids come from the client, so an authorize callback is the difference between "my chat app works" and "anyone who guesses an id can read that conversation".

The part the SDK has no answer for

Regenerate an answer in ChatGPT, Claude or v0 and the old answer does not disappear - you can page back to it with a little ‹ 2/3 › control. Edit an earlier question and the original stays, on its own branch.

Almost every app built on the AI SDK throws that away, because keeping it is a storage problem wearing a UI costume. Asking for it is vercel/ai#2929, open since September 2024. Elsewhere in the ecosystem, assistant-ui tracks branches in its client runtime and @ably/ai-transport keeps a tree on its realtime channels - both leave the durable copy to you, which is the part this owns.

The model is unglamorous. Every message stores a parentId; every thread stores an activeLeafId. A regenerated answer is a second child of the same parent rather than an overwrite, and the live conversation is the walk from the active leaf back to the root:

// Regenerate: point the leaf where a fresh answer belongs, then stream into it.
await store.regenerateFrom(threadId, assistantMessageId);

// The ‹ 2/3 › control, straight from the store.
const { siblings, index } = await store.siblingsOf(threadId, assistantMessageId);

// Switch which path is live; everything downstream comes back with it.
await store.setActiveLeaf(threadId, siblings[index - 1].id);
Enter fullscreen mode Exit fullscreen mode

Nothing is ever deleted. getTree hands you every row if you want to draw the whole shape; loadMessages hands you only the live path, which is what useChat wants.

A real Postgres, in the page

Rather than ask you to install anything to believe that, the docs site compiles Postgres to WebAssembly and runs it in the tab. Regenerate an answer, switch between siblings, and watch the rows and the query log change as it happens - it is the published store writing real rows, not a mock: ai-sdk-threads.nixrajput.com/en/playground.

One layer down

Three details for anyone deciding whether to trust this with their data.

Loading a thread is 2 queries whether it holds one message or five hundred. The root-to-leaf path is walked in memory rather than with a recursive CTE, and listThreads is one query per page - a page 50,000 rows deep measured 1.13x the first page on Postgres 16 over 100,000 threads, with the harness in the repo so you can re-run it. Every operation's query count is pinned by a test, so an N+1 fails CI rather than surfacing as a slow page later.

Every row is stamped with sdk_version. That is the whole point of the exercise: when the next major lands, a migrate CLI can tell you what needs touching instead of you guessing, and there is an importer for the Vercel template's tables if you started there.

ai 6 and 7 are both gated in CI, running the full suite of 198 tests against each. That is not thoroughness for its own sake - it is the job that caught the onEnd/onFinish bug above.

What it deliberately is not

It stores conversations; it does not retrieve over them. No vector search, no summarisation, no agent orchestration - different problem, different library.

It is not chat UI. ai-elements and assistant-ui own that layer, and this stores what they render.

ai 4 and older are unsupported: 5 was a rewrite and the supported range is >=6 <8.

And there is no throughput benchmark, because persistence is not a speed story. The numbers above are query counts and test counts, which is what I can actually defend.

Try it

MIT, no runtime dependencies in the core, 1.04 kB minified and gzipped, and everything it does today stays free.

Docs and the playground: ai-sdk-threads.nixrajput.com (there is an llms.txt if you are an agent reading this).

The thing I would most like to hear: where does this storage model break for conversations bigger than mine? I have measured 100,000 threads and 500-message paths. If yours are bigger and it falls over, that is the issue I want.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The onEnd vs onFinish story is the strongest argument in the piece: ai 6 doesn't warn that nothing is listening, it streams a perfect answer and writes nothing to the database. Silent persistence failure behind a happy user is precisely the class of bug that survives review, because the visible behaviour is correct. Registering both names and gating CI on two majors is what makes it unmissable — that's the pattern I'd take even without the library.

Two things I'd push on. parentId plus activeLeafId with nothing ever deleted is the right model for regeneration, but the growth is a real cost: have you looked at how a long-lived thread behaves after many regenerations, or is there a pruning policy for branches off the live path? And you're right to flag authorization as the missing production piece — thread ids from the client means a guessed id reads someone else's tree. Does the authorize callback guard the load or each mutation? Those two questions are what every integrator hits in the first hour.