API

The store

createThreadStore, thread and message methods, keyset pagination, and orderPath.

createThreadStore(db)

Returns a ThreadStore backed by the two tables. db is any drizzle Postgres database - PgDatabase, which covers node-postgres, postgres.js, Neon, Vercel Postgres, and PGlite.

// lib/threads.ts
import { createThreadStore } from "ai-sdk-threads/drizzle";
import { db } from "./db";

export const store = createThreadStore(db);

If you are on ai 6.x, pass the major you are writing so a future migration reads the right stamp:

const sixStore = store; // createThreadStore(db, { sdkVersion: 6 })

Threads

MethodReturnsNotes
createThread(input?)Promise<Thread>input: { id?, userId?, title?, metadata? }. An id is generated when omitted.
getThread(id)Promise<Thread | null>null rather than a throw, so a missing thread is a 404 you handle.
listThreads(query?)Promise<{ threads, nextCursor? }>query: { userId?, limit?, cursor? }. Newest first, default limit 20.
updateThread(id, patch)Promise<Thread>patch: { title?, visibility?, metadata? }. Throws if the thread does not exist.
deleteThread(id)Promise<void>Messages cascade with it. Deleting an absent thread is a no-op.

A Thread is { id, userId, title, visibility, activeLeafId, metadata, createdAt, updatedAt }.

Keyset pagination

listThreads pages with a keyset cursor over (createdAt, id), not OFFSET. Pass nextCursor back and stop when it comes back undefined:

let cursor: string | undefined;
do {
  const page = await store.listThreads({ userId: "user_123", limit: 20, cursor });
  console.log(page.threads);
  cursor = page.nextCursor;
} while (cursor);

The timestamp columns are deliberately millisecond precision for this reason - see Schema.

Pinned and measured, not asserted. test/queries.test.ts walks every cursor page of a full traversal and requires each to cost one statement, and it pins the shape of the predicate too - a row-value comparison, (created_at, id) < ($1, $2), which Postgres can use as an index bound.

That shape is the whole property, and the difference is not subtle. The classic created_at < $1 OR (created_at = $1 AND id < $2) form costs one statement too, but Postgres can only use it as an index filter: it walks the user's range from the top and discards until the predicate passes, so the cost grows with depth. A row-value comparison is an index bound, so it does not.

Rows before the cursorcreated_at < $1 OR (…)(created_at, id) < ($1, $2)
~00.029 ms0.043 ms
~5,0001.75 ms0.031 ms
~50,0009.22 ms0.012 ms
~90,0008.09 ms0.014 ms

How that was measured

EXPLAIN (ANALYZE) on Postgres 16, 100,000 threads, pages of 20, statistics refreshed before timing. End to end through the store rather than at the server, a page 50,000 rows deep came back at 1.13x the first page and 90,000 rows deep at the same 1.13x - medians over six and three runs. The harness is in the repo, so you can re-run all of it: PG_URL=… npx tsx bench/paging.measure.ts.

Round trips

Every operation sends a fixed number of statements, independent of how much data is involved. That matters more than milliseconds, because round trips multiplied by your database's latency is the real cost:

OperationStatements
getThread, listThreads, createThread, updateThread, deleteThread1
loadMessages, getTree, siblingsOf2
appendMessages, regenerateFrom, setActiveLeaf3
forkAt, pruneBranches4
replaceMessage6

loadMessages stays at two - one select for the active leaf, one for the thread's rows - at depth 1 or depth 500, because the root-to-leaf path is walked in memory by orderPath rather than with a recursive CTE. That second select fetches the whole thread, abandoned branches included, so the statement count is flat while the payload is not - see what abandoned branches cost. pruneBranches stays at four however many rows it drops: one delete, not one per row.

These are the statements the query builder issues. The write paths run in a transaction, so on the wire they cost two more - begin and commit - which server logs confirm: one appendMessages sends begin, select, insert, update, commit. Budget five round trips for an append and six for a fork. listThreads stays at one statement for a deep page as well as the first. The counts are pinned by a test in the package (test/queries.test.ts), so an N+1 introduced anywhere fails CI, and they were confirmed unchanged against a real PostgreSQL 16 server as well as the in-process PGlite the suite uses.

Messages

MethodReturnsNotes
appendMessages(threadId, msgs)Promise<StoredMessage[]>Takes UIMessage[]. Transactional. Throws if the thread is absent.
loadMessages(threadId)Promise<UIMessage[]>Ordered oldest first, validated by the SDK before it is returned.

appendMessages uses each UIMessage's own id as the row's primary key, because useChat already owns message ids. Messages are chained to the end of the thread and the thread's activeLeafId moves to the last one, inside one transaction that locks the thread row - so two concurrent appends cannot interleave.

loadMessages returns [] for a thread with no messages yet, but throws for a thread that does not exist - two different states, and a client-supplied chat id that has never been posted to is the second one. Server-rendering a page from a fresh id therefore needs a getThread check first (see Getting started), or the first render is a 500. Before returning, rows pass through the SDK's own validateUIMessages, so a row that no longer matches the SDK's shape fails loudly here rather than in your renderer.

const messages = await store.loadMessages(threadId);
console.log(messages.map((m) => m.role));

orderPath(rows, activeLeafId)

Walks parentId links from a leaf back to the root and returns the rows oldest first. loadMessages uses it; it is exported for when you query the tables yourself.

import { orderPath } from "ai-sdk-threads";

Returns [] for a null leaf and throws on a broken link, naming the id it could not find. If a leaf ever points at a message that no longer exists, setActiveLeaf is the repair path.

Because a thread is genuinely a tree once anything has been edited or regenerated, walk parent_id from active_leaf_id rather than sorting by created_at if you query the tables directly - a plain sort interleaves branches that were never part of the same conversation.

On this page