DEV Community

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

Posted on Edited on

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 (5)

Collapse
 
raju_dandigam profile image
Raju Dandigam •

@nixrajput, keeping parentId and activeLeafId makes regeneration honest, and gating both SDK majors catches callback drift before users see it. The remaining coupling is the “store parts exactly as produced” choice: it preserves fidelity but lets provider- or SDK-specific part shapes leak into durable history. Would you keep an immutable raw envelope plus a versioned normalized projection, so migrations can rebuild the live path without rewriting original evidence?

Collapse
 
nixrajput profile image
Nikhil Rajput •

Thank you, this is a sharp framing - and it's most of what the storage already does.

Reads already work that way. loadMessages never hands back stored bytes directly: each row's parts pass through migrateParts(parts, sdkVersion) on the way out, keyed by the major that wrote that particular row. The row itself is never touched by a read, so the stored payload is the immutable envelope and what useChat receives is a projection of it. A thread written across three majors comes back coherent with nothing rewritten.

What's missing from your description is that the projection is computed rather than materialised. That costs nothing today - migrateParts is a pass-through for 5, 6 and 7, whose part payloads are byte-identical, which I checked rather than assumed (test/fixtures/parts-v5 and parts-v6). Caching an identity function would be pure overhead. If a real transform ever lands, the first thing I'd want isn't the stored projection but a second version stamp: sdkVersion records what wrote the row, and nothing records which projector produced the output.

Your point lands properly on the migrate CLI, which does write parts back in place when a migration changes them. It has never actually rewritten one - the transform is an identity, so it only restamps sdkVersion - but the destructive path is there. Making it additive rather than in-place is a fair change to want, and it's on the list.

On the leak itself, storing parts as produced is deliberate and the containment sits at the edges rather than in the schema: convertToUIMessages throws on file, image and provider-specific content rather than guessing a shape for it, so a lossy row can't enter quietly.

Thanks for this - exactly the kind of question I was hoping the piece would draw.

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.

Collapse
 
nixrajput profile image
Nikhil Rajput •

Thank you, this is a genuinely useful comment. I'm glad that part landed, because it's the bit I'd most want someone to carry away even if they never install anything.

It's worth being precise about where the type system covers you there, though, because it very nearly does. ai 6 has no onEnd at all - only onFinish, which ai 7 then deprecated in favour of onEnd. So if you pass your options as an inline object literal, TypeScript does catch it on ai 6: TS2353: 'onEnd' does not exist in type .... Excess property checking saves you.

Where it stops saving you is more ordinary than you'd hope. If the options are built as a variable first, excess property checks no longer apply and it passes straight through. If you develop and typecheck against ai 7 while someone runs your code on ai 6, the types never see the problem. Same if the callback is wired up somewhere untyped. The runtime is silent in all three - the stream completes, the user is happy, nothing reaches the database.

That middle case is really why the CI gate does the work rather than just adding confidence. For a package supporting a version range, the types you compile against are whichever version happens to be installed, so actually running the suite against the older major is the only thing that proves anything. Passing both names costs nothing, and only one of them ever fires.

On to your two questions, both of which are fair.

Branch growth. You're right that it's a real cost, and the honest answer turned out worse than the one I'd have given you from memory, so I went and measured it.

The mechanism isn't what the "2 queries" claim implies. loadMessages runs one query for the thread and one that fetches every row in the thread, then walks root-to-leaf in memory. The query count stays constant; the payload doesn't. Abandoned branches ride along on every read.

A 50-message thread, measured:

live path rows in table fetched-to-used
fresh 50 50 1.00x
after 20 regenerations of the last answer 50 70 1.40x
after editing question 5 12 72 6.00x

So regenerating is cheap - one stranded row each - and editing early is what actually hurts. Rewriting question 5 of a 25-turn thread strands all 38 messages below it in a single click, and the thread carries that weight on every load afterwards. A conversation someone edits repeatedly near the top degrades much faster than one they regenerate at the bottom.

There was no pruning policy, which was a gap. One is landing in the next release: pruneBranches(threadId) deletes everything the active leaf can't reach and hands back what it removed, so you can log or archive first. It's deliberately explicit rather than automatic - keeping abandoned branches is the whole point of the tree, so discarding them should never happen as a side effect of a regeneration.

Authorization. Good question, and the honest answer is that it guards the request rather than the store:

  • chatHandler runs authorize once at the top of the POST and answers 403. That single gate covers the whole turn - load, append, stream, persist - since a turn is one logical operation on one thread.
  • resumeHandler runs it on GET and DELETE separately, and answers 204 rather than 403, so someone probing can't tell "not your thread" from "nothing to resume".
  • The store methods themselves check nothing. So any branching route you write yourself - the ‹ 2/3 › control, the edit button, a thread list - is yours to guard. Wiring chatHandler correctly doesn't cover them.

You're right that both of these are what an integrator hits in the first hour, which is a good argument for them being much more visible than they are.

Thanks again for pushing on both - easily the most useful response the piece has had, and both are going into the docs because of it.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.