@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
281 lines (263 loc) • 10.2 kB
text/typescript
import { modelMessagesToUIMessages } from '@tanstack/ai'
import type { ModelMessage, UIMessage } from '@tanstack/ai'
import { validateReconstructChatStores } from './types'
import type {
AIPersistence,
ChatTranscriptStores,
MessagePage,
MessageStore,
} from './types'
const MAX_PAGE_SIZE = 500
/**
* The JSON body `reconstructChat` returns and a server-authoritative client
* hydrates from on mount.
*
* `messages` is the stored transcript as UI messages (ready to paint).
* `activeRun` is a cursor to a run still generating for the thread, or `null` —
* resolved from the STABLE thread id via `stores.runs.findActiveRun`, so the
* client learns "there is a live run to tail" without ever handling a run id.
* `interrupts` is the thread's pending human-in-the-loop interrupts (tool
* approvals, client-tool/generic waits) and the run they paused, or `null` —
* so a reload (or another device) re-prompts the approval from the SERVER, not
* from client storage. Resolved via `stores.interrupts.listPending`.
* `page` is set only when the GET included a valid `limit`. `truncated` is true
* when older UI messages exist. `cursor` is the opaque `before` token for the
* next older window.
*/
export interface ReconstructedChat {
messages: Array<UIMessage>
activeRun: { runId: string } | null
interrupts: {
runId: string
pending: Array<Record<string, unknown>>
} | null
page?: { truncated: false } | { truncated: true; cursor: string }
}
export interface ReconstructChatOptions {
/** Query parameter carrying the thread id. Defaults to `threadId`. */
param?: string
/**
* Authorize access to the requested thread before loading history.
*
* ⚠️ Without this, any caller who knows or guesses `?threadId=` receives the
* full transcript. Multi-user / multi-tenant deployments **must** supply
* an authorization check (session → owned threads) or resolve a validated
* thread id in the route and pass it via a custom `param` that only your
* server sets.
*
* Return:
* - `true` to allow the load
* - `false` for a default `403` response
* - a `Response` to return as-is (e.g. `401` with a body)
*/
authorize?: (
threadId: string,
request: Request,
) => boolean | Response | Promise<boolean | Response>
}
/**
* Build the JSON `Response` a server-authoritative client hydrates from on load
* (see the client-persistence guide). Reads the thread id from the request query
* (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }`
* ({@link ReconstructedChat}):
*
* - `messages` — the stored transcript as UI messages.
* - `activeRun` — `{ runId }` if a run is still generating for the thread (so the
* client tails it via the durability stream), else `null`. Resolved via the
* required `stores.runs.findActiveRun`; `null` when the `runs` store is absent.
* - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop
* interrupts (a paused approval / wait) and the run they paused, else `null`, so
* a reload re-prompts the decision from the server. Resolved via the optional
* `stores.interrupts.listPending`; `null` when that store is absent.
*
* Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns
* the newest window of UI messages plus `page`. `?before=` walks to an older
* window. Invalid `limit` (`0`, negative, NaN) is ignored and the full
* transcript is returned. `activeRun` and `interrupts` are never paged.
*
* Requires `stores.messages`. Returns an empty transcript with no active run
* and no interrupts when the thread id is missing or the thread is unknown, so
* the caller never has to special-case a first load.
*
* This helper does **not** enforce tenancy by itself. Pass
* {@link ReconstructChatOptions.authorize} (or wrap the call in your own
* session gate) before exposing it on a public route.
*
* ```ts
* export async function GET(request: Request) {
* return reconstructChat(persistence, request, {
* authorize: async (threadId, req) => {
* const userId = await getSessionUserId(req)
* return userId != null && (await userOwnsThread(userId, threadId))
* },
* })
* }
* ```
*/
export async function reconstructChat(
persistence: AIPersistence<ChatTranscriptStores>,
request: Request,
options?: ReconstructChatOptions,
): Promise<Response> {
validateReconstructChatStores(persistence)
const messageStore = persistence.stores.messages
if (!messageStore) {
// validateReconstructChatStores already throws; this narrows for TypeScript.
throw new Error('reconstructChat requires stores.messages.')
}
const requestUrl = new URL(request.url)
const param = options?.param ?? 'threadId'
const threadId = requestUrl.searchParams.get(param) ?? ''
const pageSize = parsePageSize(requestUrl.searchParams.get('limit'))
const before = parseBefore(requestUrl.searchParams.get('before'))
if (threadId && options?.authorize) {
const decision = await options.authorize(threadId, request)
if (decision instanceof Response) {
return decision
}
if (!decision) {
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store',
},
})
}
}
// Resolve the active run BEFORE reading the transcript. `withPersistence`
// persists the final transcript BEFORE marking a run complete, so observing
// "no active run" here guarantees the transcript read below is the FINAL one.
// Reading them in the other order opens a finish-window race: a fast run that
// completes between the two reads would return a stale streaming snapshot with
// `activeRun: null`, leaving the client stuck on the partial (no run to tail).
const active = threadId
? await persistence.stores.runs?.findActiveRun(threadId)
: null
const stored =
threadId === ''
? []
: pageSize === undefined
? await messageStore.loadThread(threadId)
: await messageStore.loadThread(threadId, {
limit: pageSize + 1,
...(before === undefined ? {} : { before }),
})
// Pending interrupts for the thread, so a reload re-prompts the approval from
// the server. Each stored `payload` is the full interrupt descriptor the
// client hydrates; they share the run they paused.
const pending = threadId
? ((await persistence.stores.interrupts?.listPending(threadId)) ?? [])
: []
const firstPending = pending[0]
const isPaging = pageSize !== undefined && threadId !== ''
const transcript = !isPaging
? {
messages: modelMessagesToUIMessages(threadMessages(stored)),
}
: Array.isArray(stored)
? await windowFromArray({
stored,
messageStore,
threadId,
pageSize,
before,
})
: windowFromMessagePage(stored, pageSize)
const body: ReconstructedChat = {
messages: transcript.messages,
activeRun: active ? { runId: active.runId } : null,
interrupts: firstPending
? {
runId: firstPending.runId,
pending: pending.map((record) => record.payload),
}
: null,
...('page' in transcript ? { page: transcript.page } : {}),
}
return new Response(JSON.stringify(body), {
headers: {
'content-type': 'application/json',
'cache-control': 'no-store',
},
})
}
function parsePageSize(raw: string | null) {
if (raw == null) return
const pageSize = Number(raw)
const isValidPageSize = Number.isInteger(pageSize) && pageSize > 0
if (!isValidPageSize) return
return Math.min(pageSize, MAX_PAGE_SIZE)
}
function parseBefore(raw: string | null) {
if (raw == null || raw === '') return
return raw
}
function threadMessages(
loaded: Array<ModelMessage> | MessagePage,
): Array<ModelMessage> {
return Array.isArray(loaded) ? loaded : loaded.messages
}
function completePage() {
return { truncated: false as const }
}
function truncatedPage(cursor: string) {
return { truncated: true as const, cursor }
}
function pageFromCursor(cursor: string | undefined) {
if (cursor === undefined || cursor === '') {
return completePage()
}
return truncatedPage(cursor)
}
function newestUiWindow(messages: Array<UIMessage>, pageSize: number) {
const truncated = messages.length > pageSize
if (!truncated) {
return { messages, page: completePage() }
}
const uiWindow = messages.slice(messages.length - pageSize)
return {
messages: uiWindow,
page: pageFromCursor(uiWindow[0]?.id),
}
}
function uiBeforeCursor(messages: Array<UIMessage>, cursor: string) {
const cut = messages.findIndex((message) => message.id === cursor)
if (cut === -1) return
return messages.slice(0, cut)
}
function windowFromMessagePage(page: MessagePage, pageSize: number) {
const ui = modelMessagesToUIMessages(page.messages)
if (ui.length > pageSize) {
// Extra slice uses a library-minted cursor. Keeping the adapter cursor
// after dropping the oldest row would skip that row on the next GET.
return newestUiWindow(ui, pageSize)
}
if (page.truncated) {
return {
messages: ui,
page: pageFromCursor(page.cursor),
}
}
return { messages: ui, page: completePage() }
}
async function windowFromArray(input: {
stored: Array<ModelMessage>
messageStore: MessageStore
threadId: string
pageSize: number
before: string | undefined
}) {
const { stored, messageStore, threadId, pageSize, before } = input
if (before === undefined) {
return newestUiWindow(modelMessagesToUIMessages(stored), pageSize)
}
// Array adapters own no cursor. Apply `before` to the full transcript so an
// adapter that ignored the hint cannot return the same newest page forever.
const full = threadMessages(await messageStore.loadThread(threadId))
const older = uiBeforeCursor(modelMessagesToUIMessages(full), before)
if (older === undefined) {
return { messages: [], page: truncatedPage(before) }
}
return newestUiWindow(older, pageSize)
}