@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
155 lines (120 loc) • 5.85 kB
Markdown
<div align="center">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/ai.png?theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/ai.png"
/>
<img
src="https://tanstack.com/api/readme/ai.png"
alt="TanStack AI"
width="900"
/>
</picture>
</div>
<br />
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks
A conversation that only lives in memory is gone on reload and absent on a second device. This package stores it in your own database: one middleware on the server writes the transcript — and, with the matching stores configured, run status and pending approvals — through an adapter you define, and the client asks the server for the thread on mount. The client half ships in the framework package you already use (`@tanstack/ai-react`, `-vue`, `-solid`, `-svelte`, `-angular`, or `@tanstack/ai-client`).
## Installation
```bash
npm install @tanstack/ai-persistence
# or
pnpm add @tanstack/ai-persistence
# or
yarn add @tanstack/ai-persistence
```
## Usage
### Server: store the conversation
`withPersistence` writes the transcript into your `messages` store, plus run status and pending approvals when the adapter also provides `runs` and `interrupts`. Start with `memoryPersistence()` for local development and swap in your own adapter later:
```typescript
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
return toServerSentEventsResponse(stream)
}
```
`persistence: true` puts the server in charge: the browser caches nothing and fetches the thread on mount. Best for multi-user and multi-device apps.
```tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
return <button onClick={() => sendMessage('hi')}>{messages.length}</button>
}
```
Prefer the browser to own the history? Pass a storage adapter instead — `localStoragePersistence()`, `sessionStoragePersistence()`, or `indexedDBPersistence()` — and no server store is needed.
Add a `GET` on the same route. `reconstructChat` returns the stored thread plus a cursor to any run still generating; `useChat` tails that run so the reply finishes in place.
```typescript
import { chatParamsFromRequest } from '@tanstack/ai'
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export function GET(request: Request) {
return reconstructChat(persistence, request, {
// WITHOUT this, anyone who guesses a thread id gets the whole transcript.
authorize: async (threadId, req) => ownsThread(req, threadId),
})
}
```
An adapter is a plain object of store functions. The core never looks at your tables, so the schema stays yours. One store, `messages`, is enough for `withPersistence`:
```typescript
import {
defineAIPersistence,
defineMessageStore,
} from '@tanstack/ai-persistence'
import { db } from './db'
export const persistence = defineAIPersistence({
stores: {
messages: defineMessageStore({
// Return [] for a thread that was never saved, never null.
loadThread: (threadId) => db.threads.messages(threadId),
// The full transcript, not a delta. Overwrite what you had.
saveThread: (threadId, messages) => db.threads.save(threadId, messages),
}),
},
})
```
The other stores are optional and add capabilities as you provide them: `runs`, `interrupts`, and `metadata` for chat state; `generationRuns`, `artifacts`, and `blobs` for image, video, speech, and transcription runs. `composePersistence` layers overrides on top of a base backend.
The same suite every packaged backend runs is shipped for yours:
```typescript
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
import { sqlitePersistence } from './sqlite-persistence'
runPersistenceConformance('my sqlite adapter', () =>
sqlitePersistence({ url: ':memory:', migrate: true }),
)
```
Stores you do not provide go in `skip`. The testkit is a Vitest suite; `vitest` is an optional peer dependency, so install it in your project before importing `@tanstack/ai-persistence/testkit`.
- [Overview](https://tanstack.com/ai/latest/docs/persistence/overview): the three steps, and which setup you want
- [Chat persistence](https://tanstack.com/ai/latest/docs/persistence/chat-persistence): the server middleware in full, including durable interrupts
- [Client persistence](https://tanstack.com/ai/latest/docs/persistence/client-persistence): the modes, storage backends, and what a reload restores
- [Build your own adapter](https://tanstack.com/ai/latest/docs/persistence/build-your-own-adapter)
- [Controls](https://tanstack.com/ai/latest/docs/persistence/controls): compose backends per store
- [Store reference](https://tanstack.com/ai/latest/docs/persistence/store-reference): every store's methods and how the records relate
MIT