Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 0 additions & 78 deletions .changeset/persistence-layer.md

This file was deleted.

43 changes: 43 additions & 0 deletions .changeset/persistence-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@tanstack/ai': minor
'@tanstack/ai-client': minor
'@tanstack/ai-durable-stream': minor
'@tanstack/ai-persistence': minor
'@tanstack/ai-persistence-drizzle': minor
'@tanstack/ai-persistence-prisma': minor
'@tanstack/ai-angular': minor
'@tanstack/ai-preact': minor
'@tanstack/ai-react': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-vue': minor
---

Persistence v2: split state (middleware) vs delivery (transport) durability.

State durability stays on the middleware layer as `withChatPersistence` /
`withGenerationPersistence`, backed by a store contract (messages, runs,
interrupts, metadata, locks, artifacts, blobs). The `@tanstack/ai-persistence`
package now ships a state-only middleware, an in-core memory backend, and a
shared conformance suite; `@tanstack/ai-persistence-drizzle` and
`@tanstack/ai-persistence-prisma` provide state backends.

Delivery durability (replay a disconnected/reloaded stream) moves to the
transport layer via a pluggable `StreamDurability` sink — the SDK owns zero
delivery-event storage. The new `@tanstack/ai-durable-stream` package provides a
durable-streams-protocol `StreamDurability` adapter, and the client
(`@tanstack/ai-client`) plus every framework binding (`@tanstack/ai-react`,
`-solid`, `-vue`, `-svelte`, `-angular`, `-preact`) switch to resumable SSE with
interrupt-only resume.

The home-grown delivery/event-log subsystem is removed: the in-band `cursor` on
`StreamChunk`, the `cursor` param on `chat()`, the public/internal event stores,
the `ResumeSource` seam, cursor-replay client machinery, and the deprecated
approval-store shim are all deleted (this feature is unreleased — no back-compat
shims). Interrupt resume (approvals / client-tool results via
`RunAgentInput.resume[]`) is preserved as state durability.

