cd /news/ai-tools/your-ai-sdk-chat-table-breaks-every-… · home topics ai-tools article
[ARTICLE · art-128905] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Your AI SDK chat table breaks every six months

A developer released ai-sdk-threads, an npm package that persists AI SDK chat threads and messages directly into an existing Postgres or SQLite database via Drizzle, replacing the copy-and-maintain persistence pattern used in Vercel's ai-chatbot template. The package wraps the chat route in a single chatHandler that loads the thread, stores the incoming message once, streams the reply, and persists it, registering both the onEnd and onFinish callbacks so persistence does not silently fail across AI SDK major versions. The author says the work was motivated by hand-running the template's Message_v2 migration and by callback-name drift between ai 6 and ai 7.

by read6 min views1 publishedSep 14, 2026

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

export const message = pgTable("Message_v2", {

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.

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,
});

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.

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.

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);

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,
    }),
});

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".

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);

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.

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.

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

** 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.

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.

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @ai-sdk-threads 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-ai-sdk-chat-tab…] indexed:0 read:6min 2026-09-14 ·