feat(persistence): generation persistence — client snapshot + durable media bytes#987
feat(persistence): generation persistence — client snapshot + durable media bytes#987AlemTuzlak wants to merge 37 commits into
Conversation
…kends Server-side persistence for chat(): durable thread messages, run records, and interrupts via the withChatPersistence middleware, with pluggable backends. - @tanstack/ai-persistence: store contracts, withChatPersistence / withGenerationPersistence middleware, memoryPersistence reference store, conformance testkit. Locks (LockStore/InMemoryLockStore/LocksCapability) live here rather than core; the sandbox-consumer bridge is deferred. - -drizzle / -prisma / -cloudflare: backend store implementations + migration / schema / models CLIs. Cloudflare D1 delegates to the drizzle backend. Reconciled against the shipped ephemeral-interrupt engine: the middleware records interrupts and gates new input, and delegates resume-tool-state reconstruction to the engine (resume batch + interrupt bindings in history). Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
The persistence adapter now stores one combined { messages, resume? } record
per chat id, so a full page reload restores the transcript, rehydrates pending
interrupts, and rejoins an in-flight run through joinRun when the connection is
durability-backed. Legacy bare-array records are still read.
Adds localStoragePersistence / sessionStoragePersistence / indexedDBPersistence
(+ StorageUnavailableError and the ChatPersistedState / ChatStorageAdapter
types). Durability rides the existing option, so every framework integration
gets it with no framework-specific code.
Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
New /persistent-chat route: useChat with localStoragePersistence on the client and withChatPersistence(sqlitePersistence) on the server, so a full page reload restores the conversation on both ends. Adds a nav link and README section. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
…ility
New docs/persistence section: overview, chat-persistence, browser-refresh,
controls, custom-stores, sql-backends, drizzle, prisma, cloudflare, migrations,
internals. Wires the nav and updates the client chat/persistence page for the
combined { messages, resume } record and built-in storage adapters.
Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Update the agent skills for the new surface: withChatPersistence server middleware and its backends, and the client browser-refresh durability (combined persistence record, storage adapters, joinRun rejoin, all frameworks). Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Provider-free durable harness route + client page + spec proving message restore after reload and interrupt-survives-reload via localStorage. Mid-stream joinRun rejoin is covered by ai-client unit tests and delivery-durability. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Add the object form `persistence: { store, messages?: boolean }`. `messages:
false` caches only the tiny resume pointer, keeping large transcripts off the
client while durability rejoin and interrupt restore still work and the server
stays authoritative for history. A bare adapter remains shorthand for
`{ store, messages: true }`, so this is backward compatible and every framework
passthrough is unchanged.
The persistent-chat example gains a history branch on its GET route
(loadThread by threadId, distinct from the per-run delivery replay) so a
server-authoritative reload can hydrate the transcript. Docs + chat-experience
skill document the lever.
Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
Rewrite the persistence overview into a concept + decision page: the three problems (dropped stream, lost-on-reload, no durable record), the two independent layers (delivery durability vs state persistence), client vs server halves, the reload/rehydration timeline, and a when-to-pick-each guide. Add a back-link from the resumable-streams overview so the two sections cross-reference. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
…tence localStoragePersistence / sessionStoragePersistence / indexedDBPersistence now default their type parameter to ChatPersistedState and to a JSON codec, so `persistence: localStoragePersistence()` needs no type argument and no serialize/deserialize pair. Drops the IsJsonSerializable type gate that forced a codec for the chat record (UIMessage already round-trips as JSON on the wire). Client persistence now keys on `threadId` (the conversation identity), so a reload with the same threadId restores the same record; `id` becomes an optional storage-key override. The storage adapters and persistence types are re-exported from every framework package, so a single import from @tanstack/ai-react (etc.) works. Claude-Session: https://claude.ai/code/session_01RqjWdHxvmMrhjbd8dYvENp
reconstructChat(persistence, request) returns a thread's stored messages as a JSON Response, so a server-authoritative client can hydrate its transcript on load from a one-line GET handler instead of hand-rolling loadThread + Response.
…curate resume Add a "What we recommend" section to the overview: client resume-pointer-only plus server persistence plus one GET that rehydrates history and resumes durable streams, with the reasoning. Update every snippet to the zero-config localStoragePersistence() and threadId, use reconstructChat for history, and discriminate the resume GET with durability.resumeFrom() instead of sniffing query params (the run id rides the X-Run-Id header, the offset the Last-Event-ID header). Example, e2e page, and chat-experience skill match.
Rename browser-refresh to client-persistence and make it the single home for the client story: turning it on, what a reload restores, the two cache modes (everything vs resume-pointer-only) with when to use each, and the three storage backends with when to use each. Remove the legacy docs/chat/persistence page (client content now lives in the persistence section) and repoint its links. Make the other persistence docs server-only: drop the client rows from the controls decision table and the browser-storage section from internals, leaving a pointer to the client guide. The overview stays the cross-cutting map. Update all cross-links, the chat-experience skill source, and the reconstructChat doc reference.
In `{ messages: false }` mode a prior session's persisted record is
`{ messages: [], resume }`. The constructor treated that empty transcript as
authoritative and clobbered host-provided `initialMessages`, and the async
hydrate path applied `[]` on top, so a server-authoritative reload dropped the
history the app had fetched from the server.
The persisted transcript is now adopted only when the client actually caches it
(`cachesMessages`); in messages:false mode the client keeps `initialMessages`
and takes only the resume pointer from storage. This makes the recommended
server-authoritative flow work: on a mid-stream reload the app seeds history via
initialMessages (the reconstruct GET) while the client separately rejoins the
live run via joinRun (the resume GET), and the replayed run merges into the
seeded history by message id. Adds a test covering both together.
Also document in the overview that history hydration and run rejoin are two
separate GET requests, so the handler's if/else routes each and neither blocks
the other.
The primary chat persistence middleware is now `withPersistence`. `withGenerationPersistence` is unchanged. Unreleased, so no alias is kept. Updates all call sites, docs, skills, the example, and the changeset. fix(ai-client): rejoin an in-flight run from an async persistence store Auto-rejoin was gated on the synchronous read, so an async store (indexedDBPersistence) restored messages and interrupts on reload but never rejoined a mid-stream run. A guarded maybeRejoinInFlight now fires from both the sync read and the async hydrate path; it rejoins a run at most once and never while another run is already active (a fresh send wins). Adds a test that a run rejoins from an async (Promise-returning) adapter.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚀 Changeset Version Preview15 package(s) bumped directly, 38 bumped as dependents. 🟥 Major bumps
🟨 Minor bumps
🟩 Patch bumps
|
|
View your CI Pipeline Execution ↗ for commit 41397c1
☁️ Nx Cloud last updated this comment at |
A stale local build had masked real breakage: the ported persistence layer was
behind the current core. Reconciled against a clean build:
- withGenerationPersistence keys runs on `requestId` (the current
GenerationMiddlewareContext has no runId/threadId).
- Restore server-authoritative resume: withPersistence translates persisted
interrupts into resumeToolState and clears `config.resume`, so the engine
skips its ephemeral (client-history) reconstruction, which the empty-messages
persistence flow can't satisfy. (Reverts an incorrect earlier removal.)
- ai-client: never persist an empty record (no messages, no resume), so a
cleared conversation is removed, not left as `{ messages: [] }` — fixes the
clear/suppression regressions from the combined-record change.
- Tests updated to the combined-record shape, LockStore imported from
@tanstack/ai-persistence, generation-context mocks and event shapes aligned to
the current core.
The two-phase approval->client-tool continuation test is skipped with a TODO:
its exact resume-execution semantics depend on the engine and need reconciling
with the engine owner; single-phase approval and client-tool resume are covered.
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-durable-stream
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-persistence
@tanstack/ai-persistence-cloudflare
@tanstack/ai-persistence-drizzle
@tanstack/ai-persistence-prisma
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
The persisted-state resume path already works end-to-end: withPersistence rehydrates the paused thread into config.messages, so the engine reprocesses the pending tool call from server state (not the omitted client history). Approving a client tool therefore advances straight to the client-execution interrupt without re-invoking the model, and feeding the client output drives one final model call. The prior skip assumed a model re-invocation that does not happen; assertions now match the real engine behavior.
…authoritative persistence
Switch the demo to the recommended setup: `persistence: { store, messages: false }`
so the client caches only the resume pointer and the server (SQLite) owns history.
A route loader hydrates the transcript from a server function that reads the
stored thread — SSR-safe (no relative fetch), sharing one lazily-opened store
with the API route.
Type `prismaPersistence`'s client argument structurally (`PrismaClientLike`) instead of importing `PrismaClient` from `@prisma/client`. The runtime was already structural and the delegate query API is identical across majors, so this accepts a client from either the v6 `prisma-client-js` generator or the v7 `prisma-client` generator (emitted to a custom output, not `@prisma/client`). Docs note both versions.
withPersistence.onFinish saved ctx.messages, but the chat engine only appends an assistant turn to the middleware message list when it carries tool calls — a run's terminal text reply is never appended. So a stored thread dropped the assistant's final answer and a server-authoritative reload showed only user messages. Reattach the terminal reply from info.content (the last turn's accumulated text), guarded against duplication. Strengthen the unit test to assert the assistant reply is stored (it previously only checked length > 0, which masked this).
Add getWeather / rollDice server tools so the demo exercises the agent loop and tool-call persistence (tool calls + results are stored and rehydrated on reload), and replace the bare inline styles with a self-contained dark chat UI that renders tool-call cards (input/output) alongside message bubbles, plus suggestion chips and auto-scroll. Regenerate routeTree.gen.ts to register the persistent-chat routes.
Layer a lightweight, read-only resume snapshot onto media generation. As a run streams, the client builds a GenerationResumeSnapshot (run identity, status, errors, result metadata + artifact refs — never media bytes) and writes it to an optional GenerationServerPersistence store. - ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot reducer; GenerationClient/VideoGenerationClient observe chunks, persist snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot(); disposed guard. No resume() action (stream re-attach is PR #955). - ai-event-client: optional threadId/runId on generation events. - react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot options; expose resumeSnapshot/resumeState (+ pending/result artifacts). - example: Persisted mode on the image generation route. - docs: persistence/generation-persistence.md + nav entry. Pairs with the existing withGenerationPersistence server middleware.
Drop the bespoke `GenerationServerPersistence` type and the `{ server }`
option wrapper. The `persistence` option is now a bare storage adapter
reusing the shared `ChatStorageAdapter` contract (aliased as
`GenerationPersistence`), so `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` work for generations
exactly as they do for chat — matching main's ergonomics.
…istence (no call-site generic)
Default `localStoragePersistence` / `sessionStoragePersistence` /
`indexedDBPersistence` to a value-agnostic `TValue` so a bare, unannotated
call works for BOTH chat and generation persistence — the consuming
`persistence` option constrains the stored value. Generation docs/example now
use `localStoragePersistence({ keyPrefix })` with no type declaration.
PR #955 (resumable streams) is merged, so delivery durability is available today — it was wrongly described as an unlanded future feature. Rewrite the generation-persistence doc: the server example now wires a durability adapter + GET handler, and the delivery section explains that a dropped mid-generation connection re-attaches through the same adapters useChat uses. Clarify that the read-only snapshot carries run state (incl. runId) across reloads, while hooks do not auto-resume on mount.
c1cd561 to
a77d800
Compare
Server-side artifact + blob storage for generated media, layered on withGenerationPersistence. - @tanstack/ai: result-transform machinery (resultTransforms/artifactInputs on GenerationMiddlewareContext, applyGenerationResultTransforms), threadId/ runId on the image/audio/speech/transcription activities, and generation:artifacts emission from streamGenerationResult. - @tanstack/ai-utils: base64ToUint8Array. - @tanstack/ai-persistence: ArtifactStore + BlobStore contracts, in-memory impls in memoryPersistence(), and withGenerationPersistence byte-persistence (writes bytes to blobs, records ArtifactRecord, attaches PersistedArtifactRef, emits generation:artifacts) with extractArtifacts/nameArtifact options.
…+ blobs, serve route, extractArtifacts/nameArtifact)
2f56980 to
f3d9fe6
Compare
Add retrieveArtifact(persistence, id) and retrieveBlob(persistence, idOrRecord) so a serve handler fetches a persisted generation artifact's metadata and bytes without hand-rolling the blob key. artifactBlobKey is the shared key builder used by both withGenerationPersistence (write) and retrieveBlob (read).
0b17612 to
d2d08e8
Compare
Generation Persistence
Stacked on #984 (
feat/persistence-core) — please review/merge that first. Rebased onto its current tip.Generation persistence in two halves:
1. Client resume snapshot (read-only)
As a media generation streams, the client builds a lightweight
GenerationResumeSnapshot(run identity, status, errors, result metadata + artifact refs — never the bytes) and writes it to apersistencestorage adapter. The option reuses the sharedChatStorageAdaptercontract, solocalStoragePersistence/sessionStoragePersistence/indexedDBPersistencework with no type argument:Hooks expose
resumeSnapshot/resumeState/pendingArtifacts/resultArtifactsacross react/solid/vue/svelte/angular. Noresume()action; reconnect to an in-flight stream is the delivery layer's job (resumable streams, #955, merged), wired via adurabilityadapter +GEThandler exactly as for chat.2. Durable media-byte storage (server, opt-in)
When the persistence backend provides both an
artifacts(ArtifactStore) and ablobs(BlobStore) store,withGenerationPersistencewrites each generated file's bytes to the blob store (keyartifacts/<runId>/<artifactId>), records anArtifactRecord, attachesPersistedArtifactRefs to the result, and emitsgeneration:artifactsso the client records them.memoryPersistence()ships both stores; any backend implementing the two contracts works. Extraction is customizable viaextractArtifacts/nameArtifact.Changes
@tanstack/ai— result-transform machinery (resultTransforms/artifactInputsonGenerationMiddlewareContext,applyGenerationResultTransforms),threadId/runIdoptions on the image/audio/speech/transcription activities, andgeneration:artifactsemission fromstreamGenerationResult.@tanstack/ai-utils—base64ToUint8Array.@tanstack/ai-persistence—ArtifactStore+BlobStorecontracts, in-memory impls inmemoryPersistence(), and byte-persistence inwithGenerationPersistence(extractArtifacts/nameArtifact).@tanstack/ai-client+ framework hooks (react/solid/vue/svelte/angular) — the client snapshot +persistenceoption + artifact refs.@tanstack/ai-event-client— optionalthreadId/runIdon generation events.docs/persistence/generation-persistence.md(client snapshot, byte storage + serve route, reconnect), kiira-verified.Verification
test:types— 0 errors in ai / ai-client / ai-persistence.Scope note
Durable SQL/R2 artifact+blob backends (Drizzle/Prisma/Cloudflare R2) are not in this PR:
memoryPersistenceplus any customArtifactStore+BlobStorecover the contract today, and the durable backends (which add SQL schema + migrations per package) are a clean follow-up.generateVideo's own job-polling artifact path is likewise deferred (image/audio/speech/transcription persist bytes).