The delivery-store / SQL-driver packages that only existed to back the removed
event log — `@tanstack/ai-persistence-sql`, `-sqlite`, `-postgres`, and
`-cloudflare` — are removed. They were never published (part of this same
unreleased persistence work), so no npm tombstone release is needed.
2 changes: 0 additions & 2 deletions docs/chat/connection-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,6 @@ function websocketConnection(url: string): SubscribeConnectionAdapter {
JSON.stringify({
threadId: runContext?.threadId,
runId: runContext?.runId,
cursor: runContext?.cursor,
resume: runContext?.resume,
messages,
data,
Expand Down Expand Up @@ -402,7 +401,6 @@ const myAdapter: ConnectConnectionAdapter = {
body: JSON.stringify({
threadId: runContext?.threadId,
runId: runContext?.runId,
cursor: runContext?.cursor,
resume: runContext?.resume,
messages,
...data,
Expand Down
9 changes: 7 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,11 @@
"addedAt": "2026-06-18",
"updatedAt": "2026-07-09"
},
{
"label": "Delivery Durability",
"to": "persistence/delivery-durability",
"addedAt": "2026-07-09"
},
{
"label": "Persistence Controls",
"to": "persistence/controls",
Expand All @@ -419,7 +424,7 @@
"label": "Migrations",
"to": "persistence/migrations",
"addedAt": "2026-07-08",
"updatedAt": "2026-07-08"
"updatedAt": "2026-07-09"
},
{
"label": "Chat Persistence",
Expand Down Expand Up @@ -449,7 +454,7 @@
"label": "Cloudflare",
"to": "persistence/cloudflare",
"addedAt": "2026-07-03",
"updatedAt": "2026-07-08"
"updatedAt": "2026-07-09"
},
{
"label": "Prisma",
Expand Down
54 changes: 34 additions & 20 deletions docs/media/generation-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Generation hooks share a consistent API across all media types:
| `useGenerateVideo` | `VideoGenerateInput` | `VideoGenerateResult` |
| `useGeneration` | Generic `TInput` | Generic `TResult` |

Every hook returns the same core shape: `generate`, `result`, `isLoading`, `error`, `status`, `stop`, and `reset`. Streaming hooks also expose lightweight resume state and persisted artifact refs. You provide either a `connection` (streaming transport) or a `fetcher` (direct async call).
Every hook returns the same core shape: `generate`, `result`, `isLoading`, `error`, `status`, `stop`, and `reset`. Streaming hooks also expose a read-only generation state snapshot (`resumeSnapshot`, `resumeState`, `pendingArtifacts`, `resultArtifacts`) for observability and rendering persisted artifact refs. You provide either a `connection` (streaming transport) or a `fetcher` (direct async call).

## Server Setup

Expand Down Expand Up @@ -399,10 +399,8 @@ function EmbeddingGenerator() {
| `fetcher` | `GenerationFetcher<TInput, TResult>` | Direct async function (no streaming protocol needed) |
| `id` | `string` | Unique identifier for this generation instance |
| `body` | `Record<string, any>` | Additional body parameters sent with connection requests |
| `persistence` | `{ server?: GenerationServerPersistence }` | Stores the lightweight generation resume snapshot. Generated media bytes are not stored in browser persistence. |
| `autoResume` | `boolean` | Whether the hook should resume a persisted run on mount. Defaults to `true`. |
| `initialResumeSnapshot` | `GenerationResumeSnapshot` | Initial lightweight snapshot restored by the app or a persistence adapter. |
| `resumeState` | `GenerationResumeState` | Explicit `{ threadId, runId, cursor }` to use for the next resume or generation request. |
| `persistence` | `{ server?: GenerationServerPersistence }` | Stores the lightweight generation state snapshot. Generated media bytes are not stored in browser persistence. |
| `initialResumeSnapshot` | `GenerationResumeSnapshot` | Initial lightweight snapshot restored by the app or a persistence adapter, surfaced as read-only state. |
| `onResult` | `(result: TResult) => TOutput \| null \| void` | Transform or react to results |
| `onError` | `(error: Error) => void` | Error callback |
| `onProgress` | `(progress: number, message?: string) => void` | Progress updates (0-100) |
Expand All @@ -421,9 +419,8 @@ function EmbeddingGenerator() {
| `status` | `GenerationClientState` | `'idle'` \| `'generating'` \| `'success'` \| `'error'` |
| `stop` | `() => void` | Abort the current generation |
| `reset` | `() => void` | Clear result, error, and return to idle |
| `resume` | `(state?: GenerationResumeState) => Promise<boolean>` | Reconnect to the current, initial, or explicit resumable generation run |
| `resumeSnapshot` | `GenerationResumeSnapshot \| undefined` | Lightweight snapshot containing resume cursor, status, errors, and artifact refs |
| `resumeState` | `GenerationResumeState \| null` | Current `{ threadId, runId, cursor }`, or `null` when nothing is resumable |
| `resumeSnapshot` | `GenerationResumeSnapshot \| undefined` | Read-only lightweight snapshot containing run/cursor metadata, status, errors, and artifact refs |
| `resumeState` | `GenerationResumeState \| null` | Observed `{ threadId, runId }` metadata from the snapshot, or `null` (read-only) |
| `pendingArtifacts` | `Array<GenerationPendingArtifact>` | Persisted artifact refs observed during generation or replay before completion |
| `resultArtifacts` | `Array<PersistedArtifactRef>` | Persisted artifact refs attached to the final replayed result |

Expand Down Expand Up @@ -452,12 +449,19 @@ const { result } = useGenerateImage({
// result is now string[] instead of ImageGenerationResult
```

## Resumable Generation
## Generation State Snapshot

All generation hooks can resume streamed generation endpoints when the server
uses `withGenerationPersistence(...)` and returns SSE events. Add `persistence.server` to
store the latest snapshot, rely on the default `autoResume: true`, or opt out
and call `resume()` from your UI.
Generation hooks do **not** auto-resume or expose a `resume()` action. A
generation run is only started by an explicit `generate(...)` call — hooks never
re-launch a run on mount. What the hooks _do_ surface, when the server uses
`withGenerationPersistence(...)` and returns SSE events, is a **read-only**
snapshot of the latest observed run for observability and for rendering
persisted artifact refs.

Add `persistence.server` to store the latest snapshot under a stable `id`, or
pass `initialResumeSnapshot` to hydrate it from your own store. The hook then
reflects `resumeSnapshot`, `resumeState`, `pendingArtifacts`, and
`resultArtifacts` as read-only values.

```tsx
import { localStorageAIPersistence } from '@tanstack/ai-client'
Expand All @@ -469,22 +473,32 @@ export function TrailerVideoGenerator() {
connection: fetchServerSentEvents('/api/generate/video'),
persistence: {
server: localStorageAIPersistence({
keyPrefix: 'tanstack-ai:generation-resume:',
keyPrefix: 'tanstack-ai:generation-state:',
}),
},
})

return (
<button disabled={video.isLoading} onClick={() => video.resume()}>
Resume
</button>
<div>
<button disabled={video.isLoading} onClick={() => video.generate({ prompt: 'A city flyover' })}>
Generate
</button>
{video.resumeState && <p>Last run: {video.resumeState.runId}</p>}
{video.pendingArtifacts.map((artifact) => (
<a key={artifact.artifactId} href={`/api/artifacts/${artifact.artifactId}`}>
{artifact.name}
</a>
))}
</div>
)
}
```

If the server aborts provider work on disconnect, resume can only replay events
that were already persisted. If a durable producer keeps running on the server,
the hook can reconnect and receive the later artifact/result events.
The snapshot lets you render persisted artifact refs and observe run status
after a reload, but it does not reconnect to or replay a server run. Generated
bytes live in server-side artifact/blob stores. See
[Generation Persistence](../persistence/generation-persistence) for serving
those durable bytes.

## Framework Variants

Expand Down
72 changes: 43 additions & 29 deletions docs/persistence/chat-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,71 @@ id: chat-persistence
---

Use chat persistence when the server should be authoritative for a thread. The
client may keep local UI state, but the durable transcript, run status,
replayable event log, and pending user decisions live behind
`withChatPersistence(...)`.
client may keep local UI state, but the durable transcript, run status, and
pending user decisions live behind `withChatPersistence(...)`. This is **state
durability** — messages, runs, and interrupts. Delivery durability (replaying an
in-flight stream after a disconnect) is a separate transport concern; see
[Delivery Durability](./delivery-durability).

By the end, your endpoint accepts `{ threadId, runId, cursor, resume }`, writes
streamed chunks to durable storage, and lets the client resume after an
in-session disconnect or full page reload.
By the end, your endpoint accepts `{ threadId, runId, resume }`, persists chat
state at boundaries, and — paired with a delivery-durability sink — lets the
client reconnect to an in-progress response after a disconnect or reload.

## Install a backend

SQLite is the simplest durable backend for a Node server:
SQLite is the simplest durable backend for a Node server. The batteries-included
`sqlPersistence` ships Drizzle-generated migrations:

```sh
pnpm add @tanstack/ai-persistence @tanstack/ai-persistence-sqlite
pnpm add @tanstack/ai-persistence @tanstack/ai-persistence-drizzle
```

## Create the server endpoint

Build the persistence instance once and reuse it across requests.

```ts
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import {
chat,
memoryStream,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { withChatPersistence } from '@tanstack/ai-persistence'
import { sqlitePersistence } from '@tanstack/ai-persistence-sqlite'
import { sqlPersistence } from '@tanstack/ai-persistence-drizzle'

const persistence = sqlitePersistence({
path: '.tanstack-ai/state.sqlite',
const persistence = sqlPersistence({
dialect: 'sqlite',
url: 'file:.tanstack-ai/state.sqlite',
migrate: true,
})

export async function POST(request: Request) {
const { messages, threadId, runId, cursor, resume } = await request.json()
const { messages, threadId, runId, resume } = await request.json()

const stream = chat({
threadId,
runId,
cursor,
resume,
adapter: anthropicText('claude-sonnet-4-6'),
messages,
middleware: [withChatPersistence(persistence)],
})

return toServerSentEventsResponse(stream)
// State persists at boundaries; the durability sink makes the delivered
// stream resumable (native Last-Event-ID reconnect / second-tab join).
return toServerSentEventsResponse(stream, {
durability: memoryStream(request),
})
}
```

`withChatPersistence(...)` loads stored thread history, saves the resulting
transcript, records run status, and appends every public AG-UI event with an
opaque cursor. When `cursor` is present, the run replays persisted events after
that cursor instead of re-running the adapter. For the exact event log and
cursor validation rules, see [Persistence Internals](./internals).
transcript, and records run status and interrupts at run boundaries. `resume`
carries interrupt/approval decisions back into a paused run. Delivery resume is
handled entirely by the transport's durability sink — see
[Delivery Durability](./delivery-durability). For the state store contract, see
[Persistence Internals](./internals).

If the same app also uses `withGenerationPersistence`, keep **run IDs unique
across activities** — they may share a store and `threadId`, but not a
Expand Down Expand Up @@ -117,15 +129,17 @@ export function Chat() {
}
```

Auto-resume is enabled by default. On mount, reconnect, or when the tab comes
back online, the client can continue an interrupted run by forwarding the last
known `{ threadId, runId, cursor }`. Opt out with `autoResume: false`, or call
`chat.resume()` when you want a manual retry button.
Delivery resume is transparent: the resumable SSE connection reattaches to an
in-flight run via the browser's native `Last-Event-ID` on reconnect, with no
client cursor state. There is no `resume()`/`autoResume` on `useChat` — see
[Delivery Durability](./delivery-durability).

`chat.resumeState` contains the active resume identity, or `null` when there is
nothing to continue. `chat.pendingInterrupts` contains the client-side
descriptors needed to answer pending user decisions. `persistence.server`
stores them together and hydrates them on the next client construction.
`chat.resumeState` contains the active interrupt-resume identity
(`{ threadId, runId }`), or `null` when there is nothing to continue.
`chat.pendingInterrupts` contains the client-side descriptors needed to answer
pending user decisions, resolved with `chat.resumeInterrupts(...)`.
`persistence.server` stores them together and hydrates them on the next client
construction.

## Choose the controls you need

Expand All @@ -136,8 +150,8 @@ chat, these are the common combinations:
| --- | --- |
| Browser-only drafts | `persistence.client` on the chat client. |
| Server-owned transcript | `stores.messages` and `features: ['messages']`. |
| Reconnect without re-running the model | `stores.runs`, `stores.publicEvents`, and `durable-replay`. |
| Pending approvals or human input | `interrupts`, which also requires run and public-event stores. |
| Reconnect without re-running the model | A delivery-durability sink on the transport — see [Delivery Durability](./delivery-durability). |
| Pending approvals or human input | `interrupts`, which also requires the `stores.runs` and `stores.interrupts` stores. |
| Multi-worker resume safety | Add `stores.locks` when a backend supports it. |

## Resume pending decisions
Expand Down
Loading
Loading