{"slug": "your-ai-sdk-chat-table-breaks-every-six-months", "title": "Your AI SDK chat table breaks every six months", "summary": "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.", "body_md": "Open `lib/db/schema.ts` in Vercel's `ai-chatbot` template and look at line 42:\n\n``` js\nexport const message = pgTable(\"Message_v2\", {\n```\n\nThe `_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.\n\nI have done that migration by hand. I did not want to do it again.\n\nPersisting 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:\n\n``` js\nconst { id, messages } = await req.json();\n\nconst existing = await store.loadMessages(id);\nconst known = new Set(existing.map((m) => m.id));\nconst fresh = messages.filter((m) => m.role === \"user\" && !known.has(m.id));\nif (fresh.length > 0) await store.appendMessages(id, fresh);\n\nconst history = [...existing, ...fresh];\nconst result = streamText({\n  model: openai(\"gpt-5\"),\n  messages: await convertToModelMessages(history),\n});\n\nlet persisted = false;\nconst persist = async ({ responseMessage }) => {\n  if (persisted || responseMessage.parts.length === 0) return;\n  persisted = true;\n  await store.appendMessages(id, [responseMessage]);\n};\n\nreturn result.toUIMessageStreamResponse({\n  generateMessageId: generateId,\n  onEnd: persist,\n  onFinish: persist,\n});\n```\n\nFour things in there are load-bearing and easy to get wrong.\n\nDrop `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.\n\nThe 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.\n\nThe AI SDK's own [persistence guide](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence) 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.\n\n[assistant-ui](https://www.assistant-ui.com) cloud and [Convex](https://www.convex.dev) 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.\n\nForking the template gets you an app, not a dependency. You inherit its auth stack, its blob storage and its choices.\n\nSo: [`ai-sdk-threads`](https://www.npmjs.com/package/ai-sdk-threads). Threads and messages in your Postgres or SQLite. Two tables you re-export, one store you build once:\n\n```\n// db/schema.ts - plain drizzle objects, so your own migration tooling picks them up\nexport { messages, threads } from \"ai-sdk-threads/drizzle\";\n\n// lib/threads.ts - from the drizzle instance you already have\nexport const store = createThreadStore(db);\n```\n\nAfter `npm install ai-sdk-threads drizzle-orm` and a migration, the whole chat route becomes this:\n\n``` js\nexport const POST = chatHandler({\n  store,\n  execute: ({ modelMessages }) =>\n    streamText({\n      model: openai(\"gpt-5\"),\n      messages: modelMessages,\n    }),\n});\n```\n\nIt 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.\n\nMessage `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.\n\nOne 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\".\n\nRegenerate 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.\n\nAlmost 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](https://github.com/vercel/ai/issues/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.\n\nThe 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:\n\n```\n// Regenerate: point the leaf where a fresh answer belongs, then stream into it.\nawait store.regenerateFrom(threadId, assistantMessageId);\n\n// The ‹ 2/3 › control, straight from the store.\nconst { siblings, index } = await store.siblingsOf(threadId, assistantMessageId);\n\n// Switch which path is live; everything downstream comes back with it.\nawait store.setActiveLeaf(threadId, siblings[index - 1].id);\n```\n\nNothing 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.\n\nRather 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](https://ai-sdk-threads.nixrajput.com/en/playground).\n\nThree details for anyone deciding whether to trust this with their data.\n\n**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.\n\n**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.\n\n**`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.\n\nIt stores conversations; it does not retrieve over them. No vector search, no summarisation, no agent orchestration - different problem, different library.\n\nIt is not chat UI. [ai-elements](https://ai-sdk.dev/elements) and assistant-ui own that layer, and this stores what they render.\n\n`ai` 4 and older are unsupported: 5 was a rewrite and the supported range is `>=6 <8`.\n\nAnd 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.\n\nMIT, no runtime dependencies in the core, 1.04 kB minified and gzipped, and everything it does today stays free.\n\nDocs and the playground: [ai-sdk-threads.nixrajput.com](https://ai-sdk-threads.nixrajput.com) (there is an `llms.txt` if you are an agent reading this).\n\nThe 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.", "url": "https://wpnews.pro/news/your-ai-sdk-chat-table-breaks-every-six-months", "canonical_source": "https://dev.to/nixrajput/your-ai-sdk-chat-table-breaks-every-six-months-6eb", "published_at": "2026-09-14 09:55:34+00:00", "updated_at": "2026-09-14 10:06:21.296259+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products", "large-language-models"], "entities": ["ai-sdk-threads", "Vercel", "AI SDK", "assistant-ui", "Convex", "Drizzle", "Postgres", "SQLite"], "alternates": {"html": "https://wpnews.pro/news/your-ai-sdk-chat-table-breaks-every-six-months", "markdown": "https://wpnews.pro/news/your-ai-sdk-chat-table-breaks-every-six-months.md", "text": "https://wpnews.pro/news/your-ai-sdk-chat-table-breaks-every-six-months.txt", "jsonld": "https://wpnews.pro/news/your-ai-sdk-chat-table-breaks-every-six-months.jsonld"}}