From 76aff62e3cc906b0ec8618fa74fb648c0daf5f3d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 31 Jul 2026 12:40:32 +0200 Subject: [PATCH 1/2] refactor(persistence)!: trim the public API surface to what apps call Generation persistence is server-driven, so the options and types that only existed to support a client-managed copy of a run are gone, along with a few exports no consumer ever named. - remove `initialResumeSnapshot` from every generation hook (7 hooks x 5 frameworks) and from `GenerationClient` / `VideoGenerationClient`; `useChat` keeps its own - unexport the generation hydration internals from `@tanstack/ai-client`: `GenerationResumeSnapshot` / `GenerationResumeState` / `GenerationResumeStatus` / `GenerationResultSnapshot` / `GenerationErrorSnapshot` / `GenerationEventSnapshot` / `parseGenerationResumeSnapshot` / `updateGenerationResumeSnapshot` / `ChatResumeSnapshot` - delete `GenerationPendingArtifact` and `GenerationPersistenceOption` (an alias for `boolean`) - collapse `ChatResumeSnapshotV1` / `ChatResumeSnapshotV2` into one shape with no `schemaVersion`: they were structurally identical, no reader branched on the version, and only V2 was written - drop the `TValue` generic from `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence`; it existed so generation could share the chat adapters and had zero non-default instantiations - stop re-exporting `PersistedArtifactRef` from the framework packages, where no hook type refers to it - unexport `artifactBlobKey` in favour of `resolveArtifactBlobKey`, which its own docs already recommended for reads - delete `createInterruptController` / `InterruptController`, a five-method forwarder to the `interrupts` store with no caller outside its own test Tests: the base-hook "no auto-fire from a seeded running snapshot" guards are redundant with each framework's existing hydrated-running test and are removed; the video ones are converted to the surviving `persistence: true` + `hydrateGeneration` path so the coverage stays. Docs: - document the ctx-capability plumbing (`PersistenceCapability`, `InterruptsCapability`, `getPersistence`, `getInterrupts`, `providePersistence`, `provideInterrupts`) in persistence/internals - fix the `ai-core/client-persistence` skill frontmatter, which still described the removed client-driven generation mode its own body denies - split the 1513-line build-your-own-adapter page into build-your-own-adapter (shape, store choice, existing schemas, conformance), build-your-own-chat-adapter, build-your-own-generation-adapter, and store-reference; inbound links and anchors repointed, nav updated - drop every em dash and separator glyph from docs/persistence --- .../client-typed-storage-adapter-defaults.md | 21 +- ...tion-mount-hydration-and-speech-restore.md | 2 +- .../generation-persistence-server-only.md | 15 +- .changeset/generation-persistence.md | 4 +- .../generation-run-threadid-required.md | 6 +- .changeset/hooks-expose-run-id.md | 5 +- .changeset/trim-persistence-public-surface.md | 47 + docs/config.json | 17 +- docs/persistence/build-your-own-adapter.md | 1251 +---------------- .../build-your-own-chat-adapter.md | 436 ++++++ .../build-your-own-generation-adapter.md | 517 +++++++ docs/persistence/chat-persistence.md | 26 +- docs/persistence/client-persistence.md | 10 +- docs/persistence/controls.md | 4 +- docs/persistence/generation-persistence.md | 38 +- docs/persistence/id-map.md | 4 +- docs/persistence/internals.md | 74 +- docs/persistence/keep-generated-files.md | 30 +- docs/persistence/overview.md | 41 +- docs/persistence/store-reference.md | 286 ++++ packages/ai-angular/src/index.ts | 5 - .../ai-angular/src/inject-generate-audio.ts | 6 +- .../ai-angular/src/inject-generate-video.ts | 13 +- packages/ai-angular/src/inject-generation.ts | 13 +- .../tests/inject-generation.test.ts | 55 +- packages/ai-angular/tests/test-utils.ts | 4 +- packages/ai-client/src/chat-client.ts | 2 - packages/ai-client/src/connection-adapters.ts | 6 +- packages/ai-client/src/generation-client.ts | 7 +- packages/ai-client/src/generation-types.ts | 77 +- packages/ai-client/src/index.ts | 17 +- packages/ai-client/src/storage-adapters.ts | 44 +- packages/ai-client/src/types.ts | 17 +- .../ai-client/src/video-generation-client.ts | 5 +- .../chat-client-interrupt-correlation.test.ts | 1 - .../tests/chat-client-interrupts.test.ts | 3 - .../ai-client/tests/resume-snapshot.test.ts | 8 - .../skills/ai-persistence/SKILL.md | 2 +- .../build-cloudflare-artifact-store/SKILL.md | 4 +- packages/ai-persistence/src/index.ts | 5 - packages/ai-persistence/src/interrupts.ts | 24 - packages/ai-persistence/src/retrieve.ts | 6 +- .../ai-persistence/tests/capabilities.test.ts | 56 - packages/ai-preact/tests/test-utils.ts | 5 +- packages/ai-react/src/index.ts | 5 - packages/ai-react/src/use-generate-audio.ts | 3 - packages/ai-react/src/use-generate-image.ts | 3 - packages/ai-react/src/use-generate-speech.ts | 3 - packages/ai-react/src/use-generate-video.ts | 13 +- packages/ai-react/src/use-generation.ts | 10 +- packages/ai-react/src/use-summarize.ts | 3 - packages/ai-react/src/use-transcription.ts | 3 - packages/ai-react/tests/test-utils.ts | 4 +- .../ai-react/tests/use-generation.test.ts | 68 +- packages/ai-solid/src/index.ts | 5 - packages/ai-solid/src/use-generate-audio.ts | 6 +- packages/ai-solid/src/use-generate-image.ts | 6 +- packages/ai-solid/src/use-generate-speech.ts | 6 +- packages/ai-solid/src/use-generate-video.ts | 17 +- packages/ai-solid/src/use-generation.ts | 14 +- packages/ai-solid/src/use-summarize.ts | 6 +- packages/ai-solid/src/use-transcription.ts | 6 +- packages/ai-solid/tests/test-utils.ts | 5 +- .../ai-solid/tests/use-generation.test.ts | 60 +- .../src/create-generate-audio.svelte.ts | 6 +- .../src/create-generate-image.svelte.ts | 6 +- .../src/create-generate-speech.svelte.ts | 6 +- .../src/create-generate-video.svelte.ts | 13 +- .../ai-svelte/src/create-generation.svelte.ts | 13 +- .../ai-svelte/src/create-summarize.svelte.ts | 6 +- .../src/create-transcription.svelte.ts | 6 +- packages/ai-svelte/src/index.ts | 5 - .../ai-svelte/tests/create-generation.test.ts | 55 +- packages/ai-svelte/tests/test-utils.ts | 4 +- packages/ai-vue/src/index.ts | 5 - packages/ai-vue/src/use-generate-audio.ts | 6 +- packages/ai-vue/src/use-generate-image.ts | 6 +- packages/ai-vue/src/use-generate-speech.ts | 6 +- packages/ai-vue/src/use-generate-video.ts | 13 +- packages/ai-vue/src/use-generation.ts | 13 +- packages/ai-vue/src/use-summarize.ts | 6 +- packages/ai-vue/src/use-transcription.ts | 6 +- packages/ai-vue/tests/test-utils.ts | 4 +- packages/ai-vue/tests/use-generation.test.ts | 57 +- .../ai-core/client-persistence/SKILL.md | 9 +- 85 files changed, 1716 insertions(+), 2000 deletions(-) create mode 100644 .changeset/trim-persistence-public-surface.md create mode 100644 docs/persistence/build-your-own-chat-adapter.md create mode 100644 docs/persistence/build-your-own-generation-adapter.md create mode 100644 docs/persistence/store-reference.md delete mode 100644 packages/ai-persistence/src/interrupts.ts diff --git a/.changeset/client-typed-storage-adapter-defaults.md b/.changeset/client-typed-storage-adapter-defaults.md index 983ba5753..e515fdabe 100644 --- a/.changeset/client-typed-storage-adapter-defaults.md +++ b/.changeset/client-typed-storage-adapter-defaults.md @@ -3,15 +3,16 @@ --- `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` -default their `TValue` back to `ChatPersistedState` instead of `any`. +are no longer generic. Each returns a `ChatStorageAdapter`, +and `WebStoragePersistenceOptions` types its `serialize` / `deserialize` codec +over `ChatPersistedState`. -The `any` default was justified by a claim that "a bare call works for both the -chat **and generation** `persistence` options with no type argument". That is no -longer true: generation `persistence` is now `boolean` (server-driven only), so -chat is the sole `persistence` option that takes a storage adapter — and the -`any` default erased `getItem` / `setItem` type safety for chat users in exchange -for nothing. +The type parameter existed so one adapter could back both the chat and the +generation `persistence` option. Generation `persistence` is now `boolean` +(server-driven only), so chat is the sole option that takes a storage adapter and +the parameter had no second value to hold. -A bare `localStoragePersistence()` still needs no type argument. Only a -standalone store holding something other than a chat transcript needs the -explicit one, e.g. `localStoragePersistence()`. +A bare `localStoragePersistence()` is unchanged. A call that passed an explicit +type argument for a standalone store, `localStoragePersistence()`, no +longer compiles: build that store with your own object literal, since these +factories are for chat state. diff --git a/.changeset/generation-mount-hydration-and-speech-restore.md b/.changeset/generation-mount-hydration-and-speech-restore.md index 13b7b3e02..b80ae37b2 100644 --- a/.changeset/generation-mount-hydration-and-speech-restore.md +++ b/.changeset/generation-mount-hydration-and-speech-restore.md @@ -16,7 +16,7 @@ results. together re-fired the hydrate GET on every discarded/speculative render, flooding the connection pool (`ERR_INSUFFICIENT_RESOURCES`). Hydration now runs once from `mountDevtools` (the hooks' commit-phase mount effect), guarded by `serverHydrationStarted`. - `initialResumeSnapshot` still seeds SSR/first paint. Note for direct + Note for direct (non-framework) `GenerationClient`/`VideoGenerationClient` users: mount hydration and the "missing `hydrateGeneration` handler" warning now fire from `mountDevtools()` rather than the constructor, so call `mountDevtools()` (as diff --git a/.changeset/generation-persistence-server-only.md b/.changeset/generation-persistence-server-only.md index fa29823c1..63c3a1978 100644 --- a/.changeset/generation-persistence-server-only.md +++ b/.changeset/generation-persistence-server-only.md @@ -23,14 +23,15 @@ restored differently: a client snapshot can never hold the generated bytes, so `result` came back `null` from storage but whole from the server. One mode removes that split. -Gone from `@tanstack/ai-client`: the `GenerationPersistence` type, the storage -read/write path in `GenerationClient` and `VideoGenerationClient`, and the -adapter arm of `GenerationPersistenceOption`. `persistence: true` still requires -a stable `threadId` at the type level, and still needs a `hydrateGeneration` -handler (every built-in connection has one) plus a `reconstructGeneration` route. +Gone from `@tanstack/ai-client`: the `GenerationPersistence` type and the storage +read/write path in `GenerationClient` and `VideoGenerationClient`. +`persistence: true` still requires a stable `threadId` at the type level, and +still needs a `hydrateGeneration` handler (every built-in connection has one) +plus a `reconstructGeneration` route. -`initialResumeSnapshot` is unchanged, so an app that wants to manage its own -storage can still seed the client from it. +The `initialResumeSnapshot` option went with it: it seeded the storage mode that +no longer exists, so the server hydration handler is the only way a run is +restored. **None of this touches chat.** `useChat` keeps both modes, and `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 68af8bcab..84027ac52 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -17,7 +17,7 @@ Add generation persistence, mirroring chat: media generation runs survive a relo **Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. `authorize` is optional at the type level for single-user and prototype routes, but any multi-user deployment must pass it: the run and thread ids arrive from the caller, so identity has to be derived from server-side session state and ownership checked before the helper reads persistence. The same applies to a route that serves artifact bytes by id. -**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. Prompt media referenced by **URL** is not downloaded: the URL is caller-supplied, so fetching it server-side would be an SSRF vector, and the bytes are redundant. Opt in per-app with `allowInputUrl` (a predicate, so the check can't be skipped). Every artifact fetch is limited to `http:`/`https:`, timed out (`artifactFetchTimeoutMs`, default 30s) and size-capped (`maxArtifactBytes`, default 100 MiB); input fetches additionally block loopback/private/link-local hosts and refuse redirects. `artifactFetch` injects the `fetch` used, for routing downloads through an egress-restricted proxy. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. +**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (which resolve the key through `resolveArtifactBlobKey`) serve the bytes back. Prompt media referenced by **URL** is not downloaded: the URL is caller-supplied, so fetching it server-side would be an SSRF vector, and the bytes are redundant. Opt in per-app with `allowInputUrl` (a predicate, so the check can't be skipped). Every artifact fetch is limited to `http:`/`https:`, timed out (`artifactFetchTimeoutMs`, default 30s) and size-capped (`maxArtifactBytes`, default 100 MiB); input fetches additionally block loopback/private/link-local hosts and refuse redirects. `artifactFetch` injects the `fetch` used, for routing downloads through an egress-restricted proxy. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. **Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take a `persistence` option, and it is boolean — server-driven only, with no client-storage adapter arm: `true` hydrates the last run for a stable `threadId` on mount, and the browser caches nothing. Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and reports the in-flight run's id as `runId` — there is no `resumeSnapshot` / `resumeState` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. With byte storage configured, a restored `result` is rebuilt whole, its media resolved to the durable serve URL and its refs on `result.artifacts`; without it, `status` / `error` restore and `result` stays null. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. @@ -27,4 +27,4 @@ Add generation persistence, mirroring chat: media generation runs survive a relo `findLatestForThread` is a **required** method on `GenerationRunStore` — a `?threadId=` lookup is the whole mount-time hydration path, so a store that cannot answer it cannot back generation persistence. TypeScript rejects a store that omits it; a JavaScript adapter that ships without it fails at the call, not silently. -Snapshots arriving from the server are validated with the new `parseGenerationResumeSnapshot` before anything is repainted. +Snapshots arriving from the server are validated before anything is repainted, so a stale or malformed record cannot paint a bogus result. diff --git a/.changeset/generation-run-threadid-required.md b/.changeset/generation-run-threadid-required.md index 41956cafe..e004bb268 100644 --- a/.changeset/generation-run-threadid-required.md +++ b/.changeset/generation-run-threadid-required.md @@ -27,9 +27,9 @@ so a record without one could be written and then never read back. And the client discarded any snapshot that arrived without one. That last disagreement was a silent failure: the server legitimately omitted -`threadId` for a record that had none, and `parseGenerationResumeSnapshot` -responded by dropping the **entire** snapshot — status, result and error along -with the cursor — leaving a blank idle panel with no diagnostic while the +`threadId` for a record that had none, and the client's snapshot validation +responded by dropping the **entire** snapshot (status, result and error along +with the cursor), leaving a blank idle panel with no diagnostic while the provider kept billing. Making the field required removes the disagreement by construction rather than patching one side of it. diff --git a/.changeset/hooks-expose-run-id.md b/.changeset/hooks-expose-run-id.md index 425efb437..eacfc1f59 100644 --- a/.changeset/hooks-expose-run-id.md +++ b/.changeset/hooks-expose-run-id.md @@ -38,9 +38,8 @@ ordinary streaming turn. `runId` tracks every run: it is set when any run starts `injectChat` (Angular) exposed no equivalent field before and now returns `runId` alongside the other frameworks. -`ChatResumeState` and `GenerationResumeState` remain exported — they still -describe the persisted resume snapshot (and `resumeInterruptsUnsafe` still takes -a `ChatResumeState`). They are simply no longer part of a hook's return shape. +`ChatResumeState` remains exported, since `resumeInterruptsUnsafe` still takes +one. It is simply no longer part of a hook's return shape. New docs page: [Id map](https://tanstack.com/ai/latest/docs/persistence/id-map) covers what each id means on chat versus generation, how to choose a `threadId`, diff --git a/.changeset/trim-persistence-public-surface.md b/.changeset/trim-persistence-public-surface.md new file mode 100644 index 000000000..9fb26b38a --- /dev/null +++ b/.changeset/trim-persistence-public-surface.md @@ -0,0 +1,47 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-angular': minor +--- + +**Breaking:** trim the persistence public API down to what an app actually calls. + +Generation persistence is server-driven, so the types and options that only +existed to support a client-managed copy of a run are gone. + +- **`initialResumeSnapshot` is removed from every generation hook** (`useGeneration`, + `useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, + `useSummarize`, `useTranscription`, and the Solid / Vue / Svelte / Angular + equivalents) and from `GenerationClient` / `VideoGenerationClient`. A run is + restored by `persistence: true` plus a `hydrateGeneration` handler. **`useChat` + keeps its `initialResumeSnapshot`.** +- **No longer exported from `@tanstack/ai-client`** (they are internals of the + hydration path): `GenerationResumeSnapshot`, `GenerationResumeState`, + `GenerationResumeStatus`, `GenerationResultSnapshot`, `GenerationErrorSnapshot`, + `GenerationEventSnapshot`, `GenerationPendingArtifact`, + `parseGenerationResumeSnapshot`, `updateGenerationResumeSnapshot`, and + `ChatResumeSnapshot`. `GenerationPersistenceOption` (an alias for `boolean`) is + deleted; write `persistence?: boolean`. `GenerationPersistenceOptions`, the + union that requires a `threadId` alongside `persistence`, is unchanged. +- **`ChatResumeSnapshotV1` / `ChatResumeSnapshotV2` are collapsed into one shape** + with no `schemaVersion` field. The two versions were structurally identical, no + reader branched on the version, and only V2 was ever written. +- **The framework packages no longer re-export `PersistedArtifactRef`.** No hook + type refers to it; import it from `@tanstack/ai` where the artifact stores are + defined. +- **`artifactBlobKey` is no longer exported from `@tanstack/ai-persistence`.** Use + `resolveArtifactBlobKey(record)`, which its own docs already recommended for + reads, since a record written with a custom `storageKey` carries its real key. +- **`createInterruptController` and `InterruptController` are deleted.** The + controller only forwarded five calls to the `interrupts` store; call the store + directly (`persistence.stores.interrupts`). +- The ctx-capability plumbing (`PersistenceCapability`, `InterruptsCapability`, + `getPersistence`, `providePersistence`, `getInterrupts`, `provideInterrupts`) is + unchanged and now documented, for middleware that reads the stores + `withPersistence` holds. See + [Persistence internals](https://tanstack.com/ai/latest/docs/persistence/internals). diff --git a/docs/config.json b/docs/config.json index 9bbf14527..e50a13e3f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -289,6 +289,21 @@ "addedAt": "2026-07-24", "updatedAt": "2026-07-31" }, + { + "label": "Build a Chat Adapter", + "to": "persistence/build-your-own-chat-adapter", + "addedAt": "2026-07-31" + }, + { + "label": "Build a Generation Adapter", + "to": "persistence/build-your-own-generation-adapter", + "addedAt": "2026-07-31" + }, + { + "label": "Store Reference", + "to": "persistence/store-reference", + "addedAt": "2026-07-31" + }, { "label": "Migrations", "to": "persistence/migrations", @@ -299,7 +314,7 @@ "label": "Internals", "to": "persistence/internals", "addedAt": "2026-07-22", - "updatedAt": "2026-07-30" + "updatedAt": "2026-07-31" } ] }, diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 49ac8512a..16ec4e01a 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -15,10 +15,17 @@ store interfaces from `@tanstack/ai-persistence`. Implement the ones you want against your database, hand the result to `withPersistence`, and you are done. The core never inspects your tables, so the schema is yours to shape. -This guide builds a complete SQLite adapter on Node's built-in `node:sqlite`, end -to end, then shows how to map the same contracts onto a database schema you -already have. The runnable version of everything here lives in the -`examples/ts-react-chat` app (`src/lib/sqlite-persistence.ts`). +This page covers the shape every adapter has, which stores your app actually +needs, and how to verify the result. The two walkthroughs build a real one on +Node's built-in `node:sqlite`, end to end: + +- [Build a chat adapter](./build-your-own-chat-adapter): the transcript, run + lifecycle, durable approvals, and key/value state. +- [Build a generation adapter](./build-your-own-generation-adapter): generation + runs plus the artifact and blob stores that keep generated media. + +The runnable version of both lives in the `examples/ts-react-chat` app +(`src/lib/sqlite-persistence.ts`). ## Which stores do you need? @@ -53,8 +60,8 @@ Two pairs cannot be split: nothing describing them, is not a usable combination. So the smallest adapter worth shipping is a single `messages` store, and the -common production shape is `messages` + `runs` + `interrupts`. The guide below -builds them in that order. +common production shape is `messages` + `runs` + `interrupts`. The +[chat walkthrough](./build-your-own-chat-adapter) builds them in that order. ## What an adapter is @@ -86,13 +93,13 @@ For generation: - `generationRuns`: the generation run lifecycle. The counterpart to `runs`, keyed by its own `runId`. - `artifacts` + `blobs`: keep the generated media bytes. See - [Generation & media stores](#generation--media-stores). + [Build a generation adapter](./build-your-own-generation-adapter). The middleware turns on behavior for whatever stores it finds, so a `messages`-only adapter is a valid adapter. -Those seven — `messages`, `runs`, `interrupts`, `metadata`, `generationRuns`, -`artifacts`, `blobs` — are the *only* keys `stores` accepts; anything else +Those seven keys (`messages`, `runs`, `interrupts`, `metadata`, +`generationRuns`, `artifacts`, `blobs`) are the *only* ones `stores` accepts; anything else throws `Unknown AIPersistence store key` at construction. Need a mutex across instances? That is `withLocks`; see [Locks](../advanced/locks). @@ -123,13 +130,13 @@ Annotate the value with a named shape: `stores.messages` is possibly `undefined`. Every method signature and invariant is in the -[store interface reference](#store-interface-reference) at the end of this page. +[store reference](./store-reference). The invariants (idempotent creates, insert-if-absent, ordered listings) are what the shared conformance suite checks, and getting one wrong is the usual source of subtle bugs. The records the stores hold form a small schema. The thread is not a table of -its own — it exists as the `thread_id` key the other records hang off — and +its own; it exists as the `thread_id` key the other records hang off. And `metadata` is independent of all of it (its identity is `(namespace, key)`). Note the asymmetry on the generation side. A chat run belongs to a thread, and its own `run_id` is secondary. A generation run is keyed by its own `run_id` @@ -138,11 +145,11 @@ first, and its `thread_id` names the slot the run fills, which is what ```mermaid erDiagram - MESSAGES ||--o{ RUN : "thread_id — a thread has many runs" - RUN ||--o{ INTERRUPT : "run_id — a run may pause on interrupts" + MESSAGES ||--o{ RUN : "thread_id, a thread has many runs" + RUN ||--o{ INTERRUPT : "run_id, a run may pause on interrupts" MESSAGES ||..o{ GENERATION_RUN : "thread_id, the slot a run fills" - GENERATION_RUN ||--o{ ARTIFACT : "run_id — a run produces artifacts" - ARTIFACT ||--|| BLOB : "blob_key — the bytes" + GENERATION_RUN ||--o{ ARTIFACT : "run_id, a run produces artifacts" + ARTIFACT ||--|| BLOB : "blob_key, the bytes" MESSAGES { string thread_id PK @@ -181,924 +188,11 @@ erDiagram } ``` -## New database: a SQLite adapter start to finish - -### 1. The schema - -Four tables. JSON payloads are stored as text (SQLite has no JSON column type), -timestamps as integers (epoch milliseconds), everything keyed the way the store -methods look records up. - -```sql -CREATE TABLE IF NOT EXISTS messages ( - thread_id text PRIMARY KEY NOT NULL, - messages_json text NOT NULL -); -CREATE TABLE IF NOT EXISTS runs ( - run_id text PRIMARY KEY NOT NULL, - thread_id text NOT NULL, - status text NOT NULL, - started_at integer NOT NULL, - finished_at integer, - error text, - usage_json text -); -CREATE TABLE IF NOT EXISTS interrupts ( - interrupt_id text PRIMARY KEY NOT NULL, - run_id text NOT NULL, - thread_id text NOT NULL, - status text NOT NULL, - requested_at integer NOT NULL, - resolved_at integer, - payload_json text NOT NULL, - response_json text -); -CREATE TABLE IF NOT EXISTS metadata ( - scope text NOT NULL, - key text NOT NULL, - value_json text NOT NULL, - PRIMARY KEY (scope, key) -); -``` - -### 2. Messages: full-transcript overwrite - -Two contracts to hold: - -- `saveThread` always receives the complete, authoritative history. It is a - replace, not an append. -- `loadThread` returns `[]` for a thread that was never saved, never `null`. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineMessageStore } from '@tanstack/ai-persistence' -import type { ModelMessage } from '@tanstack/ai' - -// `defineMessageStore` types the object inline against the contract — you get -// autocomplete and checking with no separate `: MessageStore` annotation. -function createMessageStore(db: DatabaseSync) { - const select = db.prepare( - 'SELECT messages_json FROM messages WHERE thread_id = ?', - ) - const upsert = db.prepare( - `INSERT INTO messages (thread_id, messages_json) VALUES (?, ?) - ON CONFLICT(thread_id) DO UPDATE SET messages_json = excluded.messages_json`, - ) - return defineMessageStore({ - async loadThread(threadId) { - const json = select.get(threadId)?.messages_json - // Unknown thread → [] (never null). `node:sqlite` types columns as a - // SQL-value union, so narrow to string before parsing (no cast). - if (typeof json !== 'string') return [] - const parsed: Array = JSON.parse(json) - return parsed - }, - async saveThread(threadId, messages) { - upsert.run(threadId, JSON.stringify(messages)) - }, - }) -} -``` - -The methods are `async`, so `node:sqlite` (a synchronous driver) needs no -`Promise.resolve` wrapper: `async` promotes the returned value to a promise, and -a method that returns nothing resolves to `void`. On an async driver, `await` the -query instead. - -### 3. Runs: idempotent create, patch, get - -Two contracts to hold: - -- `createOrResume` must be idempotent. If the run id already exists, return the - stored record unchanged, so resuming a run never resets its `startedAt` or - status. `INSERT ... ON CONFLICT DO NOTHING` gives you that in one statement. -- `update` on an unknown run id is a no-op. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineRunStore } from '@tanstack/ai-persistence' -import type { RunRecord, RunStatus } from '@tanstack/ai-persistence' - -// The `status` column is text; validate it back into the union (no cast). -function toRunStatus(value: unknown): RunStatus { - switch (value) { - case 'running': - case 'completed': - case 'failed': - case 'interrupted': - return value - default: - throw new TypeError(`Unexpected run status: ${String(value)}`) - } -} - -// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field -// (String / Number / typeof) rather than casting the whole row. -function mapRun(row: Record): RunRecord { - return { - runId: String(row.run_id), - threadId: String(row.thread_id), - status: toRunStatus(row.status), - startedAt: Number(row.started_at), - ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), - ...(typeof row.error === 'string' ? { error: row.error } : {}), - ...(typeof row.usage_json === 'string' - ? { usage: JSON.parse(row.usage_json) } - : {}), - } -} - -function createRunStore(db: DatabaseSync) { - const select = db.prepare('SELECT * FROM runs WHERE run_id = ?') - const insert = db.prepare( - `INSERT INTO runs (run_id, thread_id, status, started_at) VALUES (?, ?, ?, ?) - ON CONFLICT(run_id) DO NOTHING`, - ) - const active = db.prepare( - `SELECT * FROM runs WHERE thread_id = ? AND status = 'running' - ORDER BY started_at DESC LIMIT 1`, - ) - return defineRunStore({ - async createOrResume(input) { - const existing = select.get(input.runId) - if (existing) return mapRun(existing) - const status: RunStatus = input.status ?? 'running' - insert.run(input.runId, input.threadId, status, input.startedAt) - return { - runId: input.runId, - threadId: input.threadId, - status, - startedAt: input.startedAt, - } - }, - async update(runId, patch) { - const sets: Array = [] - const params: Array = [] - if (patch.status !== undefined) { - sets.push('status = ?') - params.push(patch.status) - } - if (patch.finishedAt !== undefined) { - sets.push('finished_at = ?') - params.push(patch.finishedAt) - } - if (patch.error !== undefined) { - sets.push('error = ?') - params.push(patch.error) - } - if (patch.usage !== undefined) { - sets.push('usage_json = ?') - params.push(JSON.stringify(patch.usage)) - } - if (sets.length === 0) return - params.push(runId) - db.prepare(`UPDATE runs SET ${sets.join(', ')} WHERE run_id = ?`).run( - ...params, - ) - }, - async get(runId) { - const row = select.get(runId) - return row ? mapRun(row) : null - }, - // The most recent still-running run for a thread. `reconstructChat` calls - // this so a hydrating client (a reload, another device, or switching back to - // a generating thread) learns there is a live run and tails it. Stub it to - // null and the thread always looks idle on hydrate: the transcript restores, - // but a reply that was mid-stream never resumes. - async findActiveRun(threadId) { - const row = active.get(threadId) - return row ? mapRun(row) : null - }, - }) -} -``` - -`update` builds its `SET` list from only the fields present in the patch, so an -empty patch touches nothing and a partial patch leaves other columns alone. Map -each row back with a small helper that omits absent optional fields and parses -the JSON columns. - -### 4. Interrupts: insert-if-absent, ordered listings - -`create` is insert-if-absent: a duplicate interrupt id must never overwrite an -interrupt that was already resolved. Every `list*` method returns records ordered -by `requested_at` ascending, which the middleware relies on. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineInterruptStore } from '@tanstack/ai-persistence' -import type { - InterruptRecord, - InterruptStatus, -} from '@tanstack/ai-persistence' - -function toInterruptStatus(value: unknown): InterruptStatus { - switch (value) { - case 'pending': - case 'resolved': - case 'cancelled': - return value - default: - throw new TypeError(`Unexpected interrupt status: ${String(value)}`) - } -} - -function mapInterrupt(row: Record): InterruptRecord { - return { - interruptId: String(row.interrupt_id), - runId: String(row.run_id), - threadId: String(row.thread_id), - status: toInterruptStatus(row.status), - requestedAt: Number(row.requested_at), - ...(row.resolved_at != null ? { resolvedAt: Number(row.resolved_at) } : {}), - payload: - typeof row.payload_json === 'string' ? JSON.parse(row.payload_json) : {}, - ...(typeof row.response_json === 'string' - ? { response: JSON.parse(row.response_json) } - : {}), - } -} - -function createInterruptStore(db: DatabaseSync) { - const insert = db.prepare( - `INSERT INTO interrupts - (interrupt_id, run_id, thread_id, status, requested_at, payload_json, response_json) - VALUES (?, ?, ?, 'pending', ?, ?, ?) - ON CONFLICT(interrupt_id) DO NOTHING`, - ) - const resolveRow = db.prepare( - `UPDATE interrupts SET status = 'resolved', resolved_at = ?, response_json = ? - WHERE interrupt_id = ?`, - ) - const cancelRow = db.prepare( - `UPDATE interrupts SET status = 'cancelled', resolved_at = ? WHERE interrupt_id = ?`, - ) - const selectOne = db.prepare('SELECT * FROM interrupts WHERE interrupt_id = ?') - // Every listing is ORDER BY requested_at ASC — the middleware relies on it. - const byThread = db.prepare( - 'SELECT * FROM interrupts WHERE thread_id = ? ORDER BY requested_at ASC', - ) - const pendingByThread = db.prepare( - `SELECT * FROM interrupts WHERE thread_id = ? AND status = 'pending' - ORDER BY requested_at ASC`, - ) - const byRun = db.prepare( - 'SELECT * FROM interrupts WHERE run_id = ? ORDER BY requested_at ASC', - ) - const pendingByRun = db.prepare( - `SELECT * FROM interrupts WHERE run_id = ? AND status = 'pending' - ORDER BY requested_at ASC`, - ) - return defineInterruptStore({ - async create(record) { - // Insert-if-absent: a duplicate id must never clobber an already-resolved - // interrupt back to pending. - insert.run( - record.interruptId, - record.runId, - record.threadId, - record.requestedAt, - JSON.stringify(record.payload), - record.response === undefined ? null : JSON.stringify(record.response), - ) - }, - async resolve(interruptId, response) { - resolveRow.run( - Date.now(), - response === undefined ? null : JSON.stringify(response), - interruptId, - ) - }, - async cancel(interruptId) { - cancelRow.run(Date.now(), interruptId) - }, - async get(interruptId) { - const row = selectOne.get(interruptId) - return row ? mapInterrupt(row) : null - }, - async list(threadId) { - return byThread.all(threadId).map(mapInterrupt) - }, - async listPending(threadId) { - return pendingByThread.all(threadId).map(mapInterrupt) - }, - async listByRun(runId) { - return byRun.all(runId).map(mapInterrupt) - }, - async listPendingByRun(runId) { - return pendingByRun.all(runId).map(mapInterrupt) - }, - }) -} -``` - -### 5. Metadata: reject nullish - -`(scope, key)` is the composite identity. A SQL backend cannot store a nullish -value in a `NOT NULL` text column, so reject `null` and `undefined` with a clear -error instead of a cryptic driver failure. Callers clear a value with `delete`. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineMetadataStore } from '@tanstack/ai-persistence' - -function createMetadataStore(db: DatabaseSync) { - const select = db.prepare( - 'SELECT value_json FROM metadata WHERE scope = ? AND key = ?', - ) - const upsert = db.prepare( - `INSERT INTO metadata (scope, key, value_json) VALUES (?, ?, ?) - ON CONFLICT(scope, key) DO UPDATE SET value_json = excluded.value_json`, - ) - return defineMetadataStore({ - async get(scope, key) { - const json = select.get(scope, key)?.value_json - return typeof json === 'string' ? JSON.parse(json) : null - }, - async set(scope, key, value) { - if (value == null) { - throw new TypeError( - 'Metadata values must be defined, non-null JSON. Use delete() to clear.', - ) - } - upsert.run(scope, key, JSON.stringify(value)) - }, - async delete(scope, key) { - db.prepare('DELETE FROM metadata WHERE scope = ? AND key = ?').run( - scope, - key, - ) - }, - }) -} -``` - -### 6. Assemble the adapter - -Open the database, create the tables, and return the stores as an -`AIPersistence`. `defineAIPersistence` keeps the exact store keys in the type and -rejects unknown keys at runtime. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineAIPersistence } from '@tanstack/ai-persistence' -import type { ChatPersistence } from '@tanstack/ai-persistence' -// The four store factories and the schema string, each from your own module. -import { createInterruptStore } from './interrupt-store' -import { createMessageStore } from './message-store' -import { createMetadataStore } from './metadata-store' -import { createRunStore } from './run-store' -import { SCHEMA_SQL } from './schema' - -export function sqlitePersistence(options: { - url: string - migrate?: boolean -}): ChatPersistence { - const db = new DatabaseSync(options.url) - if (options.migrate) db.exec(SCHEMA_SQL) - return defineAIPersistence({ - stores: { - messages: createMessageStore(db), - runs: createRunStore(db), - interrupts: createInterruptStore(db), - metadata: createMetadataStore(db), - }, - }) -} -``` - -That is a complete backend. If you also need a mutex across workers, add -`withLocks` alongside it; see [Locks](../advanced/locks). - -Wire it into `chat()` exactly like any other persistence: - -```ts -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) -} -``` - -## Generation & media stores - -Everything above builds a **chat** adapter. -[Media generation](./generation-persistence) persists differently: it does not -use the chat `runs` store at all. - -- **Required:** a `generationRuns` store, a `GenerationRunStore` keyed by - `runId` (the run/request id a generation mints). It is the counterpart to - `runs`. -- **Optional, to keep the generated bytes:** an `artifacts` store (metadata) and - a `blobs` store (the bytes). These two must be provided **together**. - -`threadId` is the slot the run belongs to, recorded on each run record. - -These are three more tables alongside the four from the schema in step 1: - -```sql -CREATE TABLE IF NOT EXISTS generation_runs ( - run_id text PRIMARY KEY NOT NULL, - thread_id text NOT NULL, - activity text NOT NULL, - provider text NOT NULL, - model text NOT NULL, - status text NOT NULL, - started_at integer NOT NULL, - finished_at integer, - error_json text, - result_json text, - artifacts_json text, - usage_json text -); -CREATE TABLE IF NOT EXISTS artifacts ( - artifact_id text PRIMARY KEY NOT NULL, - run_id text NOT NULL, - thread_id text NOT NULL, - blob_key text, - name text NOT NULL, - mime_type text NOT NULL, - size integer NOT NULL, - source_url text, - created_at integer NOT NULL -); -CREATE TABLE IF NOT EXISTS blobs ( - key text PRIMARY KEY NOT NULL, - bytes blob NOT NULL, - size integer NOT NULL, - etag text NOT NULL, - content_type text, - custom_metadata_json text, - created_at integer NOT NULL, - updated_at integer NOT NULL -); -``` - -### Generation runs: idempotent create, patch, latest-for-thread - -`GenerationRunStore` is the generation analogue of `RunStore`. Three contracts -to hold: - -- `createOrResume` is idempotent. A second call for a `runId` returns the stored - record unchanged, so resuming a run never resets its `startedAt`, `activity`, - or status. `INSERT ... ON CONFLICT DO NOTHING` gives you that. -- `update` on an unknown `runId` is a no-op. -- `findLatestForThread` returns the run with the greatest `startedAt` linked to a - thread. `reconstructGeneration` calls it to hydrate the last generation for a - thread on a server-driven client's mount. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineGenerationRunStore } from '@tanstack/ai-persistence' -import type { - GenerationRunRecord, - GenerationRunStatus, -} from '@tanstack/ai-persistence' - -function toGenerationRunStatus(value: unknown): GenerationRunStatus { - switch (value) { - case 'running': - case 'completed': - case 'failed': - case 'interrupted': - return value - default: - throw new TypeError(`Unexpected generation run status: ${String(value)}`) - } -} - -// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field -// (String / Number / typeof) and JSON-parse the text columns — no cast. -function mapGenerationRun(row: Record): GenerationRunRecord { - return { - runId: String(row.run_id), - threadId: String(row.thread_id), - activity: String(row.activity), - provider: String(row.provider), - model: String(row.model), - status: toGenerationRunStatus(row.status), - startedAt: Number(row.started_at), - ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), - ...(typeof row.error_json === 'string' - ? { error: JSON.parse(row.error_json) } - : {}), - ...(typeof row.result_json === 'string' - ? { result: JSON.parse(row.result_json) } - : {}), - ...(typeof row.artifacts_json === 'string' - ? { artifacts: JSON.parse(row.artifacts_json) } - : {}), - ...(typeof row.usage_json === 'string' - ? { usage: JSON.parse(row.usage_json) } - : {}), - } -} - -function createGenerationRunStore(db: DatabaseSync) { - const select = db.prepare('SELECT * FROM generation_runs WHERE run_id = ?') - const insert = db.prepare( - `INSERT INTO generation_runs - (run_id, thread_id, activity, provider, model, status, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id) DO NOTHING`, - ) - const latest = db.prepare( - `SELECT * FROM generation_runs WHERE thread_id = ? - ORDER BY started_at DESC LIMIT 1`, - ) - return defineGenerationRunStore({ - async createOrResume(input) { - const existing = select.get(input.runId) - if (existing) return mapGenerationRun(existing) - const status: GenerationRunStatus = input.status ?? 'running' - insert.run( - input.runId, - input.threadId, - input.activity, - input.provider, - input.model, - status, - input.startedAt, - ) - return { - runId: input.runId, - threadId: input.threadId, - activity: input.activity, - provider: input.provider, - model: input.model, - status, - startedAt: input.startedAt, - } - }, - async update(runId, patch) { - const sets: Array = [] - const params: Array = [] - if (patch.status !== undefined) { - sets.push('status = ?') - params.push(patch.status) - } - if (patch.finishedAt !== undefined) { - sets.push('finished_at = ?') - params.push(patch.finishedAt) - } - if (patch.error !== undefined) { - sets.push('error_json = ?') - params.push(JSON.stringify(patch.error)) - } - if (patch.result !== undefined) { - sets.push('result_json = ?') - params.push(JSON.stringify(patch.result)) - } - if (patch.artifacts !== undefined) { - sets.push('artifacts_json = ?') - params.push(JSON.stringify(patch.artifacts)) - } - if (patch.usage !== undefined) { - sets.push('usage_json = ?') - params.push(JSON.stringify(patch.usage)) - } - // Empty patch, or an unknown run id, touches nothing (UPDATE no-ops). - if (sets.length === 0) return - params.push(runId) - db.prepare( - `UPDATE generation_runs SET ${sets.join(', ')} WHERE run_id = ?`, - ).run(...params) - }, - async get(runId) { - const row = select.get(runId) - return row ? mapGenerationRun(row) : null - }, - // The most recent run linked to a thread. `reconstructGeneration` calls this - // so a server-driven client (`persistence: true`) hydrates the last - // generation for its thread by the stable thread id, without a run id. - async findLatestForThread(threadId) { - const row = latest.get(threadId) - return row ? mapGenerationRun(row) : null - }, - }) -} -``` - -### Artifacts: media metadata - -`ArtifactStore` holds one metadata row per generated file: its `runId`, -`mimeType`, `size`, and a `createdAt`. The bytes live in the blob store below. - -- `save` is an upsert. -- `list(runId)` returns every artifact for a run, `[]` when there are none. -- `delete` / `deleteForRun` are required. Retention and erasure are the point of - storing media durably, and they mirror `BlobStore.delete`. - -Persist `blobKey` verbatim. It records where these bytes actually went, and a -`storageKey` mapper can put them anywhere, so a reader cannot recompute the -path — `resolveArtifactBlobKey(record)` falls back to the default convention -only for rows written before the column existed. Drop it and every artifact -stored under a custom key becomes unreadable. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineArtifactStore } from '@tanstack/ai-persistence' -import type { ArtifactRecord } from '@tanstack/ai-persistence' - -function mapArtifact(row: Record): ArtifactRecord { - return { - artifactId: String(row.artifact_id), - runId: String(row.run_id), - threadId: String(row.thread_id), - ...(typeof row.blob_key === 'string' ? { blobKey: row.blob_key } : {}), - name: String(row.name), - mimeType: String(row.mime_type), - size: Number(row.size), - ...(typeof row.source_url === 'string' - ? { sourceUrl: row.source_url } - : {}), - createdAt: Number(row.created_at), - } -} - -function createArtifactStore(db: DatabaseSync) { - const upsert = db.prepare( - `INSERT INTO artifacts - (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(artifact_id) DO UPDATE SET - run_id = excluded.run_id, thread_id = excluded.thread_id, - blob_key = excluded.blob_key, name = excluded.name, - mime_type = excluded.mime_type, size = excluded.size, - source_url = excluded.source_url, created_at = excluded.created_at`, - ) - const selectOne = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') - const byRun = db.prepare( - 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', - ) - return defineArtifactStore({ - async save(record) { - upsert.run( - record.artifactId, - record.runId, - record.threadId, - record.blobKey ?? null, - record.name, - record.mimeType, - record.size, - record.sourceUrl ?? null, - record.createdAt, - ) - }, - async get(artifactId) { - const row = selectOne.get(artifactId) - return row ? mapArtifact(row) : null - }, - async list(runId) { - return byRun.all(runId).map(mapArtifact) - }, - async delete(artifactId) { - db.prepare('DELETE FROM artifacts WHERE artifact_id = ?').run(artifactId) - }, - async deleteForRun(runId) { - db.prepare('DELETE FROM artifacts WHERE run_id = ?').run(runId) - }, - }) -} -``` - -### Blobs: the bytes - -`BlobStore` is a small object store. `withGenerationPersistence` writes each -generated file under the key `artifacts//`, so a -prefix-filtered `list({ prefix: 'artifacts//' })` enumerates a run's -media. - -- `put` accepts any `BlobBody`: a stream, buffer, string, or `Blob`. The helper - below normalizes it to bytes. -- `list` matches `prefix` literally and pages with a keyset cursor. - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineBlobStore } from '@tanstack/ai-persistence' -import type { - BlobBody, - BlobObject, - BlobRecord, -} from '@tanstack/ai-persistence' - -async function toBytes(body: BlobBody): Promise { - if (typeof body === 'string') return new TextEncoder().encode(body) - if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) - if (ArrayBuffer.isView(body)) { - return new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice() - } - if (body instanceof Blob) { - return new Uint8Array(await body.arrayBuffer()) - } - // ReadableStream: drain it into one buffer. - const reader = body.getReader() - const chunks: Array = [] - let total = 0 - for (;;) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - total += value.byteLength - } - const bytes = new Uint8Array(total) - let offset = 0 - for (const chunk of chunks) { - bytes.set(chunk, offset) - offset += chunk.byteLength - } - return bytes -} - -function mapBlobRecord(row: Record): BlobRecord { - return { - key: String(row.key), - ...(row.size != null ? { size: Number(row.size) } : {}), - ...(typeof row.etag === 'string' ? { etag: row.etag } : {}), - ...(typeof row.content_type === 'string' - ? { contentType: row.content_type } - : {}), - ...(typeof row.custom_metadata_json === 'string' - ? { customMetadata: JSON.parse(row.custom_metadata_json) } - : {}), - ...(row.created_at != null ? { createdAt: Number(row.created_at) } : {}), - ...(row.updated_at != null ? { updatedAt: Number(row.updated_at) } : {}), - } -} - -function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { - return { - ...record, - body: new ReadableStream({ - start(controller) { - controller.enqueue(bytes.slice()) - controller.close() - }, - }), - arrayBuffer() { - const copy = new ArrayBuffer(bytes.byteLength) - new Uint8Array(copy).set(bytes) - return Promise.resolve(copy) - }, - text: () => Promise.resolve(new TextDecoder().decode(bytes)), - } -} - -function createBlobStore(db: DatabaseSync) { - const upsert = db.prepare( - `INSERT INTO blobs - (key, bytes, size, etag, content_type, custom_metadata_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(key) DO UPDATE SET - bytes = excluded.bytes, size = excluded.size, etag = excluded.etag, - content_type = excluded.content_type, - custom_metadata_json = excluded.custom_metadata_json, - updated_at = excluded.updated_at`, - ) - const selectCreated = db.prepare('SELECT created_at FROM blobs WHERE key = ?') - const selectOne = db.prepare('SELECT * FROM blobs WHERE key = ?') - return defineBlobStore({ - async put(key, body, options) { - const bytes = await toBytes(body) - const now = Date.now() - const prior = selectCreated.get(key) - const createdAt = - prior && prior.created_at != null ? Number(prior.created_at) : now - const etag = String(now) - upsert.run( - key, - bytes, - bytes.byteLength, - etag, - options?.contentType ?? null, - options?.customMetadata ? JSON.stringify(options.customMetadata) : null, - createdAt, - now, - ) - return { - key, - size: bytes.byteLength, - etag, - createdAt, - updatedAt: now, - ...(options?.contentType !== undefined - ? { contentType: options.contentType } - : {}), - ...(options?.customMetadata !== undefined - ? { customMetadata: options.customMetadata } - : {}), - } - }, - async get(key) { - const row = selectOne.get(key) - if (!row) return null - const bytes = - row.bytes instanceof Uint8Array ? row.bytes : new Uint8Array() - return blobObject(mapBlobRecord(row), bytes) - }, - async head(key) { - const row = selectOne.get(key) - return row ? mapBlobRecord(row) : null - }, - async delete(key) { - db.prepare('DELETE FROM blobs WHERE key = ?').run(key) - }, - async list(options) { - if (options?.limit === 0) return { objects: [], truncated: false } - // Match the prefix with `substr(...) = ?` rather than LIKE: SQLite's LIKE - // is case-INsensitive for ASCII and treats `%`/`_` as wildcards, while the - // contract says a prefix matches literally and case-sensitively. Then page - // with a keyset cursor (keys strictly greater than the last one returned). - const prefix = options?.prefix ?? '' - const params: Array = [prefix, prefix] - let where = 'substr(key, 1, length(?)) = ?' - if (options?.cursor !== undefined) { - where += ' AND key > ?' - params.push(options.cursor) - } - let sql = `SELECT * FROM blobs WHERE ${where} ORDER BY key ASC` - const limit = options?.limit - if (limit !== undefined) { - sql += ' LIMIT ?' // fetch one extra row to detect truncation - params.push(limit + 1) - } - const rows = db - .prepare(sql) - .all(...params) - .map(mapBlobRecord) - if (limit !== undefined && rows.length > limit) { - const page = rows.slice(0, limit) - const cursor = page.at(-1)?.key - return { - objects: page, - truncated: true, - ...(cursor !== undefined ? { cursor } : {}), - } - } - return { objects: rows, truncated: false } - }, - }) -} -``` - -### Assemble a generation adapter - -Hand the three stores to `defineAIPersistence` the same way. `generationRuns` alone is a -valid generation adapter (run records, no byte storage); add `artifacts` + -`blobs` — together — to keep the media: - -```ts -import { DatabaseSync } from 'node:sqlite' -import { defineAIPersistence } from '@tanstack/ai-persistence' -// The three generation store factories and the schema string, from your modules. -import { createArtifactStore } from './artifact-store' -import { createBlobStore } from './blob-store' -import { createGenerationRunStore } from './generation-run-store' -import { GENERATION_SCHEMA_SQL } from './generation-schema' - -export function generationPersistence(options: { - url: string - migrate?: boolean -}) { - const db = new DatabaseSync(options.url) - if (options.migrate) db.exec(GENERATION_SCHEMA_SQL) - return defineAIPersistence({ - stores: { - generationRuns: createGenerationRunStore(db), - artifacts: createArtifactStore(db), - blobs: createBlobStore(db), - }, - }) -} -``` - -Pass the result to `withGenerationPersistence` on a `generateImage` / -`generateVideo` / … call; see [Generation persistence](./generation-persistence). -You can also fold these stores into an existing chat adapter with -`composePersistence`, so one backend serves both `withPersistence` and -`withGenerationPersistence`. - ## Existing database: map the contracts onto your schema -You do not have to create the four tables above. If you already have a database, -map each store method onto the tables and columns you already run. Three things -change from the from-scratch version. +You do not have to create tables at all. If you already have a database, map each +store method onto the tables and columns you already run. Three things change from +the from-scratch walkthroughs. **Your column names, your types.** The core reads and writes only through your store methods, so name columns whatever you like and use your database's native @@ -1132,6 +226,7 @@ must touch both is two writes; design retries and idempotency for that yourself. The store invariants (idempotent `createOrResume`, insert-if-absent `create`) are what make those retries safe, which is exactly why they are invariants. + ## Verify with the conformance suite Do not eyeball it. `@tanstack/ai-persistence` ships the same conformance test @@ -1148,9 +243,8 @@ runPersistenceConformance('my sqlite adapter', () => ) ``` -The suite covers all seven stores — the four chat state stores and the three -generation stores from the section above — so an adapter lists whatever it -deliberately omits. A chat-only adapter skips the generation half: +The suite covers all seven stores, the four chat state stores and the three +generation stores, so an adapter lists whatever it deliberately omits. A chat-only adapter skips the generation half: ```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' @@ -1189,6 +283,7 @@ green, your adapter is a drop-in for `withPersistence` (and, with the generation stores, `withGenerationPersistence`). The `examples/ts-react-chat` app runs exactly this test against its SQLite backend, which provides all seven. + ## Let your coding agent write it You do not have to type this page out. `@tanstack/ai-persistence` ships @@ -1196,7 +291,7 @@ You do not have to type this page out. `@tanstack/ai-persistence` ships assistant follows against **your** stack: it reads your existing ORM config, schema file, and database handle, appends the four tables to the schema you already have, and writes a single `src/lib/chat-persistence.ts` exporting the -`ChatPersistence` — no new package, no second database client, and no migration +`ChatPersistence`. No new package, no second database client, and no migration mechanism competing with the one you run. Install the skills with [TanStack Intent](https://tanstack.com/intent/latest/docs/overview), @@ -1208,21 +303,21 @@ pnpm add @tanstack/ai-persistence npx @tanstack/intent@latest install ``` -Then ask for what you want — "add chat persistence to this app" — and the +Then ask for what you want ("add chat persistence to this app") and the matching skill loads itself into context: | Skill | Covers | | ----------------------------------------- | ------------------------------------------------------------------- | -| `ai-persistence` | Entry point — routes to everything below | +| `ai-persistence` | Entry point, routes to everything below | | `ai-persistence/server` | `withPersistence`, run lifecycle, interrupts, `reconstructChat` | | `ai-persistence/stores` | The store contracts and their invariants | | `ai-core/locks` | `LockStore` / `withLocks` coordination (ships in `@tanstack/ai/locks`) | | `ai-persistence/build-drizzle-adapter` | `chat-persistence.ts` for a Drizzle app (SQLite / Postgres / MySQL) | | `ai-persistence/build-prisma-adapter` | `chat-persistence.ts` for a Prisma app | | `ai-persistence/build-cloudflare-adapter` | `chat-persistence.ts` for a Worker on D1, plus Durable Object locks | -| `ai-persistence/build-custom-adapter` | `chat-persistence.ts` for anything else — raw `pg`, Kysely, SQLite, Mongo, Supabase | +| `ai-persistence/build-custom-adapter` | `chat-persistence.ts` for anything else: raw `pg`, Kysely, SQLite, Mongo, Supabase | -Browser-side persistence is not in this package — its skill ships with +Browser-side persistence is not in this package. Its skill ships with `@tanstack/ai` as `ai-core/client-persistence`, alongside the framework code it teaches. @@ -1230,283 +325,11 @@ They are plain Markdown at `node_modules/@tanstack/ai-persistence/skills//SKILL.md` if you prefer to read or follow them yourself. -## Store interface reference - -These are the public contracts from `@tanstack/ai-persistence`. Implement only -the stores you need. - -### MessageStore - -```ts -import type { ModelMessage } from '@tanstack/ai' - -interface MessageStore { - loadThread(threadId: string): Promise> - saveThread(threadId: string, messages: Array): Promise -} -``` - -`saveThread` receives the full authoritative model-message history, not a delta. -`loadThread` returns `[]` (never `null`) for a thread that was never saved. - -### RunStore - -```ts -import type { TokenUsage } from '@tanstack/ai' - -interface RunRecord { - runId: string - threadId: string - status: 'running' | 'completed' | 'failed' | 'interrupted' - startedAt: number // epoch ms - finishedAt?: number // epoch ms, set once the run reaches a terminal status - error?: string - usage?: TokenUsage // token counts, from @tanstack/ai -} - -interface RunStore { - createOrResume(input: { - runId: string - threadId: string - status?: RunRecord['status'] - startedAt: number - }): Promise - update( - runId: string, - patch: Partial< - Pick - >, - ): Promise - get(runId: string): Promise - // The most recent 'running' run for a thread (greatest `startedAt` wins), or - // null when the thread is idle. `reconstructChat` calls it to report - // `activeRun`, which is how a hydrating client tails a run that is still - // generating. - findActiveRun(threadId: string): Promise -} -``` - -Three contracts to hold: - -- `createOrResume` must be idempotent. A second call for an existing `runId` - returns the stored record unchanged, which is what makes resuming a run safe. - Retries may repeat the same run id. -- `update` against an unknown `runId` is a no-op. -- `findActiveRun` must do real work. Stub it to `null` and `reconstructChat` - always reports `activeRun: null`, so a client that reloads (or switches back - to) a still-generating thread restores the transcript but never resumes the - live reply. Nothing detects it either, because `null` is also the right answer - for an idle thread. - -Every method on a store you provide is required. A backend that genuinely has no -run lifecycle should declare `ChatTranscriptStores` and omit `runs` entirely -rather than supply a `RunStore` with a stubbed method: an absent store is caught -by the type system, an incomplete one fails silently at runtime. - -### InterruptStore - -```ts -interface InterruptRecord { - interruptId: string - runId: string - threadId: string - status: 'pending' | 'resolved' | 'cancelled' - requestedAt: number // epoch ms - resolvedAt?: number // epoch ms, set once resolved or cancelled - payload: Record - response?: unknown -} - -interface InterruptStore { - create(record: Omit): Promise - resolve(interruptId: string, response?: unknown): Promise - cancel(interruptId: string): Promise - get(interruptId: string): Promise - list(threadId: string): Promise> - listPending(threadId: string): Promise> - listByRun(runId: string): Promise> - listPendingByRun(runId: string): Promise> -} -``` - -`create` accepts a record without `status`/`resolvedAt` so every interrupt is -born `'pending'`; it is insert-if-absent, so a duplicate `create` never clobbers -an already-resolved interrupt. The `list*` methods return records ordered by -`requestedAt` ascending. An `interrupts` store requires a `runs` store when used -with chat persistence. - -### MetadataStore - -```ts -interface MetadataStore { - get(scope: string, key: string): Promise - set(scope: string, key: string, value: unknown): Promise - delete(scope: string, key: string): Promise -} -``` - -Namespaces and value schemas are application-owned, and `(scope, key)` is the -composite identity. A stored `null` is indistinguishable from absence at the type -level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or -reject nullish values outright the way the SQLite store above does. - -### GenerationRunStore - -The generation counterpart to `RunStore`. Keyed by its own `runId`, with -`threadId` the slot `findLatestForThread` looks runs up by. -`withGenerationPersistence` requires this store, not `runs`. - -Its `status` uses the same vocabulary as a chat run's `RunStatus`, so one status -column and one set of checks cover both tables. - -```ts -import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai' - -// The same vocabulary as a chat run's `RunStatus`. -type GenerationRunStatus = 'running' | 'completed' | 'failed' | 'interrupted' - -interface GenerationRunRecord { - runId: string - threadId: string // the slot this run fills, hydrated by findLatestForThread - activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription' - provider: string - model: string - status: GenerationRunStatus - startedAt: number // epoch ms - finishedAt?: number // epoch ms, set once the run reaches a terminal status - error?: { message: string; code?: string } - result?: unknown // terminal result metadata (ids, urls) — never media bytes - artifacts?: Array // present with an artifacts + blobs backend - usage?: TokenUsage -} - -interface GenerationRunStore { - createOrResume(input: { - runId: string - activity: string - provider: string - model: string - startedAt: number - threadId: string - status?: GenerationRunStatus - }): Promise - update( - runId: string, - patch: Partial< - Pick< - GenerationRunRecord, - 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' - > - >, - ): Promise - get(runId: string): Promise - // The most recent run filed under a thread (greatest `startedAt`), or null. - // Required: it is the only query that hydrates a generation, so an adapter - // without it would be indistinguishable from one whose thread has no runs — - // `persistence: true` would silently restore nothing, forever. - findLatestForThread(threadId: string): Promise -} -``` - -Implement `createOrResume` idempotently: a second call for an existing `runId` -returns the stored record unchanged (`startedAt` / `activity` / `provider` / -`model` / `threadId` are not mutated), which is what makes resuming a run safe. -`update` against an unknown `runId` is a no-op. - -### ArtifactStore - -Metadata rows for persisted media. The bytes live in a `BlobStore`; this record -holds the descriptive metadata and an optional `sourceUrl` for reference-only -backends. Provide it together with a `BlobStore` to keep generated bytes. - -```ts -interface ArtifactRecord { - artifactId: string - runId: string - threadId: string - blobKey?: string // where the bytes live; absent on pre-blobKey records - name: string - mimeType: string - size: number - sourceUrl?: string // where the bytes were fetched FROM (provenance) - createdAt: number // epoch ms -} - -interface ArtifactStore { - save(record: ArtifactRecord): Promise - get(artifactId: string): Promise - list(runId: string): Promise> // [] when the run has none - delete(artifactId: string): Promise - deleteForRun(runId: string): Promise -} -``` - -### BlobStore - -A durable object/blob store for the bytes. `withGenerationPersistence` writes -each generated file under the key `artifacts//`. - -```ts -type BlobBody = - | ReadableStream - | ArrayBuffer - | ArrayBufferView - | string - | Blob - -interface BlobRecord { - key: string - size?: number - etag?: string - contentType?: string - customMetadata?: Record - createdAt?: number // epoch ms first written - updatedAt?: number // epoch ms last overwritten -} - -interface BlobObject extends BlobRecord { - arrayBuffer(): Promise - text(): Promise - body?: ReadableStream -} - -interface BlobListPage { - objects: Array - cursor?: string // present only when `truncated` - truncated?: boolean -} - -interface BlobPutOptions { - contentType?: string - customMetadata?: Record -} - -interface BlobListOptions { - prefix?: string - cursor?: string - limit?: number -} - -interface BlobStore { - put(key: string, body: BlobBody, options?: BlobPutOptions): Promise - get(key: string): Promise - head(key: string): Promise - delete(key: string): Promise - list(options?: BlobListOptions): Promise -} -``` - -Three contracts to hold for `list`: - -- `prefix` matches literally and case-sensitively. Escape SQL `LIKE` - metacharacters. -- When `limit` is given and more keys match, return `truncated: true` with a - `cursor`. Passing that cursor back returns the strictly-following keys, so - paging visits every key exactly once. -- `limit: 0` yields an empty, untruncated page. - ## Where to go next +- [Build a chat adapter](./build-your-own-chat-adapter): the SQLite walkthrough for the four chat stores. +- [Build a generation adapter](./build-your-own-generation-adapter): generation runs, artifacts, and blobs. +- [Store reference](./store-reference): every method signature and invariant. - [Controls](./controls): compose stores from different systems. - [Locks](../advanced/locks): `LockStore` / `withLocks` coordination. - [Migrations](./migrations): who owns the schema and when to apply changes. diff --git a/docs/persistence/build-your-own-chat-adapter.md b/docs/persistence/build-your-own-chat-adapter.md new file mode 100644 index 000000000..9765ba50a --- /dev/null +++ b/docs/persistence/build-your-own-chat-adapter.md @@ -0,0 +1,436 @@ +--- +title: Build a Chat Adapter +id: build-your-own-chat-adapter +--- + +# Build a Chat Adapter + +You want the transcript, the run lifecycle, and durable approvals in your own +database, and you would rather write four small stores than add a service. This +page builds all of them against SQLite (Node's built-in `node:sqlite`), start to +finish, in the order an app usually grows into them. + +Read [Build your own adapter](./build-your-own-adapter) first for the shape of an +adapter and which stores your app needs. Every method signature and invariant is +in the [store reference](./store-reference). The runnable version of this +walkthrough lives in the `examples/ts-react-chat` app +(`src/lib/sqlite-persistence.ts`). + +## 1. The schema + +Four tables. JSON payloads are stored as text (SQLite has no JSON column type), +timestamps as integers (epoch milliseconds), everything keyed the way the store +methods look records up. + +```sql +CREATE TABLE IF NOT EXISTS messages ( + thread_id text PRIMARY KEY NOT NULL, + messages_json text NOT NULL +); +CREATE TABLE IF NOT EXISTS runs ( + run_id text PRIMARY KEY NOT NULL, + thread_id text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + finished_at integer, + error text, + usage_json text +); +CREATE TABLE IF NOT EXISTS interrupts ( + interrupt_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + status text NOT NULL, + requested_at integer NOT NULL, + resolved_at integer, + payload_json text NOT NULL, + response_json text +); +CREATE TABLE IF NOT EXISTS metadata ( + scope text NOT NULL, + key text NOT NULL, + value_json text NOT NULL, + PRIMARY KEY (scope, key) +); +``` + +## 2. Messages: full-transcript overwrite + +Two contracts to hold: + +- `saveThread` always receives the complete, authoritative history. It is a + replace, not an append. +- `loadThread` returns `[]` for a thread that was never saved, never `null`. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineMessageStore } from '@tanstack/ai-persistence' +import type { ModelMessage } from '@tanstack/ai' + +// `defineMessageStore` types the object inline against the contract, so you get +// autocomplete and checking with no separate `: MessageStore` annotation. +function createMessageStore(db: DatabaseSync) { + const select = db.prepare( + 'SELECT messages_json FROM messages WHERE thread_id = ?', + ) + const upsert = db.prepare( + `INSERT INTO messages (thread_id, messages_json) VALUES (?, ?) + ON CONFLICT(thread_id) DO UPDATE SET messages_json = excluded.messages_json`, + ) + return defineMessageStore({ + async loadThread(threadId) { + const json = select.get(threadId)?.messages_json + // Unknown thread → [] (never null). `node:sqlite` types columns as a + // SQL-value union, so narrow to string before parsing (no cast). + if (typeof json !== 'string') return [] + const parsed: Array = JSON.parse(json) + return parsed + }, + async saveThread(threadId, messages) { + upsert.run(threadId, JSON.stringify(messages)) + }, + }) +} +``` + +The methods are `async`, so `node:sqlite` (a synchronous driver) needs no +`Promise.resolve` wrapper: `async` promotes the returned value to a promise, and +a method that returns nothing resolves to `void`. On an async driver, `await` the +query instead. + +## 3. Runs: idempotent create, patch, get + +Two contracts to hold: + +- `createOrResume` must be idempotent. If the run id already exists, return the + stored record unchanged, so resuming a run never resets its `startedAt` or + status. `INSERT ... ON CONFLICT DO NOTHING` gives you that in one statement. +- `update` on an unknown run id is a no-op. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineRunStore } from '@tanstack/ai-persistence' +import type { RunRecord, RunStatus } from '@tanstack/ai-persistence' + +// The `status` column is text; validate it back into the union (no cast). +function toRunStatus(value: unknown): RunStatus { + switch (value) { + case 'running': + case 'completed': + case 'failed': + case 'interrupted': + return value + default: + throw new TypeError(`Unexpected run status: ${String(value)}`) + } +} + +// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field +// (String / Number / typeof) rather than casting the whole row. +function mapRun(row: Record): RunRecord { + return { + runId: String(row.run_id), + threadId: String(row.thread_id), + status: toRunStatus(row.status), + startedAt: Number(row.started_at), + ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), + ...(typeof row.error === 'string' ? { error: row.error } : {}), + ...(typeof row.usage_json === 'string' + ? { usage: JSON.parse(row.usage_json) } + : {}), + } +} + +function createRunStore(db: DatabaseSync) { + const select = db.prepare('SELECT * FROM runs WHERE run_id = ?') + const insert = db.prepare( + `INSERT INTO runs (run_id, thread_id, status, started_at) VALUES (?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const active = db.prepare( + `SELECT * FROM runs WHERE thread_id = ? AND status = 'running' + ORDER BY started_at DESC LIMIT 1`, + ) + return defineRunStore({ + async createOrResume(input) { + const existing = select.get(input.runId) + if (existing) return mapRun(existing) + const status: RunStatus = input.status ?? 'running' + insert.run(input.runId, input.threadId, status, input.startedAt) + return { + runId: input.runId, + threadId: input.threadId, + status, + startedAt: input.startedAt, + } + }, + async update(runId, patch) { + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error = ?') + params.push(patch.error) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + if (sets.length === 0) return + params.push(runId) + db.prepare(`UPDATE runs SET ${sets.join(', ')} WHERE run_id = ?`).run( + ...params, + ) + }, + async get(runId) { + const row = select.get(runId) + return row ? mapRun(row) : null + }, + // The most recent still-running run for a thread. `reconstructChat` calls + // this so a hydrating client (a reload, another device, or switching back to + // a generating thread) learns there is a live run and tails it. Stub it to + // null and the thread always looks idle on hydrate: the transcript restores, + // but a reply that was mid-stream never resumes. + async findActiveRun(threadId) { + const row = active.get(threadId) + return row ? mapRun(row) : null + }, + }) +} +``` + +`update` builds its `SET` list from only the fields present in the patch, so an +empty patch touches nothing and a partial patch leaves other columns alone. Map +each row back with a small helper that omits absent optional fields and parses +the JSON columns. + +## 4. Interrupts: insert-if-absent, ordered listings + +`create` is insert-if-absent: a duplicate interrupt id must never overwrite an +interrupt that was already resolved. Every `list*` method returns records ordered +by `requested_at` ascending, which the middleware relies on. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineInterruptStore } from '@tanstack/ai-persistence' +import type { + InterruptRecord, + InterruptStatus, +} from '@tanstack/ai-persistence' + +function toInterruptStatus(value: unknown): InterruptStatus { + switch (value) { + case 'pending': + case 'resolved': + case 'cancelled': + return value + default: + throw new TypeError(`Unexpected interrupt status: ${String(value)}`) + } +} + +function mapInterrupt(row: Record): InterruptRecord { + return { + interruptId: String(row.interrupt_id), + runId: String(row.run_id), + threadId: String(row.thread_id), + status: toInterruptStatus(row.status), + requestedAt: Number(row.requested_at), + ...(row.resolved_at != null ? { resolvedAt: Number(row.resolved_at) } : {}), + payload: + typeof row.payload_json === 'string' ? JSON.parse(row.payload_json) : {}, + ...(typeof row.response_json === 'string' + ? { response: JSON.parse(row.response_json) } + : {}), + } +} + +function createInterruptStore(db: DatabaseSync) { + const insert = db.prepare( + `INSERT INTO interrupts + (interrupt_id, run_id, thread_id, status, requested_at, payload_json, response_json) + VALUES (?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(interrupt_id) DO NOTHING`, + ) + const resolveRow = db.prepare( + `UPDATE interrupts SET status = 'resolved', resolved_at = ?, response_json = ? + WHERE interrupt_id = ?`, + ) + const cancelRow = db.prepare( + `UPDATE interrupts SET status = 'cancelled', resolved_at = ? WHERE interrupt_id = ?`, + ) + const selectOne = db.prepare('SELECT * FROM interrupts WHERE interrupt_id = ?') + // Every listing is ORDER BY requested_at ASC, which the middleware relies on. + const byThread = db.prepare( + 'SELECT * FROM interrupts WHERE thread_id = ? ORDER BY requested_at ASC', + ) + const pendingByThread = db.prepare( + `SELECT * FROM interrupts WHERE thread_id = ? AND status = 'pending' + ORDER BY requested_at ASC`, + ) + const byRun = db.prepare( + 'SELECT * FROM interrupts WHERE run_id = ? ORDER BY requested_at ASC', + ) + const pendingByRun = db.prepare( + `SELECT * FROM interrupts WHERE run_id = ? AND status = 'pending' + ORDER BY requested_at ASC`, + ) + return defineInterruptStore({ + async create(record) { + // Insert-if-absent: a duplicate id must never clobber an already-resolved + // interrupt back to pending. + insert.run( + record.interruptId, + record.runId, + record.threadId, + record.requestedAt, + JSON.stringify(record.payload), + record.response === undefined ? null : JSON.stringify(record.response), + ) + }, + async resolve(interruptId, response) { + resolveRow.run( + Date.now(), + response === undefined ? null : JSON.stringify(response), + interruptId, + ) + }, + async cancel(interruptId) { + cancelRow.run(Date.now(), interruptId) + }, + async get(interruptId) { + const row = selectOne.get(interruptId) + return row ? mapInterrupt(row) : null + }, + async list(threadId) { + return byThread.all(threadId).map(mapInterrupt) + }, + async listPending(threadId) { + return pendingByThread.all(threadId).map(mapInterrupt) + }, + async listByRun(runId) { + return byRun.all(runId).map(mapInterrupt) + }, + async listPendingByRun(runId) { + return pendingByRun.all(runId).map(mapInterrupt) + }, + }) +} +``` + +## 5. Metadata: reject nullish + +`(scope, key)` is the composite identity. A SQL backend cannot store a nullish +value in a `NOT NULL` text column, so reject `null` and `undefined` with a clear +error instead of a cryptic driver failure. Callers clear a value with `delete`. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineMetadataStore } from '@tanstack/ai-persistence' + +function createMetadataStore(db: DatabaseSync) { + const select = db.prepare( + 'SELECT value_json FROM metadata WHERE scope = ? AND key = ?', + ) + const upsert = db.prepare( + `INSERT INTO metadata (scope, key, value_json) VALUES (?, ?, ?) + ON CONFLICT(scope, key) DO UPDATE SET value_json = excluded.value_json`, + ) + return defineMetadataStore({ + async get(scope, key) { + const json = select.get(scope, key)?.value_json + return typeof json === 'string' ? JSON.parse(json) : null + }, + async set(scope, key, value) { + if (value == null) { + throw new TypeError( + 'Metadata values must be defined, non-null JSON. Use delete() to clear.', + ) + } + upsert.run(scope, key, JSON.stringify(value)) + }, + async delete(scope, key) { + db.prepare('DELETE FROM metadata WHERE scope = ? AND key = ?').run( + scope, + key, + ) + }, + }) +} +``` + +## 6. Assemble the adapter + +Open the database, create the tables, and return the stores as an +`AIPersistence`. `defineAIPersistence` keeps the exact store keys in the type and +rejects unknown keys at runtime. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { ChatPersistence } from '@tanstack/ai-persistence' +// The four store factories and the schema string, each from your own module. +import { createInterruptStore } from './interrupt-store' +import { createMessageStore } from './message-store' +import { createMetadataStore } from './metadata-store' +import { createRunStore } from './run-store' +import { SCHEMA_SQL } from './schema' + +export function sqlitePersistence(options: { + url: string + migrate?: boolean +}): ChatPersistence { + const db = new DatabaseSync(options.url) + if (options.migrate) db.exec(SCHEMA_SQL) + return defineAIPersistence({ + stores: { + messages: createMessageStore(db), + runs: createRunStore(db), + interrupts: createInterruptStore(db), + metadata: createMetadataStore(db), + }, + }) +} +``` + +That is a complete backend. If you also need a mutex across workers, add +`withLocks` alongside it; see [Locks](../advanced/locks). + +Wire it into `chat()` exactly like any other persistence: + +```ts +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) +} +``` + +## Where to go next + +- [Build a generation adapter](./build-your-own-generation-adapter): generation runs, artifacts, and blobs, for media generation. +- [Build your own adapter](./build-your-own-adapter#verify-with-the-conformance-suite): run the conformance suite against what you just built. +- [Migrations](./migrations): who owns the schema and when to apply changes. diff --git a/docs/persistence/build-your-own-generation-adapter.md b/docs/persistence/build-your-own-generation-adapter.md new file mode 100644 index 000000000..214a1ef1d --- /dev/null +++ b/docs/persistence/build-your-own-generation-adapter.md @@ -0,0 +1,517 @@ +--- +title: Build a Generation Adapter +id: build-your-own-generation-adapter +--- + +# Build a Generation Adapter + +Image, audio, and video runs need their own persistence: a run record so a reload +can find the last generation for a slot, and byte storage so the media itself +comes back. This page builds those three stores against SQLite (Node's built-in +`node:sqlite`). + +Read [Build your own adapter](./build-your-own-adapter) first for the shape of an +adapter and which stores your app needs. Every method signature and invariant is +in the [store reference](./store-reference). + +[Media generation](./generation-persistence) persists differently from chat: it +does not use the chat `runs` store at all. + +- **Required:** a `generationRuns` store, a `GenerationRunStore` keyed by + `runId` (the run/request id a generation mints). It is the counterpart to + `runs`. +- **Optional, to keep the generated bytes:** an `artifacts` store (metadata) and + a `blobs` store (the bytes). These two must be provided **together**. + +`threadId` is the slot the run belongs to, recorded on each run record. + +Three tables, independent of the four a +[chat adapter](./build-your-own-chat-adapter) uses: + +```sql +CREATE TABLE IF NOT EXISTS generation_runs ( + run_id text PRIMARY KEY NOT NULL, + thread_id text NOT NULL, + activity text NOT NULL, + provider text NOT NULL, + model text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + finished_at integer, + error_json text, + result_json text, + artifacts_json text, + usage_json text +); +CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + blob_key text, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + source_url text, + created_at integer NOT NULL +); +CREATE TABLE IF NOT EXISTS blobs ( + key text PRIMARY KEY NOT NULL, + bytes blob NOT NULL, + size integer NOT NULL, + etag text NOT NULL, + content_type text, + custom_metadata_json text, + created_at integer NOT NULL, + updated_at integer NOT NULL +); +``` + +## Generation runs: idempotent create, patch, latest-for-thread + +`GenerationRunStore` is the generation analogue of `RunStore`. Three contracts +to hold: + +- `createOrResume` is idempotent. A second call for a `runId` returns the stored + record unchanged, so resuming a run never resets its `startedAt`, `activity`, + or status. `INSERT ... ON CONFLICT DO NOTHING` gives you that. +- `update` on an unknown `runId` is a no-op. +- `findLatestForThread` returns the run with the greatest `startedAt` linked to a + thread. `reconstructGeneration` calls it to hydrate the last generation for a + thread on a server-driven client's mount. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineGenerationRunStore } from '@tanstack/ai-persistence' +import type { + GenerationRunRecord, + GenerationRunStatus, +} from '@tanstack/ai-persistence' + +function toGenerationRunStatus(value: unknown): GenerationRunStatus { + switch (value) { + case 'running': + case 'completed': + case 'failed': + case 'interrupted': + return value + default: + throw new TypeError(`Unexpected generation run status: ${String(value)}`) + } +} + +// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field +// (String / Number / typeof) and JSON-parse the text columns, with no cast. +function mapGenerationRun(row: Record): GenerationRunRecord { + return { + runId: String(row.run_id), + threadId: String(row.thread_id), + activity: String(row.activity), + provider: String(row.provider), + model: String(row.model), + status: toGenerationRunStatus(row.status), + startedAt: Number(row.started_at), + ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), + ...(typeof row.error_json === 'string' + ? { error: JSON.parse(row.error_json) } + : {}), + ...(typeof row.result_json === 'string' + ? { result: JSON.parse(row.result_json) } + : {}), + ...(typeof row.artifacts_json === 'string' + ? { artifacts: JSON.parse(row.artifacts_json) } + : {}), + ...(typeof row.usage_json === 'string' + ? { usage: JSON.parse(row.usage_json) } + : {}), + } +} + +function createGenerationRunStore(db: DatabaseSync) { + const select = db.prepare('SELECT * FROM generation_runs WHERE run_id = ?') + const insert = db.prepare( + `INSERT INTO generation_runs + (run_id, thread_id, activity, provider, model, status, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const latest = db.prepare( + `SELECT * FROM generation_runs WHERE thread_id = ? + ORDER BY started_at DESC LIMIT 1`, + ) + return defineGenerationRunStore({ + async createOrResume(input) { + const existing = select.get(input.runId) + if (existing) return mapGenerationRun(existing) + const status: GenerationRunStatus = input.status ?? 'running' + insert.run( + input.runId, + input.threadId, + input.activity, + input.provider, + input.model, + status, + input.startedAt, + ) + return { + runId: input.runId, + threadId: input.threadId, + activity: input.activity, + provider: input.provider, + model: input.model, + status, + startedAt: input.startedAt, + } + }, + async update(runId, patch) { + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error_json = ?') + params.push(JSON.stringify(patch.error)) + } + if (patch.result !== undefined) { + sets.push('result_json = ?') + params.push(JSON.stringify(patch.result)) + } + if (patch.artifacts !== undefined) { + sets.push('artifacts_json = ?') + params.push(JSON.stringify(patch.artifacts)) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + // Empty patch, or an unknown run id, touches nothing (UPDATE no-ops). + if (sets.length === 0) return + params.push(runId) + db.prepare( + `UPDATE generation_runs SET ${sets.join(', ')} WHERE run_id = ?`, + ).run(...params) + }, + async get(runId) { + const row = select.get(runId) + return row ? mapGenerationRun(row) : null + }, + // The most recent run linked to a thread. `reconstructGeneration` calls this + // so a server-driven client (`persistence: true`) hydrates the last + // generation for its thread by the stable thread id, without a run id. + async findLatestForThread(threadId) { + const row = latest.get(threadId) + return row ? mapGenerationRun(row) : null + }, + }) +} +``` + +## Artifacts: media metadata + +`ArtifactStore` holds one metadata row per generated file: its `runId`, +`mimeType`, `size`, and a `createdAt`. The bytes live in the blob store below. + +- `save` is an upsert. +- `list(runId)` returns every artifact for a run, `[]` when there are none. +- `delete` / `deleteForRun` are required. Retention and erasure are the point of + storing media durably, and they mirror `BlobStore.delete`. + +Persist `blobKey` verbatim. It records where these bytes actually went, and a +`storageKey` mapper can put them anywhere, so a reader cannot recompute the +path. `resolveArtifactBlobKey(record)` falls back to the default convention +only for rows written before the column existed. Drop it and every artifact +stored under a custom key becomes unreadable. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineArtifactStore } from '@tanstack/ai-persistence' +import type { ArtifactRecord } from '@tanstack/ai-persistence' + +function mapArtifact(row: Record): ArtifactRecord { + return { + artifactId: String(row.artifact_id), + runId: String(row.run_id), + threadId: String(row.thread_id), + ...(typeof row.blob_key === 'string' ? { blobKey: row.blob_key } : {}), + name: String(row.name), + mimeType: String(row.mime_type), + size: Number(row.size), + ...(typeof row.source_url === 'string' + ? { sourceUrl: row.source_url } + : {}), + createdAt: Number(row.created_at), + } +} + +function createArtifactStore(db: DatabaseSync) { + const upsert = db.prepare( + `INSERT INTO artifacts + (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, thread_id = excluded.thread_id, + blob_key = excluded.blob_key, name = excluded.name, + mime_type = excluded.mime_type, size = excluded.size, + source_url = excluded.source_url, created_at = excluded.created_at`, + ) + const selectOne = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') + const byRun = db.prepare( + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + ) + return defineArtifactStore({ + async save(record) { + upsert.run( + record.artifactId, + record.runId, + record.threadId, + record.blobKey ?? null, + record.name, + record.mimeType, + record.size, + record.sourceUrl ?? null, + record.createdAt, + ) + }, + async get(artifactId) { + const row = selectOne.get(artifactId) + return row ? mapArtifact(row) : null + }, + async list(runId) { + return byRun.all(runId).map(mapArtifact) + }, + async delete(artifactId) { + db.prepare('DELETE FROM artifacts WHERE artifact_id = ?').run(artifactId) + }, + async deleteForRun(runId) { + db.prepare('DELETE FROM artifacts WHERE run_id = ?').run(runId) + }, + }) +} +``` + +## Blobs: the bytes + +`BlobStore` is a small object store. `withGenerationPersistence` writes each +generated file under the key `artifacts//`, so a +prefix-filtered `list({ prefix: 'artifacts//' })` enumerates a run's +media. + +- `put` accepts any `BlobBody`: a stream, buffer, string, or `Blob`. The helper + below normalizes it to bytes. +- `list` matches `prefix` literally and pages with a keyset cursor. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineBlobStore } from '@tanstack/ai-persistence' +import type { + BlobBody, + BlobObject, + BlobRecord, +} from '@tanstack/ai-persistence' + +async function toBytes(body: BlobBody): Promise { + if (typeof body === 'string') return new TextEncoder().encode(body) + if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice() + } + if (body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()) + } + // ReadableStream: drain it into one buffer. + const reader = body.getReader() + const chunks: Array = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function mapBlobRecord(row: Record): BlobRecord { + return { + key: String(row.key), + ...(row.size != null ? { size: Number(row.size) } : {}), + ...(typeof row.etag === 'string' ? { etag: row.etag } : {}), + ...(typeof row.content_type === 'string' + ? { contentType: row.content_type } + : {}), + ...(typeof row.custom_metadata_json === 'string' + ? { customMetadata: JSON.parse(row.custom_metadata_json) } + : {}), + ...(row.created_at != null ? { createdAt: Number(row.created_at) } : {}), + ...(row.updated_at != null ? { updatedAt: Number(row.updated_at) } : {}), + } +} + +function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { + return { + ...record, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice()) + controller.close() + }, + }), + arrayBuffer() { + const copy = new ArrayBuffer(bytes.byteLength) + new Uint8Array(copy).set(bytes) + return Promise.resolve(copy) + }, + text: () => Promise.resolve(new TextDecoder().decode(bytes)), + } +} + +function createBlobStore(db: DatabaseSync) { + const upsert = db.prepare( + `INSERT INTO blobs + (key, bytes, size, etag, content_type, custom_metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + bytes = excluded.bytes, size = excluded.size, etag = excluded.etag, + content_type = excluded.content_type, + custom_metadata_json = excluded.custom_metadata_json, + updated_at = excluded.updated_at`, + ) + const selectCreated = db.prepare('SELECT created_at FROM blobs WHERE key = ?') + const selectOne = db.prepare('SELECT * FROM blobs WHERE key = ?') + return defineBlobStore({ + async put(key, body, options) { + const bytes = await toBytes(body) + const now = Date.now() + const prior = selectCreated.get(key) + const createdAt = + prior && prior.created_at != null ? Number(prior.created_at) : now + const etag = String(now) + upsert.run( + key, + bytes, + bytes.byteLength, + etag, + options?.contentType ?? null, + options?.customMetadata ? JSON.stringify(options.customMetadata) : null, + createdAt, + now, + ) + return { + key, + size: bytes.byteLength, + etag, + createdAt, + updatedAt: now, + ...(options?.contentType !== undefined + ? { contentType: options.contentType } + : {}), + ...(options?.customMetadata !== undefined + ? { customMetadata: options.customMetadata } + : {}), + } + }, + async get(key) { + const row = selectOne.get(key) + if (!row) return null + const bytes = + row.bytes instanceof Uint8Array ? row.bytes : new Uint8Array() + return blobObject(mapBlobRecord(row), bytes) + }, + async head(key) { + const row = selectOne.get(key) + return row ? mapBlobRecord(row) : null + }, + async delete(key) { + db.prepare('DELETE FROM blobs WHERE key = ?').run(key) + }, + async list(options) { + if (options?.limit === 0) return { objects: [], truncated: false } + // Match the prefix with `substr(...) = ?` rather than LIKE: SQLite's LIKE + // is case-INsensitive for ASCII and treats `%`/`_` as wildcards, while the + // contract says a prefix matches literally and case-sensitively. Then page + // with a keyset cursor (keys strictly greater than the last one returned). + const prefix = options?.prefix ?? '' + const params: Array = [prefix, prefix] + let where = 'substr(key, 1, length(?)) = ?' + if (options?.cursor !== undefined) { + where += ' AND key > ?' + params.push(options.cursor) + } + let sql = `SELECT * FROM blobs WHERE ${where} ORDER BY key ASC` + const limit = options?.limit + if (limit !== undefined) { + sql += ' LIMIT ?' // fetch one extra row to detect truncation + params.push(limit + 1) + } + const rows = db + .prepare(sql) + .all(...params) + .map(mapBlobRecord) + if (limit !== undefined && rows.length > limit) { + const page = rows.slice(0, limit) + const cursor = page.at(-1)?.key + return { + objects: page, + truncated: true, + ...(cursor !== undefined ? { cursor } : {}), + } + } + return { objects: rows, truncated: false } + }, + }) +} +``` + +## Assemble a generation adapter + +Hand the three stores to `defineAIPersistence` the same way. `generationRuns` alone is a +valid generation adapter (run records, no byte storage); add `artifacts` + +`blobs` (together) to keep the media: + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineAIPersistence } from '@tanstack/ai-persistence' +// The three generation store factories and the schema string, from your modules. +import { createArtifactStore } from './artifact-store' +import { createBlobStore } from './blob-store' +import { createGenerationRunStore } from './generation-run-store' +import { GENERATION_SCHEMA_SQL } from './generation-schema' + +export function generationPersistence(options: { + url: string + migrate?: boolean +}) { + const db = new DatabaseSync(options.url) + if (options.migrate) db.exec(GENERATION_SCHEMA_SQL) + return defineAIPersistence({ + stores: { + generationRuns: createGenerationRunStore(db), + artifacts: createArtifactStore(db), + blobs: createBlobStore(db), + }, + }) +} +``` + +Pass the result to `withGenerationPersistence` on a `generateImage` / +`generateVideo` / … call; see [Generation persistence](./generation-persistence). +You can also fold these stores into an existing chat adapter with +`composePersistence`, so one backend serves both `withPersistence` and +`withGenerationPersistence`. + +## Where to go next + +- [Generation persistence](./generation-persistence): wire this adapter into a generation route. +- [Keep generated files](./keep-generated-files): serve the stored bytes back. +- [Build your own adapter](./build-your-own-adapter#verify-with-the-conformance-suite): run the conformance suite against what you just built. diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index c8d6f3025..bda5d13f2 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -17,7 +17,7 @@ npx @tanstack/intent@latest install ``` The second command wires this package's [Agent Skills](../getting-started/agent-skills) -into your coding assistant. Run it before you start — the recipes read your +into your coding assistant. Run it before you start, because the recipes read your existing database setup and write the adapter to match, and they encode the invariants (full-overwrite `saveThread`, insert-if-absent run and interrupt creates) that are easy to get wrong and expensive to debug. @@ -71,7 +71,7 @@ schema changes through your deployment workflow instead. See ## Threads, runs, and turns -Threads and runs are protocol concepts, not persistence ones — a **thread** +Threads and runs are protocol concepts, not persistence ones. A **thread** (`threadId`) is the stable conversation, a **run** (`runId`) one `RUN_STARTED` → `RUN_FINISHED` execution, and one user-visible turn can span several runs. [Threads and runs](../chat/streaming#threads-and-runs) @@ -79,12 +79,12 @@ in the streaming guide covers the anatomy. What persistence adds is the durable record of them, anchored on the thread: - The transcript is stored per `threadId` (the `messages` store). -- Each run gets a `runs` record with status, timings, and usage — the id is +- Each run gets a `runs` record with status, timings, and usage. The id is ephemeral, the record is not. - A reconnecting client (a reload, or the same thread on another device) never has to present a run id it may no longer know: the store resolves the thread's live run (`findActiveRun(threadId)`) and the client tails that. -- Interrupt records carry both ids — the `runId` of the execution they paused +- Interrupt records carry both ids: the `runId` of the execution they paused and the `threadId` of the conversation they live in. [Id map](./id-map) is the practical companion to this: how to choose a thread @@ -108,9 +108,9 @@ id, why both client and server must file under the same one, when to read | Moment | What is written | Best-effort? | | --- | --- | --- | -| **Start of a run** (`onStart`) | Pending turn (just-submitted user message + prior history) so a reload mid-generation still shows the question | Yes — failure does not abort the run; finish is authoritative | -| **Interrupt boundary** | New interrupt records, run status `interrupted`, and a thread snapshot of current messages | No — store failures propagate | -| **Finish** (`onFinish`) | Complete transcript (including the terminal assistant reply with its stream `messageId` for in-place reload identity), run status `completed`, and commit of consumed resumes | No — transcript is saved **before** the run is marked completed | +| **Start of a run** (`onStart`) | Pending turn (just-submitted user message + prior history) so a reload mid-generation still shows the question | Yes. Failure does not abort the run; finish is authoritative | +| **Interrupt boundary** | New interrupt records, run status `interrupted`, and a thread snapshot of current messages | No. Store failures propagate | +| **Finish** (`onFinish`) | Complete transcript (including the terminal assistant reply with its stream `messageId` for in-place reload identity), run status `completed`, and commit of consumed resumes | No. The transcript is saved **before** the run is marked completed | | **Optionally while streaming** | Throttled partial assistant text when `snapshotStreaming: true` | Yes | ```ts group=chat-persistence @@ -132,14 +132,14 @@ Resumes accepted in `onConfig` are **not** consumed until a success boundary (an interrupt or a finish), so a failed run leaves pending interrupts retryable with the same resume batch. -Every run record moves through this lifecycle — all three end states are +Every run record moves through this lifecycle. All three end states are terminal for that record, because a continuation after an interrupt is a new run with a fresh `runId`: ```mermaid stateDiagram-v2 [*] --> running : run starts (idempotent createOrResume) - running --> completed : finish — transcript saved first + running --> completed : finish, transcript saved first running --> failed : error running --> interrupted : interrupt boundary, or abort completed --> [*] @@ -163,12 +163,12 @@ needs client message history the persistence flow deliberately omits). Resumes are committed (resolved/cancelled in the store) only once the run reaches a successful interrupt or finish boundary. -An interrupt record is born `pending` and only a commit moves it — which is why +An interrupt record is born `pending` and only a commit moves it, which is why a failed continuation leaves it answerable again: ```mermaid stateDiagram-v2 - [*] --> pending : run pauses — interrupt recorded + [*] --> pending : run pauses, interrupt recorded pending --> resolved : resume answers it, committed at a success boundary pending --> cancelled : resume cancels it resolved --> [*] @@ -180,8 +180,8 @@ stateDiagram-v2 - Bring durability to the browser too, so a full page reload restores the conversation and rejoins an in-flight run: [Client persistence](./client-persistence). - Build the backend on the core, and look up the store contracts: - [Build your own adapter](./build-your-own-adapter). Whatever you already run — - Drizzle, Prisma, Cloudflare D1, raw SQL — install the shipped + [Build your own adapter](./build-your-own-adapter). Whatever you already run + (Drizzle, Prisma, Cloudflare D1, raw SQL), install the shipped [Agent Skills](../getting-started/agent-skills) with `npx @tanstack/intent@latest install` and have your assistant write the `chat-persistence.ts` against your existing schema. diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md index 5caae8a15..2169cdc05 100644 --- a/docs/persistence/client-persistence.md +++ b/docs/persistence/client-persistence.md @@ -107,7 +107,7 @@ stable key and the server resolves everything from it. No loader, no `initialMessages`, no extra props. It needs a connection with a `hydrate` handler (every built-in connection has one) and the server `GET` endpoint below. -**Client** — a connection, a stable `threadId`, and `persistence: true`: +**Client**: a connection, a stable `threadId`, and `persistence: true`: ```tsx import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' @@ -133,7 +133,7 @@ function Chat({ threadId }: { threadId: string }) { } ``` -**Server** — one `GET` endpoint next to your chat `POST`. Replay the durability +**Server**: one `GET` endpoint next to your chat `POST`. Replay the durability log when the request carries a resume cursor, otherwise return the stored conversation with `reconstructChat`: @@ -190,8 +190,8 @@ example during server-side rendering), so constructing one on the server is safe ### Writing your own Any object with `getItem` / `setItem` / `removeItem` works. The record is one -`{ messages, resume? }` blob per chat id — the transcript plus the pointer that -lets a reload rejoin an in-flight run — so `setItem` receives that whole record, +`{ messages, resume? }` blob per chat id (the transcript plus the pointer that +lets a reload rejoin an in-flight run), so `setItem` receives that whole record, not a bare message array: ```ts @@ -228,7 +228,7 @@ const persistence: ChatClientPersistence = { ``` Reads are best-effort: a `getItem` that throws or returns `null` is treated as -"nothing stored", so an adapter that parses the wrong shape fails **silently** — +"nothing stored", so an adapter that parses the wrong shape fails **silently**: the conversation just does not come back. Round-trip your adapter once against a real reload before shipping it. diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index 5c70734f4..e74d9dff7 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -19,7 +19,7 @@ Need a mutex across instances? See [Locks](#locks-coordination) below. | `ChatPersistenceStores` / `ChatPersistence` | `messages` + `runs` + `interrupts` + `metadata` | Packaged backends (`memoryPersistence`, Drizzle, Prisma, D1) | | `ChatWithInterruptsStores` / `ChatWithInterruptsPersistence` | `messages` + `runs` + `interrupts` | HITL without requiring metadata | -There is no public sparse `AIPersistenceStores` export — use a named shape or +There is no public sparse `AIPersistenceStores` export, so use a named shape or `AIPersistence<{ messages: MessageStore, … }>` for custom maps. `defineAIPersistence` / `composePersistence` still accept sparse maps by inference. @@ -94,7 +94,7 @@ values arrive from untyped JavaScript. To define a partial backend directly rather than by composing, use `defineAIPersistence({ stores: { ... } })` and pass only the stores you have. See the -[store interface reference](./build-your-own-adapter#store-interface-reference) +[store reference](./store-reference) for the store contracts. ## Locks (coordination) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index fc441d19b..43aef366d 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -40,7 +40,7 @@ The record lives on the server, written by `withGenerationPersistence`, which needs a `generationRuns` store (a `GenerationRunStore` keyed by the run's own `runId`, with the `threadId` recorded as the slot the run belongs to). `memoryPersistence()` ships one out of the box; see -[Build your own adapter](./build-your-own-adapter#generation--media-stores) for +[Build a generation adapter](./build-your-own-generation-adapter) for your own backend. The browser caches nothing, so a generation's history is never duplicated into @@ -50,7 +50,7 @@ The record never holds the generated bytes, so on its own a reload restores `status` and `error` while `result` stays `null`. To bring the media back too, add server byte storage: see [Keep generated files](./keep-generated-files). -The record's lifecycle is small — one status field the middleware advances: +The record's lifecycle is small, one status field the middleware advances: ```mermaid stateDiagram-v2 @@ -63,7 +63,7 @@ stateDiagram-v2 interrupted --> [*] ``` -A restored `interrupted` run surfaces to the hook as an error — an aborted +A restored `interrupted` run surfaces to the hook as an error. An aborted generation cannot be resumed, only re-run. ## Wire the route @@ -109,7 +109,7 @@ export async function POST(request: Request) { threadId, stream: true, // `artifactUrl` makes the restored media render from your own origin. It is - // optional — see Keep generated files for the serve route it points at. + // optional; see Keep generated files for the serve route it points at. middleware: [ withGenerationPersistence(persistence, { artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, @@ -198,13 +198,13 @@ sequenceDiagram Note over Hook: mount (or reload) with a threadId Hook->>Route: ?threadId=… - Route->>Runs: reconstructGeneration — latest run for the thread + Route->>Runs: reconstructGeneration, latest run for the thread Runs-->>Route: run record (status, result metadata, artifact refs) Route-->>Hook: status / error / result repainted alt run still generating Hook->>Route: ?runId=…&offset=-1 Route->>Log: resumeServerSentEventsResponse - Log-->>Hook: replay + live tail — run finishes in place + Log-->>Hook: replay + live tail, run finishes in place end ``` @@ -213,14 +213,14 @@ sequenceDiagram The HTTP adapters above implement hydration and rejoin for you. With [TanStack Start](https://tanstack.com/start) server functions (or any direct, in-process call) there is no `GET` route to hang them on, so you supply the -two handlers yourself — one for mount-time hydration, one for replaying an -in-flight run — and pass them as options alongside the `fetcher` (or to +two handlers yourself (one for mount-time hydration, one for replaying an +in-flight run) and pass them as options alongside the `fetcher` (or to `stream()` / `rpcStream()`). Three server functions cover it: one runs the generation, one answers hydration with `getGenerationHydration`, and one replays the run's durability -log with `replayRunStream`. Both streaming functions return the same thing — -an SSE `Response` from `toServerSentEventsResponse` — so the client decodes +log with `replayRunStream`. Both streaming functions return the same thing, +an SSE `Response` from `toServerSentEventsResponse`, so the client decodes them the same way: ```ts group=generation-server-functions @@ -258,7 +258,7 @@ export const generateImageFn = createServerFn({ method: 'POST' }) threadId, runId, stream: true, - // `artifactUrl` is optional — see Keep generated files. + // `artifactUrl` is optional; see Keep generated files. middleware: [ withGenerationPersistence(persistence, { artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, @@ -298,7 +298,7 @@ const hydrationSchema = z.object({ export const getImageHydrationFn = createServerFn({ method: 'GET' }) .inputValidator(z.string().min(1)) .handler(async ({ data: threadId }) => { - // `getGenerationHydration` does no auth — gate on your session here, the + // `getGenerationHydration` does no auth, so gate on your session here, the // way you would pass `authorize` to `reconstructGeneration`. return hydrationSchema.parse( await getGenerationHydration(persistence, threadId), @@ -318,7 +318,7 @@ On the client, pass the two handlers next to the `fetcher`. A reload now hydrates the last run through `getImageHydrationFn`, and a run still generating is tailed to completion through `joinImageRunFn`. `joinRun` yields `StreamChunk`s rather than a `Response`, so decode the SSE body yourself and -apply the `signal` there — the server function itself takes only its `data`: +apply the `signal` there. The server function itself takes only its `data`: ```tsx import { useGenerateImage } from '@tanstack/ai-react' @@ -379,15 +379,15 @@ export function HeroImageGenerator({ threadId }: { threadId: string }) { A non-streaming `fetcher` (a plain `Promise` rather than an SSE `Response`) has no in-flight stream to rejoin, so it needs only -`hydrateGeneration` — drop `joinRun` and the decoder with it. +`hydrateGeneration`. Drop `joinRun` and the decoder with it. A restored run that was still generating but has **no** `joinRun` handler to -tail it surfaces as an interrupted error — it cannot be resumed, only re-run — +tail it surfaces as an interrupted error (it cannot be resumed, only re-run) instead of hanging on `generating` forever. -The same handlers fit the lightweight connection adapters directly — +The same handlers fit the lightweight connection adapters directly, `stream(factory, { hydrateGeneration, joinRun })` and -`rpcStream(call, { hydrateGeneration, joinRun })` — for in-process or RPC +`rpcStream(call, { hydrateGeneration, joinRun })`, for in-process or RPC transports; they also accept a chat `hydrate` handler for `useChat`'s server-driven persistence. @@ -425,7 +425,7 @@ const adapter = openaiVideo('sora-2') const middleware = [withGenerationPersistence(persistence)] const threadId = 'product-7-launch-clip' -// Opens the run. Status `running` — there is no video yet. +// Opens the run. Status `running`, and there is no video yet. const { jobId } = await generateVideo({ adapter, prompt: 'A cat chasing a dog in a sunny park', @@ -438,7 +438,7 @@ const status = await getVideoJobStatus({ adapter, jobId, threadId, middleware }) ``` Pass the same `threadId` and `middleware` to both. **The `jobId` is the whole -correlation** — the run id is derived from it, so the poll finds the run the +correlation**: the run id is derived from it, so the poll finds the run the submit opened without you storing anything, even from a different request or process. There is no run id to thread. diff --git a/docs/persistence/id-map.md b/docs/persistence/id-map.md index 36c42c43e..eebfaf325 100644 --- a/docs/persistence/id-map.md +++ b/docs/persistence/id-map.md @@ -202,7 +202,7 @@ and [Resumable streams](../resumable-streams/overview) for the log itself. Persistence only works when the client and the server file under the same string. On the client that is the hook's `threadId`. On the server it is the activity's -`threadId` — for generation the middleware reads it straight off the activity, +`threadId`. For generation the middleware reads it straight off the activity, so there is nothing to repeat on `withGenerationPersistence`: ```ts @@ -257,7 +257,7 @@ works, it just cannot be found again, which is fine for a one-shot image you sho and forget. Turn `persistence` on and `threadId` becomes required, on the hook and on the -activity the middleware wraps — `withGenerationPersistence` throws when neither +activity the middleware wraps. `withGenerationPersistence` throws when neither the activity nor its own `threadId` **override** supplies one. An app that cannot name the slot has nothing to restore into. diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md index 7c7d6a2f1..429474065 100644 --- a/docs/persistence/internals.md +++ b/docs/persistence/internals.md @@ -45,9 +45,73 @@ unchanged; persistence does not create a second event stream. When a request carries a non-empty `messages` array it is treated as the full authoritative history and, on finish, overwrites the stored thread. To continue -a stored thread without resending history, pass an empty `messages` array — the +a stored thread without resending history, pass an empty `messages` array, and the stored transcript is loaded and used. +## Reading the stores from your own middleware + +Your own middleware often needs the same stores `withPersistence` is already +holding: an audit step that writes to `metadata`, a guard that checks pending +interrupts. Passing the persistence object in twice works but drifts, because +the middleware and your code can end up with different instances. + +`withPersistence` publishes what it holds as capabilities instead. Declare what +you need in `requires`, then read it off the context: + +```ts +import { chat, defineChatMiddleware, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + InterruptsCapability, + PersistenceCapability, + getInterrupts, + getPersistence, + memoryPersistence, + withPersistence, +} from '@tanstack/ai-persistence' +import type { ChatMiddlewareContext } from '@tanstack/ai' + +const persistence = memoryPersistence() + +const auditPending = defineChatMiddleware({ + name: 'audit-pending', + // Fails fast at setup when the capability was never provided. + requires: [PersistenceCapability, InterruptsCapability], + async setup(ctx: ChatMiddlewareContext) { + const stores = getPersistence(ctx).stores + const interrupts = getInterrupts(ctx) + const pending = await interrupts.listPending(ctx.threadId) + await stores.metadata?.set(ctx.threadId, 'pending-count', { + count: pending.length, + }) + }, +}) + +export async function POST(request: Request) { + const { messages, threadId } = await request.json() + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + threadId, + // Order matters: the provider runs before the consumer. + middleware: [withPersistence(persistence), auditPending], + }) + return toServerSentEventsResponse(stream) +} +``` + +Two capabilities are published: + +- `PersistenceCapability`, read with `getPersistence(ctx)`: the whole + `AIPersistence` object, so any store it exposes is reachable. +- `InterruptsCapability`, read with `getInterrupts(ctx)`: the `interrupts` store + alone, published only when persistence actually has one. + +`providePersistence` and `provideInterrupts` are the write halves, for a +middleware of your own that supplies the stores instead of `withPersistence`. +Locks are not part of this: they are a separate capability from +[`@tanstack/ai/locks`](../advanced/locks). + ## Generation middleware lifecycle `withGenerationPersistence(persistence)` records the job across @@ -68,8 +132,8 @@ job's primary identity. `threadId` is nonetheless **required**: it is the slot the run is filed under, and `GenerationRunRecord.threadId` is a required field. The middleware resolves -it as `opts.threadId ?? ctx.threadId` — normally the `threadId` the caller -passed the activity, with the option as an override — and **throws** when +it as `opts.threadId ?? ctx.threadId` (normally the `threadId` the caller +passed the activity, with the option as an override) and **throws** when neither supplies one. It is never faked from the request id: a run filed under an invented scope can never be hydrated by one, so restoring would silently return nothing forever. @@ -119,8 +183,8 @@ An adapter owns its own resources: connection lifecycle, when migrations run, an how each store record maps to rows. The middleware only calls the store methods; it never opens a connection or inspects a table. A backend may provide any subset of the stores (for example, no `metadata`), and the return type reflects exactly the -stores it exposes. [Build your own adapter](./build-your-own-adapter) shows this -end to end for SQLite. +stores it exposes. [Build a chat adapter](./build-your-own-chat-adapter) shows +this end to end for SQLite. `composePersistence` does not add distributed transactions. When related stores use different systems, adapter authors must define retry, diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md index 190311128..a497086c4 100644 --- a/docs/persistence/keep-generated-files.md +++ b/docs/persistence/keep-generated-files.md @@ -6,7 +6,7 @@ id: keep-generated-files # Keep Generated Files Provider URLs for generated media expire. A Sora clip, a batch of images, a long -audio track — the model hands you a URL that stops working after a while, and +audio track: the model hands you a URL that stops working after a while, and once it does the output is gone. To keep the output, save the generated bytes to your own storage and serve them from your own origin, where they outlive the provider's link. @@ -28,7 +28,7 @@ What each choice gets you: `memoryPersistence()` ships all three stores (`generationRuns`, `artifacts`, `blobs`), so it works out of the box. Any backend that implements `ArtifactStore` and `BlobStore` (see -[Build your own adapter](./build-your-own-adapter#generation--media-stores)) +[Build a generation adapter](./build-your-own-generation-adapter)) works the same way. ## Serve the stored bytes @@ -39,7 +39,7 @@ add a `GET` route that reads the artifact back with the `retrieveArtifact` / `retrieveBlob` helpers and streams it from your own origin: ```ts group=generation-bytes -// routes/api.generate.image.ts — runs the generation. +// routes/api.generate.image.ts, runs the generation. import { generateImage, generationParamsFromRequest, @@ -93,19 +93,19 @@ export async function POST(request: Request) { The serve route is a **separate** route from the generation endpoint. A `GET` on the generation route is already spoken for by -[Generation persistence](./generation-persistence) — that is where a reloading -client hydrates and where an in-flight run resumes — so the bytes get their own +[Generation persistence](./generation-persistence), which is where a reloading +client hydrates and where an in-flight run resumes, so the bytes get their own path, the one `artifactUrl` stamps above: ```ts group=generation-bytes -// routes/api.generate.image.artifact.ts — serves stored bytes by id. +// routes/api.generate.image.artifact.ts, serves stored bytes by id. // // This is a plain file endpoint: it serves one stored file, it does not resume // a run or rebuild a conversation. // // Security: the id comes from the caller, so this route MUST authorize before // it serves. `ArtifactRecord` carries the `threadId` / `runId` the file was -// generated under — check that against an identity you derive server-side from +// generated under. Check that against an identity you derive server-side from // the session, never from the query string. Without this check, any caller who // learns or guesses an artifact id can read another user's media. export async function GET(request: Request) { @@ -120,7 +120,7 @@ export async function GET(request: Request) { // const owned = user != null && (await db.threadOwnedBy(user.id, artifact.threadId)) const owned = true void request - // 404, not 403 — a distinguishable "exists but forbidden" confirms valid ids. + // 404, not 403. A distinguishable "exists but forbidden" confirms valid ids. if (!owned) return new Response('not found', { status: 404 }) const blob = await retrieveBlob(persistence, artifact) @@ -145,7 +145,7 @@ file) options. By default an artifact's bytes are written under `artifacts//`. Pass `storageKey` to put them in your own -folder structure instead — useful when the bucket is shared with the rest of +folder structure instead, which helps when the bucket is shared with the rest of your app, or when you want media grouped by the thing it belongs to rather than by the run that produced it: @@ -162,7 +162,7 @@ Two things worth knowing: can no longer be recomputed from the record, so it is stored as `ArtifactRecord.blobKey` and reads resolve through it. Records written before this existed fall back to the default convention, so adding `storageKey` to an -app with existing artifacts does not orphan them — but it does mean the default +app with existing artifacts does not orphan them. It does mean the default convention can never be changed retroactively. **Returning a non-unique key overwrites.** Include `artifactId`, or something @@ -174,7 +174,7 @@ path-traversal and cross-tenant-write vector. ## Prompt media referenced by URL What gets stored is the **generated output**. When a provider returns an -expiring link, the middleware downloads it and keeps the bytes — that is the +expiring link, the middleware downloads it and keeps the bytes, which is the whole point of this page. Prompt media is different, and it splits by how you sent it: @@ -190,8 +190,8 @@ endpoints, `localhost` admin services) and then read the response back through the artifact `GET` route. The copy is also redundant: whoever supplied the URL already had the media. -If you do need a durable copy of caller-supplied media — a "paste an image URL" -input box, say — opt in with `allowInputUrl`, which is a predicate rather than a +If you do need a durable copy of caller-supplied media (a "paste an image URL" +input box, say), opt in with `allowInputUrl`, which is a predicate rather than a flag precisely so the check is not optional: ```ts group=generation-bytes @@ -232,7 +232,7 @@ link. Those durable refs ride along on `result.artifacts`, and they are what a reload restores from. In [Generation persistence](./generation-persistence), the generation hook rebuilds `result` from the persisted refs on mount, resolving -each media field to its durable `ref.url` — so the restored result renders the +each media field to its durable `ref.url`, so the restored result renders the same media the live run showed. `result.artifacts` is the whole artifact surface on the hook: there are no separate top-level artifact fields to read, live or restored. @@ -242,5 +242,5 @@ restored. - [Generation persistence](./generation-persistence): the run record that survives a reload or a dropped connection, and the `generationRuns` store that byte storage builds on. -- [Build your own adapter](./build-your-own-adapter#generation--media-stores): a +- [Build a generation adapter](./build-your-own-generation-adapter): a custom `ArtifactStore` / `BlobStore` on your own database. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index 8979134d8..437c7e627 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -24,7 +24,7 @@ TanStack AI solves these with two independent layers. You can use either alone o ## Install -The server half lives in one package. The client half needs no extra install — +The server half lives in one package. The client half needs no extra install: it ships with the framework package you already use (`@tanstack/ai-react`, `-vue`, `-solid`, `-svelte`, `-angular`, or `@tanstack/ai-client`). @@ -39,7 +39,7 @@ your coding assistant, before you write any of it: npx @tanstack/intent@latest install ``` -Run that after the package is installed, not before — Intent scans +Run that after the package is installed, not before. Intent scans `node_modules`, so anything added later needs another run. ## The two layers @@ -52,14 +52,14 @@ Run that after the package is installed, not before — Intent scans They share no code and solve different problems. Delivery durability replays a live byte stream so a dropped connection resumes exactly where it stopped. State persistence stores the conversation itself, so it survives a reload or exists on another device. A replayable stream is not a saved conversation, and a saved conversation is not a live stream. Real apps usually want both. The two layers also key on different ids. A **thread** (`threadId`) is the -conversation — the stable identity that survives reloads and exists on every +conversation, the stable identity that survives reloads and exists on every device. A **run** (`runId`) is one execution inside it: one streamed answer, minted fresh each time. A thread accumulates many runs over its life; delivery durability logs one run, state persistence stores the whole thread: ```mermaid flowchart TB - subgraph thread ["One thread — threadId (stable, the conversation)"] + subgraph thread ["One thread, threadId (stable, the conversation)"] direction LR run1["run r1 completed"] --> run2["run r2 @@ -67,20 +67,20 @@ completed"] --> run3["run r3 running"] end - subgraph delivery ["Delivery durability — one byte log per run"] + subgraph delivery ["Delivery durability, one byte log per run"] log["log for r3 replays the live stream to a reconnecting client"] end - subgraph state ["State persistence — durable store per thread"] - store["transcript · run records · interrupts"] + subgraph state ["State persistence, durable store per thread"] + store["transcript, run records, interrupts"] end run3 -. "a dropped connection tails" .-> log thread -- "saved on finish, loaded on mount" --> store ``` -Run ids are too ephemeral to reconnect by — a reloading client may not know the +Run ids are too ephemeral to reconnect by, since a reloading client may not know the current one. Reconnection therefore resolves from the stable `threadId`: the store answers "does this thread have a live run?" (`findActiveRun`), and only then does the client tail that run's log. @@ -104,7 +104,7 @@ Persistence runs on the client, the server, or both. They are independent, and t ### Identity: `Scope` and `threadId` -Server persistence keys conversation history on **`threadId`** — the same +Server persistence keys conversation history on **`threadId`**, the same conversation key as `ChatMiddlewareContext.threadId` and the required field of the shared `Scope` type from `@tanstack/ai`. Store APIs take a bare `threadId` string for adapter simplicity; multi-user isolation is still required: @@ -112,8 +112,8 @@ string for adapter simplicity; multi-user isolation is still required: - Derive `Scope.userId` / `Scope.tenantId` **server-side** from session state. - Authorize before `loadThread` / `saveThread` / `reconstructChat` (use `reconstructChat({ authorize })`). -- Never treat a client-supplied thread id alone as ownership — thread ids are - guessable. +- Never treat a client-supplied thread id alone as ownership, because thread ids + are guessable. `Scope` is re-exported from `@tanstack/ai-persistence` so apps can import the identity type next to the store contracts. @@ -150,10 +150,10 @@ export async function POST(request: Request) { The client half is one option on `useChat`, `persistence`, and it takes two forms: -- **`persistence: true`** — server-authoritative. The client caches nothing and +- **`persistence: true`**: server-authoritative. The client caches nothing and hydrates the thread from the server by its `threadId` on mount. Pair this with the server `withPersistence` above; it is the setup [we recommend](#what-we-recommend). -- **`persistence: `** — client-authoritative. A storage adapter +- **`persistence: `**: client-authoritative. A storage adapter (`localStoragePersistence()` / `sessionStoragePersistence()` / `indexedDBPersistence()`) keeps the transcript in the browser, no server needed. @@ -260,7 +260,7 @@ export function GET(request: Request): Response | Promise { return resumeServerSentEventsResponse({ adapter: durability }) } // Otherwise rehydrate the conversation from the durable store. `reconstructChat` - // reads `?threadId` and returns `{ messages, activeRun }` — the transcript plus + // reads `?threadId` and returns `{ messages, activeRun }`: the transcript plus // a cursor to any run still generating. // // Security: without `authorize`, any caller who knows a thread id receives the @@ -316,7 +316,7 @@ sequenceDiagram Note over Hook: page reloads while a run is streaming Hook->>Route: ?threadId=support-chat - Route->>Store: reconstructChat — loadThread + findActiveRun + Route->>Store: reconstructChat, loadThread + findActiveRun Store-->>Route: messages + activeRun (runId) Route-->>Hook: transcript + activeRun cursor Note over Hook: transcript paints @@ -355,7 +355,7 @@ lifecycle and the server wiring, and ## The store contract Server **state** persistence is a set of stores. Middleware activates behavior -from whichever stores are present (with entrypoint requirements — see +from whichever stores are present (with entrypoint requirements, see [Controls](./controls)). There is no separate enable list. | Store | Purpose | @@ -369,7 +369,7 @@ from whichever stores are present (with entrypoint requirements — see | `blobs` | The generated bytes (needs `artifacts`). | The last three are the generation counterpart to the chat stores, used by -`withGenerationPersistence` rather than `withPersistence` — see +`withGenerationPersistence` rather than `withPersistence`, see [Generation persistence](./generation-persistence). Named shapes, covered in [Controls](./controls): @@ -382,7 +382,7 @@ Need a mutex across instances (cross-worker coordination)? Use `withLocks` and a `LockStore` from `@tanstack/ai/locks`; see [Locks](../advanced/locks). `@tanstack/ai-persistence` ships the contracts, the middleware, an in-memory -reference backend, and a conformance testkit — not a backend for your database. +reference backend, and a conformance testkit, but not a backend for your database. You implement the stores against whatever you already run; [Build your own adapter](./build-your-own-adapter) walks through a complete one. @@ -399,6 +399,9 @@ matching your database loads itself. The full skill list is in - [Generation persistence](./generation-persistence): the same modes for media runs (image, audio, TTS, video, transcription), backed by a `generationRuns` store. - [Keep generated files](./keep-generated-files): save the generated bytes to your own storage so they outlive the provider's expiring URLs. - [Controls](./controls): compose backends per store and choose which stores to run. -- [Build your own adapter](./build-your-own-adapter): a complete SQLite example on the core, plus the store interface reference. +- [Build your own adapter](./build-your-own-adapter): the shape of an adapter, which stores you need, and how to verify one. +- [Build a chat adapter](./build-your-own-chat-adapter): a complete SQLite walkthrough for the four chat stores. +- [Build a generation adapter](./build-your-own-generation-adapter): the same for generation runs, artifacts, and blobs. +- [Store reference](./store-reference): every store method signature and invariant. - [Resumable streams](../resumable-streams/overview): the delivery-durability layer in full. - [Internals](./internals): the middleware lifecycle and composition mechanics behind every backend. diff --git a/docs/persistence/store-reference.md b/docs/persistence/store-reference.md new file mode 100644 index 000000000..95a2bbbc8 --- /dev/null +++ b/docs/persistence/store-reference.md @@ -0,0 +1,286 @@ +--- +title: Store Reference +id: store-reference +--- + +# Store Reference + +These are the public contracts from `@tanstack/ai-persistence`. Implement only +the stores you need. + +## MessageStore + +```ts +import type { ModelMessage } from '@tanstack/ai' + +interface MessageStore { + loadThread(threadId: string): Promise> + saveThread(threadId: string, messages: Array): Promise +} +``` + +`saveThread` receives the full authoritative model-message history, not a delta. +`loadThread` returns `[]` (never `null`) for a thread that was never saved. + +## RunStore + +```ts +import type { TokenUsage } from '@tanstack/ai' + +interface RunRecord { + runId: string + threadId: string + status: 'running' | 'completed' | 'failed' | 'interrupted' + startedAt: number // epoch ms + finishedAt?: number // epoch ms, set once the run reaches a terminal status + error?: string + usage?: TokenUsage // token counts, from @tanstack/ai +} + +interface RunStore { + createOrResume(input: { + runId: string + threadId: string + status?: RunRecord['status'] + startedAt: number + }): Promise + update( + runId: string, + patch: Partial< + Pick + >, + ): Promise + get(runId: string): Promise + // The most recent 'running' run for a thread (greatest `startedAt` wins), or + // null when the thread is idle. `reconstructChat` calls it to report + // `activeRun`, which is how a hydrating client tails a run that is still + // generating. + findActiveRun(threadId: string): Promise +} +``` + +Three contracts to hold: + +- `createOrResume` must be idempotent. A second call for an existing `runId` + returns the stored record unchanged, which is what makes resuming a run safe. + Retries may repeat the same run id. +- `update` against an unknown `runId` is a no-op. +- `findActiveRun` must do real work. Stub it to `null` and `reconstructChat` + always reports `activeRun: null`, so a client that reloads (or switches back + to) a still-generating thread restores the transcript but never resumes the + live reply. Nothing detects it either, because `null` is also the right answer + for an idle thread. + +Every method on a store you provide is required. A backend that genuinely has no +run lifecycle should declare `ChatTranscriptStores` and omit `runs` entirely +rather than supply a `RunStore` with a stubbed method: an absent store is caught +by the type system, an incomplete one fails silently at runtime. + +## InterruptStore + +```ts +interface InterruptRecord { + interruptId: string + runId: string + threadId: string + status: 'pending' | 'resolved' | 'cancelled' + requestedAt: number // epoch ms + resolvedAt?: number // epoch ms, set once resolved or cancelled + payload: Record + response?: unknown +} + +interface InterruptStore { + create(record: Omit): Promise + resolve(interruptId: string, response?: unknown): Promise + cancel(interruptId: string): Promise + get(interruptId: string): Promise + list(threadId: string): Promise> + listPending(threadId: string): Promise> + listByRun(runId: string): Promise> + listPendingByRun(runId: string): Promise> +} +``` + +`create` accepts a record without `status`/`resolvedAt` so every interrupt is +born `'pending'`; it is insert-if-absent, so a duplicate `create` never clobbers +an already-resolved interrupt. The `list*` methods return records ordered by +`requestedAt` ascending. An `interrupts` store requires a `runs` store when used +with chat persistence. + +## MetadataStore + +```ts +interface MetadataStore { + get(scope: string, key: string): Promise + set(scope: string, key: string, value: unknown): Promise + delete(scope: string, key: string): Promise +} +``` + +Namespaces and value schemas are application-owned, and `(scope, key)` is the +composite identity. A stored `null` is indistinguishable from absence at the type +level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or +reject nullish values outright the way the SQLite store above does. + +## GenerationRunStore + +The generation counterpart to `RunStore`. Keyed by its own `runId`, with +`threadId` the slot `findLatestForThread` looks runs up by. +`withGenerationPersistence` requires this store, not `runs`. + +Its `status` uses the same vocabulary as a chat run's `RunStatus`, so one status +column and one set of checks cover both tables. + +```ts +import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai' + +// The same vocabulary as a chat run's `RunStatus`. +type GenerationRunStatus = 'running' | 'completed' | 'failed' | 'interrupted' + +interface GenerationRunRecord { + runId: string + threadId: string // the slot this run fills, hydrated by findLatestForThread + activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription' + provider: string + model: string + status: GenerationRunStatus + startedAt: number // epoch ms + finishedAt?: number // epoch ms, set once the run reaches a terminal status + error?: { message: string; code?: string } + result?: unknown // terminal result metadata (ids, urls), never media bytes + artifacts?: Array // present with an artifacts + blobs backend + usage?: TokenUsage +} + +interface GenerationRunStore { + createOrResume(input: { + runId: string + activity: string + provider: string + model: string + startedAt: number + threadId: string + status?: GenerationRunStatus + }): Promise + update( + runId: string, + patch: Partial< + Pick< + GenerationRunRecord, + 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' + > + >, + ): Promise + get(runId: string): Promise + // The most recent run filed under a thread (greatest `startedAt`), or null. + // Required: it is the only query that hydrates a generation, so an adapter + // without it would be indistinguishable from one whose thread has no runs: + // `persistence: true` would silently restore nothing, forever. + findLatestForThread(threadId: string): Promise +} +``` + +Implement `createOrResume` idempotently: a second call for an existing `runId` +returns the stored record unchanged (`startedAt` / `activity` / `provider` / +`model` / `threadId` are not mutated), which is what makes resuming a run safe. +`update` against an unknown `runId` is a no-op. + +## ArtifactStore + +Metadata rows for persisted media. The bytes live in a `BlobStore`; this record +holds the descriptive metadata and an optional `sourceUrl` for reference-only +backends. Provide it together with a `BlobStore` to keep generated bytes. + +```ts +interface ArtifactRecord { + artifactId: string + runId: string + threadId: string + blobKey?: string // where the bytes live; absent on pre-blobKey records + name: string + mimeType: string + size: number + sourceUrl?: string // where the bytes were fetched FROM (provenance) + createdAt: number // epoch ms +} + +interface ArtifactStore { + save(record: ArtifactRecord): Promise + get(artifactId: string): Promise + list(runId: string): Promise> // [] when the run has none + delete(artifactId: string): Promise + deleteForRun(runId: string): Promise +} +``` + +## BlobStore + +A durable object/blob store for the bytes. `withGenerationPersistence` writes +each generated file under the key `artifacts//`. + +```ts +type BlobBody = + | ReadableStream + | ArrayBuffer + | ArrayBufferView + | string + | Blob + +interface BlobRecord { + key: string + size?: number + etag?: string + contentType?: string + customMetadata?: Record + createdAt?: number // epoch ms first written + updatedAt?: number // epoch ms last overwritten +} + +interface BlobObject extends BlobRecord { + arrayBuffer(): Promise + text(): Promise + body?: ReadableStream +} + +interface BlobListPage { + objects: Array + cursor?: string // present only when `truncated` + truncated?: boolean +} + +interface BlobPutOptions { + contentType?: string + customMetadata?: Record +} + +interface BlobListOptions { + prefix?: string + cursor?: string + limit?: number +} + +interface BlobStore { + put(key: string, body: BlobBody, options?: BlobPutOptions): Promise + get(key: string): Promise + head(key: string): Promise + delete(key: string): Promise + list(options?: BlobListOptions): Promise +} +``` + +Three contracts to hold for `list`: + +- `prefix` matches literally and case-sensitively. Escape SQL `LIKE` + metacharacters. +- When `limit` is given and more keys match, return `truncated: true` with a + `cursor`. Passing that cursor back returns the strictly-following keys, so + paging visits every key exactly once. +- `limit: 0` yields an empty, untruncated page. + + +## Where to go next + +- [Build a chat adapter](./build-your-own-chat-adapter): these contracts implemented against SQLite. +- [Build a generation adapter](./build-your-own-generation-adapter): the generation half. +- [Build your own adapter](./build-your-own-adapter#verify-with-the-conformance-suite): check an implementation against the conformance suite. diff --git a/packages/ai-angular/src/index.ts b/packages/ai-angular/src/index.ts index 2232598de..2c8dbef90 100644 --- a/packages/ai-angular/src/index.ts +++ b/packages/ai-angular/src/index.ts @@ -109,9 +109,4 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, - type GenerationResumeSnapshot, - type GenerationResumeState, - type GenerationResumeStatus, - type GenerationPendingArtifact, } from '@tanstack/ai-client' -export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index 358f300d5..d9a5e4d08 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -26,11 +26,7 @@ export interface InjectGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< InjectGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index cf7d6fa31..22a0401b4 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -18,8 +18,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, @@ -58,8 +56,6 @@ export interface InjectGenerateVideoOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -130,9 +126,7 @@ export function injectGenerateVideo( const isLoading = signal(false) const error = signal(undefined) const status = signal('idle') - const runId = signal( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const runId = signal(null) let disposed = false const bodySource = @@ -147,9 +141,6 @@ export function injectGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -200,7 +191,7 @@ export function injectGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposed) videoStatus.set(s) }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs: { runId: string } | null) => { if (!disposed) runId.set(rs?.runId ?? null) }, } diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 348fd7673..d1d7e3efa 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -20,8 +20,6 @@ import type { GenerationFetcher, GenerationPersistenceOptions, GenerationRestoredResult, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' import type { ReactiveOption } from './internal/to-reactive' @@ -64,8 +62,6 @@ export interface InjectGenerationOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -168,9 +164,7 @@ export function injectGeneration< const isLoading = signal(false) const error = signal(undefined) const status = signal('idle') - const runId = signal( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const runId = signal(null) let disposed = false const bodySource = @@ -185,9 +179,6 @@ export function injectGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -228,7 +219,7 @@ export function injectGeneration< onStatusChange: (s: GenerationClientState) => { if (!disposed) status.set(s) }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs) => { if (!disposed) runId.set(rs?.runId ?? null) }, } diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 954844514..d18dcf11c 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -12,7 +12,6 @@ import { injectGenerateSpeech } from '../src/inject-generate-speech' import type { PersistedArtifactRef, StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter, - GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -95,12 +94,10 @@ function renderInjectGenerateSpeech(options: any) { } } -const videoResumeSnapshot: GenerationResumeSnapshot = { - resumeState: { - threadId: 'thread-resume', - runId: 'run-resume', - }, - status: 'running', +const videoResumeSnapshot = { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, } // Hydration and snapshot removal both run through awaited promise chains, so @@ -161,29 +158,6 @@ describe('injectGeneration', () => { expect(result.result()).toEqual({ playable: true }) expect(result.status()).toBe('success') }) - - it('does not auto-fire a generation after render from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface. - const snapshot: GenerationResumeSnapshot = { - resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, - status: 'running', - } - const { adapter, connect } = createRunContextCaptureAdapter([]) - const { result } = renderInjectGeneration({ - threadId: 'no-auto-fire', - connection: adapter, - initialResumeSnapshot: snapshot, - }) - - await Promise.resolve() - - expect(connect).not.toHaveBeenCalled() - expect(result.isLoading()).toBe(false) - expect(result.status()).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(result.runId()).toBe(snapshot.resumeState?.runId) - }) - it('hydrates a snapshot from the server on mount', async () => { const { adapter, connect } = createRunContextCaptureAdapter([]) const hydrateGeneration = vi.fn(async () => ({ @@ -216,22 +190,27 @@ describe('injectGeneration', () => { }) describe('injectGenerateVideo', () => { - it('does not auto-fire a video generation after render from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface (video). + it('does not auto-fire a video generation after render from a hydrated running snapshot', async () => { const { adapter, connect } = createRunContextCaptureAdapter([]) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: videoResumeSnapshot, + activeRun: null, + })) const { result } = renderInjectGenerateVideo({ threadId: 'video-no-auto-fire', - connection: adapter, - initialResumeSnapshot: videoResumeSnapshot, + // No `joinRun`, so the restored run cannot be tailed. + connection: { ...adapter, hydrateGeneration }, + persistence: true, }) - await Promise.resolve() + await flushPromises() + // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() + expect(result.error()?.message).toMatch(/interrupted/) + expect(result.status()).toBe('error') expect(result.isLoading()).toBe(false) - expect(result.status()).toBe('idle') - // The seeded in-flight identity is exposed as read-only `resumeState`. - expect(result.runId()).toBe(videoResumeSnapshot.resumeState?.runId) + expect(result.runId()).toBeNull() }) }) diff --git a/packages/ai-angular/tests/test-utils.ts b/packages/ai-angular/tests/test-utils.ts index 761cdc254..716fe5895 100644 --- a/packages/ai-angular/tests/test-utils.ts +++ b/packages/ai-angular/tests/test-utils.ts @@ -6,7 +6,6 @@ import { } from '@angular/platform-browser-dynamic/testing' import { injectChat } from '../src/inject-chat' import type { InjectChatOptions, InjectChatResult } from '../src/types' -import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' export { createMockConnectionAdapter, @@ -14,7 +13,7 @@ export { createToolCallChunks, } from '../../ai-client/tests/test-utils' -export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { +export function createInterruptResumeSnapshot() { const pendingInterrupts = [ { id: 'staged-interrupt', @@ -45,7 +44,6 @@ export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { ] return { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts, } diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index c3fb4b552..e9444e760 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -867,7 +867,6 @@ export class ChatClient< // approval card (and hang on a stream that never comes), so the interrupt // always wins. this.applyResumeSnapshot({ - schemaVersion: 2, resumeState: { threadId: this.threadId, runId: result.interrupts.runId, @@ -1280,7 +1279,6 @@ export class ChatClient< } const descriptors = this.interruptManager.getDescriptors() this.persistor.persistResumeSnapshot({ - schemaVersion: 2, resumeState, ...(descriptors.length > 0 ? { pendingInterrupts: [...descriptors] } diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 5a83f5c77..1553f0713 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -800,9 +800,9 @@ export interface ConnectConnectionAdapter { * client never imports that package, so this is a structural contract, not a * shared type. Two deliberate widenings on this side: `schemaVersion` is * optional (the server always writes `1`, but a hand-written fixture need not), - * and `status` also admits `'idle'`, which the server's mapper never emits — - * only a client-local snapshot reaches it (a seeded `initialResumeSnapshot`, or - * `stop()` retiring a cancelled run). + * and `status` also admits `'idle'`, which the server's mapper never emits. + * Only a client-local snapshot reaches it, when `stop()` retires a cancelled + * run. */ export interface GenerationHydrationResult { resumeSnapshot: { diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 5b4931a5f..3d616d98f 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -26,8 +26,8 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, - GenerationResumeSnapshot, GenerationRestoredResult, + GenerationResumeSnapshot, GenerationResumeState, } from './generation-types' @@ -171,8 +171,6 @@ export class GenerationClient< '[TanStack AI] `persistence` needs a stable `threadId` to key on. Without one nothing will be restored after a reload. Pass a `threadId` derived from your own domain (e.g. `product-123-hero`).', ) } - this.resumeSnapshot = options.initialResumeSnapshot - this.callbacksRef = { onResult: options.onResult, onError: options.onError, @@ -197,8 +195,7 @@ export class GenerationClient< // React's render phase; hydrating here would re-fire the hydrate GET on // every discarded/speculative render, flooding the connection pool when // several clients mount together. It is kicked off once from - // `mountDevtools`, which the hooks call from a commit-phase mount effect. `initialResumeSnapshot` above still - // seeds SSR/first paint synchronously. + // `mountDevtools`, which the hooks call from a commit-phase mount effect. } private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions { diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 8984b098b..129670b89 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -66,9 +66,10 @@ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error' * Status of a persisted/restored generation run. * * `running` / `complete` / `error` are the three the server-side mapper emits - * over the wire. `idle` is client-local only: it seeds a hand-written - * `initialResumeSnapshot`, and `stop()` rewrites a `running` snapshot to it so - * a cancelled run is no longer resumable. + * over the wire. `idle` is client-local only: `stop()` rewrites a `running` + * snapshot to it so a cancelled run is no longer resumable. + * + * @internal */ export type GenerationResumeStatus = 'idle' | 'running' | 'complete' | 'error' @@ -133,6 +134,7 @@ export function clientStateFromResumeStatus( } } +/** @internal */ export interface GenerationResumeState { threadId: string runId: string @@ -144,8 +146,7 @@ export interface GenerationResumeState { pendingArtifacts?: Array } -export type GenerationPendingArtifact = PersistedArtifactRef - +/** @internal */ export interface GenerationResultSnapshot { id?: string model?: string @@ -169,49 +170,36 @@ export interface GenerationResultSnapshot { artifacts?: Array } +/** @internal */ export interface GenerationErrorSnapshot { message: string code?: string } +/** @internal */ export interface GenerationEventSnapshot { type: StreamChunk['type'] name?: string timestamp?: number } +/** @internal */ export interface GenerationResumeSnapshot { /** - * Version of the persisted snapshot shape. Written on every persisted - * snapshot so future shape changes can migrate (or reject) old records. - * Optional so hand-written seeds don't need to set it; absent means `1`. + * Version of the snapshot shape. Written on every snapshot the client builds + * so future shape changes can migrate (or reject) an older record hydrated + * from the server. Absent means `1`. */ schemaVersion?: 1 resumeState: GenerationResumeState | null status: GenerationResumeStatus activity?: PersistedArtifactRef['source']['activity'] - pendingArtifacts?: Array + pendingArtifacts?: Array result?: GenerationResultSnapshot error?: GenerationErrorSnapshot lastEvent?: GenerationEventSnapshot } -/** - * The `persistence` option for a generation client. - * - * - `false` (default) / omitted: ephemeral. Nothing is recorded; a reload starts - * from empty. - * - `true`: server-driven. On mount the client hydrates the last generation for - * its `threadId` from the server (via a `hydrateGeneration` handler, from the - * connection or supplied as an option) and repaints that snapshot. It never - * auto-starts a run. - * - * The record lives on the server, written by `withGenerationPersistence`. The - * browser caches nothing, so a generation's history is never duplicated into - * client storage. - */ -export type GenerationPersistenceOption = boolean - /** * The `persistence` / `threadId` / `id` identity shared by every generation hook. * @@ -385,27 +373,19 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { devtools?: Partial /** - * Explicit seed for the lightweight resume snapshot, for apps that manage - * storage themselves. When set, automatic hydration from `persistence` is - * skipped. It does not trigger any generation, but it is **not** inert: it - * seeds the client's live resume snapshot, which subsequent run events merge - * into and which `getResumeSnapshot()` returns and the client re-persists. - * Later reads therefore reflect this seed merged with observed activity, not - * the original value verbatim. - */ - initialResumeSnapshot?: GenerationResumeSnapshot - - /** - * How this generation persists across reloads. See - * {@link GenerationPersistenceOption}. + * How this generation persists across reloads. * * - Omit or `false`: ephemeral, in-memory only. * - `true`: server-driven. On mount the client hydrates the last generation * for its `threadId` from the server (needs a `hydrateGeneration` handler, * from the connection or the option below) and repaints that snapshot. It * never auto-starts a run. + * + * The record lives on the server, written by `withGenerationPersistence`. The + * browser caches nothing, so a generation's history is never duplicated into + * client storage. */ - persistence?: GenerationPersistenceOption + persistence?: boolean /** * Server-driven hydration handler, for transports that don't carry one on @@ -506,6 +486,8 @@ export interface GenerationRestoredResult { * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` / * `pendingArtifacts` from a previous run are dropped rather than carried into * the new run's snapshot. + * + * @internal */ export function updateGenerationResumeSnapshot( previous: GenerationResumeSnapshot | null | undefined, @@ -575,15 +557,16 @@ export function updateGenerationResumeSnapshot( } /** - * Validates an untrusted value (typically read back from a storage adapter) - * into a {@link GenerationResumeSnapshot}, or returns `undefined` when the - * value is not a usable snapshot. + * Validates an untrusted value (a hydration body resolved by the server) into a + * {@link GenerationResumeSnapshot}, or returns `undefined` when the value is + * not a usable snapshot. + * + * A hydrated record is outside the type system: it may be stale, truncated, or + * written by a different version. Every field is re-validated with the same + * narrowing the live chunk reducer uses. `lastEvent` is not restored, since it + * describes a transient stream position with no meaning after a reload. * - * Storage contents are outside the type system — they may be stale, truncated, - * hand-edited, or written by a future version. Every field is re-validated - * with the same narrowing the live chunk reducer uses. `lastEvent` is not - * restored: it describes a transient stream position that has no meaning - * after a reload. + * @internal */ export function parseGenerationResumeSnapshot( value: unknown, diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 262d8ca1d..7063e9384 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -43,9 +43,6 @@ export type { ToolApprovalInterrupt, ClientContextOptionFromTools, ChatResumeState, - ChatResumeSnapshot, - ChatResumeSnapshotV1, - ChatResumeSnapshotV2, ChatRequestBody, InferChatMessages, InferredClientContext, @@ -70,14 +67,6 @@ export type { InferGenerationOutput, InferGenerationOutputFromReturn, GenerationClientState, - GenerationResumeState, - GenerationResumeStatus, - GenerationResumeSnapshot, - GenerationPendingArtifact, - GenerationResultSnapshot, - GenerationErrorSnapshot, - GenerationEventSnapshot, - GenerationPersistenceOption, GenerationPersistenceOptions, GenerationClientOptions, GenerationFetcher, @@ -94,11 +83,7 @@ export type { VideoGenerateInput, GenerationRestoredResult, } from './generation-types' -export { - GENERATION_EVENTS, - parseGenerationResumeSnapshot, - updateGenerationResumeSnapshot, -} from './generation-types' +export { GENERATION_EVENTS } from './generation-types' // Per-activity result reconstruction mappers (used by the framework hooks to // repaint a typed `result` on restore) export { diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts index 3c7321823..84892e7e5 100644 --- a/packages/ai-client/src/storage-adapters.ts +++ b/packages/ai-client/src/storage-adapters.ts @@ -1,15 +1,15 @@ import type { ChatPersistedState, ChatStorageAdapter } from './types' -export interface WebStoragePersistenceOptions { +export interface WebStoragePersistenceOptions { keyPrefix?: string /** * Defaults to `JSON.stringify`. Override only for values JSON can't * round-trip losslessly (a `Map`, a `bigint`, a `Date` you need back as a * `Date` rather than an ISO string). */ - serialize?: (value: TValue) => string + serialize?: (value: ChatPersistedState) => string /** Defaults to `JSON.parse`. */ - deserialize?: (value: string) => TValue + deserialize?: (value: string) => ChatPersistedState } export interface IndexedDBPersistenceOptions { @@ -36,7 +36,7 @@ export class StorageUnavailableError extends Error { } } -function stringifyJson(value: TValue): string { +function stringifyJson(value: ChatPersistedState): string { const stringify: (input: unknown) => unknown = JSON.stringify const serialized = stringify(value) if (typeof serialized !== 'string') { @@ -45,10 +45,10 @@ function stringifyJson(value: TValue): string { return serialized } -function createWebStoragePersistence( +function createWebStoragePersistence( storageName: 'localStorage' | 'sessionStorage', - options: WebStoragePersistenceOptions, -): ChatStorageAdapter { + options: WebStoragePersistenceOptions, +): ChatStorageAdapter { const keyPrefix = options.keyPrefix ?? 'tanstack-ai:' const serialize = options.serialize ?? stringifyJson const deserialize = options.deserialize ?? JSON.parse @@ -88,27 +88,24 @@ function createWebStoragePersistence( * adapter can be constructed safely on the server. * * The `serialize` / `deserialize` codec defaults to `JSON.stringify` / - * `JSON.parse`, so the common case needs no codec. `TValue` defaults to the - * chat persisted-state shape — the only `persistence` option that takes an - * adapter — so `localStoragePersistence()` needs no type argument. Pass an - * explicit `TValue` for a standalone store holding something else. + * `JSON.parse`, so the common case needs no codec. */ -export function localStoragePersistence( - options: WebStoragePersistenceOptions = {}, -): ChatStorageAdapter { +export function localStoragePersistence( + options: WebStoragePersistenceOptions = {}, +): ChatStorageAdapter { return createWebStoragePersistence('localStorage', options) } /** * A `ChatStorageAdapter` backed by `window.sessionStorage` (scoped to the tab * and cleared when it closes). Identical to {@link localStoragePersistence} in - * every other respect: chat persisted-state default `TValue`, `tanstack-ai:` - * default `keyPrefix`, lazy per-operation {@link StorageUnavailableError} on - * SSR, and a JSON codec that defaults to `JSON.stringify` / `JSON.parse`. + * every other respect: the `tanstack-ai:` default `keyPrefix`, lazy + * per-operation {@link StorageUnavailableError} on SSR, and a JSON codec that + * defaults to `JSON.stringify` / `JSON.parse`. */ -export function sessionStoragePersistence( - options: WebStoragePersistenceOptions = {}, -): ChatStorageAdapter { +export function sessionStoragePersistence( + options: WebStoragePersistenceOptions = {}, +): ChatStorageAdapter { return createWebStoragePersistence('sessionStorage', options) } @@ -121,12 +118,11 @@ export function sessionStoragePersistence( * * No serialize/deserialize codec is needed or accepted — values are stored via * IndexedDB's native structured clone, so `Date`, `Map`, `ArrayBuffer`, etc. - * round-trip without a JSON step. `TValue` defaults to the chat persisted-state - * shape; pass an explicit one for a standalone store holding something else. + * round-trip without a JSON step. */ -export function indexedDBPersistence( +export function indexedDBPersistence( options: IndexedDBPersistenceOptions = {}, -): ChatStorageAdapter { +): ChatStorageAdapter { const databaseName = options.databaseName ?? 'tanstack-ai' const objectStoreName = options.objectStoreName ?? 'persistence' const keyPrefix = options.keyPrefix ?? 'tanstack-ai:' diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index f3ee42b02..75ff05976 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -37,20 +37,17 @@ export interface ChatResumeState { export type ChatPendingInterrupt = Interrupt -export interface ChatResumeSnapshotV1 { - schemaVersion?: 1 - resumeState: ChatResumeState - pendingInterrupts?: Array -} - -export interface ChatResumeSnapshotV2 { - schemaVersion: 2 +/** + * The durable pointer a chat keeps for the run it may need to rejoin, plus any + * interrupt that run is waiting on. + * + * @internal + */ +export interface ChatResumeSnapshot { resumeState: ChatResumeState pendingInterrupts?: Array } -export type ChatResumeSnapshot = ChatResumeSnapshotV1 | ChatResumeSnapshotV2 - export type InterruptItemStatus = | 'pending' | 'validating' diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 1990e5218..15bc82823 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -161,8 +161,6 @@ export class VideoGenerationClient { // `persistence` is `false`/omitted (ephemeral) or `true` (server-driven: // hydrate the last generation for `threadId` from the server on mount). this.serverDriven = options.persistence === true - this.resumeSnapshot = options.initialResumeSnapshot - this.callbacksRef = { onResult: options.onResult, onError: options.onError, @@ -190,8 +188,7 @@ export class VideoGenerationClient { // React's render phase; hydrating here would re-fire the hydrate GET on // every discarded/speculative render, flooding the connection pool when // several clients mount together. It is kicked off once from - // `mountDevtools`, which the hooks call from a commit-phase mount effect. `initialResumeSnapshot` above still - // seeds SSR/first paint synchronously. + // `mountDevtools`, which the hooks call from a commit-phase mount effect. } private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions { diff --git a/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts b/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts index 7b41260a0..d0cbf7732 100644 --- a/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts +++ b/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts @@ -65,7 +65,6 @@ describe('ChatClient interrupt error correlation', () => { connection, onChunk, initialResumeSnapshot: { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: interruptedRunId }, pendingInterrupts: [pendingInterrupt], }, diff --git a/packages/ai-client/tests/chat-client-interrupts.test.ts b/packages/ai-client/tests/chat-client-interrupts.test.ts index ec2c2f3b9..89f27e086 100644 --- a/packages/ai-client/tests/chat-client-interrupts.test.ts +++ b/packages/ai-client/tests/chat-client-interrupts.test.ts @@ -1092,7 +1092,6 @@ describe('ChatClient native interrupts', () => { connection: { async *connect() {} }, onInterruptStateChange, initialResumeSnapshot: { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts: [interrupt], }, @@ -1260,7 +1259,6 @@ describe('ChatClient native interrupts', () => { const fallback = genericDescriptor('fallback') const malformed = JSON.parse( JSON.stringify({ - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts: [fallback], interruptState: { @@ -1311,7 +1309,6 @@ describe('ChatClient native interrupts', () => { (_label, interruptState) => { const malformed = JSON.parse( JSON.stringify({ - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts: [genericDescriptor('fallback')], interruptState, diff --git a/packages/ai-client/tests/resume-snapshot.test.ts b/packages/ai-client/tests/resume-snapshot.test.ts index 23719462f..f1d1d92b7 100644 --- a/packages/ai-client/tests/resume-snapshot.test.ts +++ b/packages/ai-client/tests/resume-snapshot.test.ts @@ -44,7 +44,6 @@ describe('ChatPersistor combined record', () => { persistor.notifyMessagesChanged([createUIMessage('m1', 'hello')]) const snapshot: ChatResumeSnapshot = { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, } persistor.persistResumeSnapshot(snapshot) @@ -59,7 +58,6 @@ describe('ChatPersistor combined record', () => { const persistor = new ChatPersistor(adapter, 'chat-1', () => {}) persistor.notifyMessagesChanged([createUIMessage('m1', 'hello')]) persistor.persistResumeSnapshot({ - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, }) persistor.persistResumeSnapshot(null) @@ -99,7 +97,6 @@ describe('localStoragePersistence ergonomics', () => { const record: ChatPersistedState = { messages: [createUIMessage('m1', 'hi')], resume: { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, }, } @@ -182,7 +179,6 @@ describe('ChatClient auto-rejoin after reload', () => { const { adapter } = memoryAdapter({ messages: [createUIMessage('user-1', 'hi', 'user')], resume: { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, }, }) @@ -480,7 +476,6 @@ describe('ChatClient auto-rejoin after reload', () => { // History the app fetched from the server (reconstructChat) and seeded. initialMessages: [createUIMessage('history-1', 'earlier turn', 'user')], initialResumeSnapshot: { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, }, onMessagesChange: (messages) => { @@ -505,7 +500,6 @@ describe('ChatClient auto-rejoin after reload', () => { const record: ChatPersistedState = { messages: [], resume: { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, }, } @@ -548,7 +542,6 @@ describe('ChatClient auto-rejoin after reload', () => { const { adapter, read } = memoryAdapter({ messages: [createUIMessage('user-1', 'hi', 'user')], resume: { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'gone-run' }, }, }) @@ -649,7 +642,6 @@ describe('ChatClient auto-rejoin after reload', () => { const { adapter } = memoryAdapter({ messages: [createUIMessage('user-1', 'hi', 'user')], resume: { - schemaVersion: 2, resumeState: { threadId: 't1', runId: 'r1' }, }, }) diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index 900f16c07..6c7eb1dfe 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -49,7 +49,7 @@ the result to `withPersistence`. The core never inspects your tables. | `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | | `memoryPersistence()` | In-process reference backend, all seven stores (dev, tests) | | `reconstructChat` / `reconstructGeneration` | Server hydrate route helpers (chat / generation) | -| `retrieveArtifact` / `retrieveBlob` / `artifactBlobKey` | Serve persisted generation-media bytes back | +| `retrieveArtifact` / `retrieveBlob` / `resolveArtifactBlobKey` | Serve persisted generation-media bytes back | | `LockStore` / `withLocks` / `InMemoryLockStore` (from `@tanstack/ai/locks`) | Coordination, **not** this package — see ai-core/locks | | `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` gate (chat state stores) | diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index 236cea847..036361074 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -345,8 +345,8 @@ export default { A GET route resolves an `artifactId` to its record and its stored bytes. `retrieveArtifact` returns the `ArtifactRecord` (or `null` → 404); `retrieveBlob` returns the `BlobObject` (metadata + a streamable `body`). Both -key off `artifactBlobKey({ runId, artifactId })` internally, so you never build -the key yourself. +resolve the blob key from the record internally, so you never build the key +yourself. ```ts ignore import { retrieveArtifact, retrieveBlob } from '@tanstack/ai-persistence' diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index b0f4247b6..a370a3cc1 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -87,17 +87,12 @@ export type { export { retrieveArtifact, retrieveBlob, - artifactBlobKey, resolveArtifactBlobKey, } from './retrieve' // Reference in-memory implementation export { memoryPersistence } from './memory' -// Interrupt controller -export { createInterruptController } from './interrupts' -export type { InterruptController } from './interrupts' - // Persistence-owned capabilities only. Locks: @tanstack/ai. export { PersistenceCapability, diff --git a/packages/ai-persistence/src/interrupts.ts b/packages/ai-persistence/src/interrupts.ts deleted file mode 100644 index f8cc1d329..000000000 --- a/packages/ai-persistence/src/interrupts.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { InterruptRecord, InterruptStore } from './types' - -export interface InterruptController { - resolve: (interruptId: string, response?: unknown) => Promise - cancel: (interruptId: string) => Promise - request: ( - record: Omit, - ) => Promise - listPending: (threadId: string) => Promise> - listPendingByRun: (runId: string) => Promise> -} - -export function createInterruptController(opts: { - store: InterruptStore -}): InterruptController { - const { store } = opts - return { - resolve: (interruptId, response) => store.resolve(interruptId, response), - cancel: (interruptId) => store.cancel(interruptId), - request: (record) => store.create(record), - listPending: (threadId) => store.listPending(threadId), - listPendingByRun: (runId) => store.listPendingByRun(runId), - } -} diff --git a/packages/ai-persistence/src/retrieve.ts b/packages/ai-persistence/src/retrieve.ts index 9b5d8faa8..c54318d49 100644 --- a/packages/ai-persistence/src/retrieve.ts +++ b/packages/ai-persistence/src/retrieve.ts @@ -4,9 +4,11 @@ import type { AIPersistence, ArtifactRecord, BlobObject } from './types' * The DEFAULT blob-store key a generation artifact's bytes are stored under, * used when `withGenerationPersistence` is given no `storageKey` mapper. * - * Prefer {@link resolveArtifactBlobKey} for reads: a record written with a - * custom `storageKey` carries its real key in `blobKey`, and recomputing the + * Reads go through {@link resolveArtifactBlobKey} instead: a record written with + * a custom `storageKey` carries its real key in `blobKey`, and recomputing the * default would look in the wrong place. + * + * @internal */ export function artifactBlobKey( ref: Pick, diff --git a/packages/ai-persistence/tests/capabilities.test.ts b/packages/ai-persistence/tests/capabilities.test.ts index c8b9c17d1..da2a3ee3e 100644 --- a/packages/ai-persistence/tests/capabilities.test.ts +++ b/packages/ai-persistence/tests/capabilities.test.ts @@ -20,7 +20,6 @@ import { getInterrupts, getPersistence, } from '../src/capabilities' -import { createInterruptController } from '../src/interrupts' import type { AIPersistence, InterruptStore } from '../src' function mockAdapter(chunks: Array) { @@ -128,58 +127,3 @@ describe('persistence capabilities', () => { expect(seen.locks).toBe(locks) }) }) - -describe('createInterruptController', () => { - it('delegates request/resolve/cancel/list to the underlying store', async () => { - const persistence = memoryPersistence() - const interruptStore = persistence.stores.interrupts - expect(interruptStore).toBeDefined() - const controller = createInterruptController({ - store: interruptStore, - }) - - await controller.request({ - interruptId: 'c1', - runId: 'run-c', - threadId: 'thread-c', - requestedAt: 1, - payload: { kind: 'approval' }, - }) - expect(await controller.listPending('thread-c')).toHaveLength(1) - expect(await controller.listPendingByRun('run-c')).toHaveLength(1) - - await controller.resolve('c1', { approved: true }) - expect((await interruptStore.get('c1'))?.status).toBe('resolved') - expect((await interruptStore.get('c1'))?.response).toEqual({ - approved: true, - }) - expect(await controller.listPending('thread-c')).toHaveLength(0) - - await controller.request({ - interruptId: 'c2', - runId: 'run-c', - threadId: 'thread-c', - requestedAt: 2, - payload: {}, - }) - await controller.cancel('c2') - expect((await interruptStore.get('c2'))?.status).toBe('cancelled') - }) - - it('creates interrupts in the pending state', async () => { - const persistence = memoryPersistence() - const interruptStore = persistence.stores.interrupts - expect(interruptStore).toBeDefined() - const controller = createInterruptController({ - store: interruptStore, - }) - await controller.request({ - interruptId: 'c1', - runId: 'run-c', - threadId: 'thread-c', - requestedAt: 1, - payload: {}, - }) - expect((await interruptStore.get('c1'))?.status).toBe('pending') - }) -}) diff --git a/packages/ai-preact/tests/test-utils.ts b/packages/ai-preact/tests/test-utils.ts index a84445ea0..69ddf620b 100644 --- a/packages/ai-preact/tests/test-utils.ts +++ b/packages/ai-preact/tests/test-utils.ts @@ -4,9 +4,7 @@ import { useChat } from '../src/use-chat' import type { RenderHookResult } from '@testing-library/preact' import type { UseChatOptions, UseChatReturn } from '../src/types' -import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' - -export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { +export function createInterruptResumeSnapshot() { const pendingInterrupts = [ { id: 'staged-interrupt', @@ -36,7 +34,6 @@ export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { }, ] return { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts, } diff --git a/packages/ai-react/src/index.ts b/packages/ai-react/src/index.ts index 745f1e450..75bff7c3c 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -107,9 +107,4 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, - type GenerationResumeSnapshot, - type GenerationResumeState, - type GenerationResumeStatus, - type GenerationPendingArtifact, } from '@tanstack/ai-client' -export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index c1b133f20..16786194c 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -8,7 +8,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -53,8 +52,6 @@ export interface UseGenerateAudioOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index ea6114aee..75b74f5eb 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -7,7 +7,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -53,8 +52,6 @@ export interface UseGenerateImageOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index 179e9c37f..157f396c7 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -7,7 +7,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' @@ -53,8 +52,6 @@ export interface UseGenerateSpeechOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 356fde889..92ad62046 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -8,8 +8,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, @@ -55,8 +53,6 @@ export interface UseGenerateVideoOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -182,9 +178,7 @@ export function useGenerateVideo( const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(undefined) const [status, setStatus] = useState('idle') - const [runId, setRunId] = useState( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const [runId, setRunId] = useState(null) const optionsRef = useRef(options) optionsRef.current = options @@ -205,9 +199,6 @@ export function useGenerateVideo( ? { threadId: opts.threadId } : { id: opts.id ?? hookId }), ...(opts.persistence !== undefined && { persistence: opts.persistence }), - ...(opts.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: opts.initialResumeSnapshot, - }), ...(opts.hydrateGeneration !== undefined && { hydrateGeneration: opts.hydrateGeneration, }), @@ -259,7 +250,7 @@ export function useGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposedRef.current) setVideoStatus(s) }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs: { runId: string } | null) => { if (!disposedRef.current) setRunId(rs?.runId ?? null) }, } diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 2507b37d5..08fe68a98 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -10,7 +10,6 @@ import type { GenerationFetcher, GenerationPersistenceOptions, GenerationRestoredResult, - GenerationResumeSnapshot, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -59,8 +58,6 @@ export interface UseGenerationOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -179,9 +176,7 @@ export function useGeneration< const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(undefined) const [status, setStatus] = useState('idle') - const [runId, setRunId] = useState( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const [runId, setRunId] = useState(null) const optionsRef = useRef(options) optionsRef.current = options @@ -201,9 +196,6 @@ export function useGeneration< ? { threadId: opts.threadId } : { id: opts.id ?? hookId }), ...(opts.persistence !== undefined && { persistence: opts.persistence }), - ...(opts.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: opts.initialResumeSnapshot, - }), ...(opts.hydrateGeneration !== undefined && { hydrateGeneration: opts.hydrateGeneration, }), diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index 86ad10f9c..b412870ea 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -7,7 +7,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' @@ -53,8 +52,6 @@ export interface UseSummarizeOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index e17a22254..30ec4bab0 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -7,7 +7,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' @@ -53,8 +52,6 @@ export interface UseTranscriptionOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / diff --git a/packages/ai-react/tests/test-utils.ts b/packages/ai-react/tests/test-utils.ts index 2a132c508..eba9ac1ed 100644 --- a/packages/ai-react/tests/test-utils.ts +++ b/packages/ai-react/tests/test-utils.ts @@ -3,7 +3,6 @@ import { renderHook } from '@testing-library/react' import { useChat } from '../src/use-chat' import type { RenderHookResult } from '@testing-library/react' import type { UseChatOptions, UseChatReturn } from '../src/types' -import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' export { createMockConnectionAdapter, @@ -11,7 +10,7 @@ export { createToolCallChunks, } from '../../ai-client/tests/test-utils' -export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { +export function createInterruptResumeSnapshot() { const pendingInterrupts = [ { id: 'staged-interrupt', @@ -41,7 +40,6 @@ export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { }, ] return { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts, } diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index 12112f42f..0f4ee075a 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -18,7 +18,6 @@ import type { import { EventType } from '@tanstack/ai' import type { ConnectConnectionAdapter, - GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -82,12 +81,10 @@ function createVideoChunks(jobId: string, url: string): Array { ] } -const videoResumeSnapshot: GenerationResumeSnapshot = { - resumeState: { - threadId: 'thread-resume', - runId: 'run-resume', - }, - status: 'running', +const videoResumeSnapshot = { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, } const replayedVideoArtifact: PersistedArtifactRef = { @@ -158,11 +155,6 @@ function createRunContextCaptureAdapter(chunks: Array): { return { adapter, connect, runContexts } } -async function flushPromises(): Promise { - await Promise.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) -} - // Helper to create error stream chunks. // NOTE: The AG-UI spec for RUN_ERROR carries `message` directly on the event // (not nested under `error`). We emit BOTH shapes here because GenerationClient @@ -368,36 +360,6 @@ describe('useGeneration', () => { // Resolve the promise after unmount — should not cause errors resolvePromise!({ id: '1' }) }) - - it('does not auto-fire a generation on mount from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface: mounting a - // generation hook that has server persistence and a persisted `running` - // snapshot must NOT start a fresh empty-prompt generation (previously - // `maybeAutoResume()` -> `resume()` -> `generate({})` fired here). - const { adapter, connect } = createRunContextCaptureAdapter( - createGenerationChunks({ id: '1' }), - ) - const { result } = renderHook(() => - useGeneration({ - threadId: 'no-auto-fire', - connection: adapter, - initialResumeSnapshot: { - resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, - status: 'running', - }, - }), - ) - - await act(async () => { - await flushPromises() - }) - - expect(connect).not.toHaveBeenCalled() - expect(result.current.isLoading).toBe(false) - expect(result.current.status).toBe('idle') - // The persisted run id is still exposed as display state. - expect(result.current.runId).toBe('run-resume') - }) }) describe('persistence', () => { @@ -1059,28 +1021,32 @@ describe('useGenerateVideo', () => { expect(result.current.status).toBe('idle') }) - it('does not auto-fire a video generation on mount from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface (video). + it('does not auto-fire a video generation on mount from a hydrated running snapshot', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createReplayVideoChunks(), ) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: videoResumeSnapshot, + activeRun: null, + })) const { result } = renderHook(() => useGenerateVideo({ threadId: 'video-no-auto-fire', - connection: adapter, - initialResumeSnapshot: videoResumeSnapshot, + // No `joinRun`, so the restored run cannot be tailed. + connection: { ...adapter, hydrateGeneration }, + persistence: true, }), ) - await act(async () => { - await flushPromises() + await waitFor(() => { + expect(result.current.error?.message).toMatch(/interrupted/) }) + // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() + expect(result.current.status).toBe('error') expect(result.current.isLoading).toBe(false) - expect(result.current.status).toBe('idle') - // The seeded in-flight identity is exposed as the read-only `runId`. - expect(result.current.runId).toBe(videoResumeSnapshot.resumeState?.runId) + expect(result.current.runId).toBeNull() }) }) diff --git a/packages/ai-solid/src/index.ts b/packages/ai-solid/src/index.ts index 6ecd7454d..86b39b403 100644 --- a/packages/ai-solid/src/index.ts +++ b/packages/ai-solid/src/index.ts @@ -94,9 +94,4 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, - type GenerationResumeSnapshot, - type GenerationResumeState, - type GenerationResumeStatus, - type GenerationPendingArtifact, } from '@tanstack/ai-client' -export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index 8943ed632..e86676e78 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -25,11 +25,7 @@ export interface UseGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index c13e1c5f5..5c08484c4 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -25,11 +25,7 @@ export interface UseGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-speech.ts b/packages/ai-solid/src/use-generate-speech.ts index e7e16211c..0705b06e1 100644 --- a/packages/ai-solid/src/use-generate-speech.ts +++ b/packages/ai-solid/src/use-generate-speech.ts @@ -23,11 +23,7 @@ import type { Accessor } from 'solid-js' */ export interface UseGenerateSpeechOptions extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index 0147aee4b..d5c9dc785 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -15,8 +15,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, @@ -65,8 +63,6 @@ export interface UseGenerateVideoOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -192,14 +188,12 @@ export function useGenerateVideo( const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') - const [runId, setRunId] = createSignal( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const [runId, setRunId] = createSignal(null) let disposed = false // Built once. `untrack` keeps the option reads below from subscribing - // construction to `options.persistence` / `options.initialResumeSnapshot` / - // `options.devtools` / `options.body`: a re-run would build a second client + // construction to `options.persistence` / `options.devtools` / + // `options.body`: a re-run would build a second client // and orphan the first (only the live one is disposed on cleanup). Later // `options.body` changes are pushed through `updateOptions` instead. const client = untrack((): VideoGenerationClient => { @@ -214,9 +208,6 @@ export function useGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -267,7 +258,7 @@ export function useGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposed) setVideoStatus(s) }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs: { runId: string } | null) => { if (!disposed) setRunId(rs?.runId ?? null) }, } diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 9b9c0cc2f..dc7be6c79 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -17,7 +17,6 @@ import type { GenerationFetcher, GenerationPersistenceOptions, GenerationRestoredResult, - GenerationResumeSnapshot, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' import type { Accessor } from 'solid-js' @@ -67,8 +66,6 @@ export interface UseGenerationOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -186,14 +183,12 @@ export function useGeneration< const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') - const [runId, setRunId] = createSignal( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const [runId, setRunId] = createSignal(null) let disposed = false // Built once. `untrack` keeps the option reads below from subscribing - // construction to `options.persistence` / `options.initialResumeSnapshot` / - // `options.devtools` / `options.body`: a re-run would build a second client + // construction to `options.persistence` / `options.devtools` / + // `options.body`: a re-run would build a second client // and orphan the first (only the live one is disposed on cleanup). Later // `options.body` changes are pushed through `updateOptions` instead. const client = untrack((): GenerationClient => { @@ -209,9 +204,6 @@ export function useGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index 355fdea74..4b19970ef 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -25,11 +25,7 @@ export interface UseSummarizeOptions< TOutput = SummarizationResult, > extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index 00fa63c3d..4b3a0f0c5 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -29,11 +29,7 @@ export interface UseTranscriptionOptions< TranscriptionResult, TOutput >, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/tests/test-utils.ts b/packages/ai-solid/tests/test-utils.ts index fe9130ef0..081d5cc5e 100644 --- a/packages/ai-solid/tests/test-utils.ts +++ b/packages/ai-solid/tests/test-utils.ts @@ -3,8 +3,6 @@ import { renderHook } from '@solidjs/testing-library' import { useChat } from '../src/use-chat' import type { UseChatOptions } from '../src/types' -import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' - export { createMockConnectionAdapter, createTextChunks, @@ -12,7 +10,7 @@ export { type MockConnectionAdapterOptions, } from '../../ai-client/tests/test-utils' -export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { +export function createInterruptResumeSnapshot() { const pendingInterrupts = [ { id: 'staged-interrupt', @@ -42,7 +40,6 @@ export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { }, ] return { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts, } diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index e6b03d5fd..9dba53d16 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -17,7 +17,6 @@ import type { import { EventType } from '@tanstack/ai' import type { ConnectConnectionAdapter, - GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -81,12 +80,10 @@ function createVideoChunks(jobId: string, url: string): Array { ] } -const videoResumeSnapshot: GenerationResumeSnapshot = { - resumeState: { - threadId: 'thread-resume', - runId: 'run-resume', - }, - status: 'running', +const videoResumeSnapshot = { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, } function createReplayVideoChunks(): Array { @@ -462,33 +459,6 @@ describe('useGeneration', () => { }) describe('persistence', () => { - it('does not auto-fire a generation on mount from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface: mounting a - // generation hook with a persisted `running` snapshot must NOT start a - // fresh empty-prompt generation. - const { adapter, connect } = createRunContextCaptureAdapter( - createGenerationChunks({ id: '1' }), - ) - const { result } = renderHook(() => - useGeneration({ - threadId: 'no-auto-fire', - connection: adapter, - initialResumeSnapshot: { - resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, - status: 'running', - }, - }), - ) - - await flushPromises() - - expect(connect).not.toHaveBeenCalled() - expect(result.isLoading()).toBe(false) - expect(result.status()).toBe('idle') - // The persisted run id is still exposed as display state. - expect(result.runId()).toBe('run-resume') - }) - it('repaints status from a hydrated complete snapshot on mount without starting a run', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: '1' }), @@ -1413,27 +1383,31 @@ describe('useGenerateVideo', () => { expect(result.status()).toBe('idle') }) - it('does not auto-fire a video generation on mount from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface (video). + it('does not auto-fire a video generation on mount from a hydrated running snapshot', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createReplayVideoChunks(), ) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: videoResumeSnapshot, + activeRun: null, + })) const { result } = renderHook(() => useGenerateVideo({ threadId: 'video-no-auto-fire', - connection: adapter, - initialResumeSnapshot: videoResumeSnapshot, + // No `joinRun`, so the restored run cannot be tailed. + connection: { ...adapter, hydrateGeneration }, + persistence: true, }), ) - await Promise.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() + expect(result.error()?.message).toMatch(/interrupted/) + expect(result.status()).toBe('error') expect(result.isLoading()).toBe(false) - expect(result.status()).toBe('idle') - // The seeded in-flight identity is exposed as the read-only `runId`. - expect(result.runId()).toBe(videoResumeSnapshot.resumeState?.runId) + expect(result.runId()).toBeNull() }) }) diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index c4b25161d..d6c244423 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -24,11 +24,7 @@ export interface CreateGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< CreateGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index dd8409a55..6a00d7a24 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -24,11 +24,7 @@ export interface CreateGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< CreateGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index 5b7db392d..ca33705aa 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -22,11 +22,7 @@ import type { */ export interface CreateGenerateSpeechOptions extends Pick< CreateGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index ea4db75d5..c0b76b7d8 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -7,8 +7,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, @@ -56,8 +54,6 @@ export interface CreateGenerateVideoOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -185,9 +181,7 @@ export function createGenerateVideo( let isLoading = $state(false) let error = $state(undefined) let status = $state('idle') - let runId = $state( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + let runId = $state(null) let disposed = false // `body` uses a conditional spread because `VideoGenerationClientOptions.body` @@ -203,9 +197,6 @@ export function createGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -262,7 +253,7 @@ export function createGenerateVideo( if (disposed) return videoStatus = s }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs: { runId: string } | null) => { if (disposed) return runId = rs?.runId ?? null }, diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 4172fab8f..549c98e13 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -9,8 +9,6 @@ import type { GenerationFetcher, GenerationPersistenceOptions, GenerationRestoredResult, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -59,8 +57,6 @@ export interface CreateGenerationOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -194,9 +190,7 @@ export function createGeneration< let isLoading = $state(false) let error = $state(undefined) let status = $state('idle') - let runId = $state( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + let runId = $state(null) let disposed = false // `body` uses a conditional spread because `GenerationClientOptions.body` @@ -213,9 +207,6 @@ export function createGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -260,7 +251,7 @@ export function createGeneration< if (disposed) return status = s }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs) => { if (disposed) return runId = rs?.runId ?? null }, diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index 6e0e6f8ff..5bdb8c631 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -24,11 +24,7 @@ export interface CreateSummarizeOptions< TOutput = SummarizationResult, > extends Pick< CreateGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index e1de8973d..8b34f2c86 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -28,11 +28,7 @@ export interface CreateTranscriptionOptions< TranscriptionResult, TOutput >, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/index.ts b/packages/ai-svelte/src/index.ts index 59fc61c16..efe617ebe 100644 --- a/packages/ai-svelte/src/index.ts +++ b/packages/ai-svelte/src/index.ts @@ -98,9 +98,4 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, - type GenerationResumeSnapshot, - type GenerationResumeState, - type GenerationResumeStatus, - type GenerationPendingArtifact, } from '@tanstack/ai-client' -export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index f1b667f85..11e40105e 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -14,7 +14,6 @@ import type { } from '@tanstack/ai' import type { ConnectConnectionAdapter, - GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -80,12 +79,10 @@ function createVideoChunks(jobId: string, url: string): Array { ] } -const videoResumeSnapshot: GenerationResumeSnapshot = { - resumeState: { - threadId: 'thread-resume', - runId: 'run-resume', - }, - status: 'running', +const videoResumeSnapshot = { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, } function createRunContextCaptureAdapter(chunks: Array): { @@ -228,29 +225,6 @@ describe('createGeneration', () => { expect(gen.status).toBe('error') expect(gen.error?.message).toBe('Generation failed') }) - - it('does not auto-fire a generation on setup from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface. - const snapshot: GenerationResumeSnapshot = { - resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, - status: 'running', - } - const { adapter, connect } = createRunContextCaptureAdapter([]) - const gen = createGeneration({ - threadId: 'no-auto-fire', - connection: adapter, - initialResumeSnapshot: snapshot, - }) - - await Promise.resolve() - - expect(connect).not.toHaveBeenCalled() - expect(gen.isLoading).toBe(false) - expect(gen.status).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(gen.runId).toBe(snapshot.resumeState?.runId) - }) - it('repaints a hydrated running snapshot with no joinRun as an interrupted error on setup', async () => { const { adapter, connect } = createRunContextCaptureAdapter([]) const hydrateGeneration = vi.fn(async () => ({ @@ -867,22 +841,27 @@ describe('createGenerateVideo', () => { expect(gen.status).toBe('idle') }) - it('does not auto-fire a video generation on setup from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface (video). + it('does not auto-fire a video generation on setup from a hydrated running snapshot', async () => { const { adapter, connect } = createRunContextCaptureAdapter([]) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: videoResumeSnapshot, + activeRun: null, + })) const gen = createGenerateVideo({ threadId: 'video-no-auto-fire', - connection: adapter, - initialResumeSnapshot: videoResumeSnapshot, + // No `joinRun`, so the restored run cannot be tailed. + connection: { ...adapter, hydrateGeneration }, + persistence: true, }) - await Promise.resolve() + await flushAsync() + // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() + expect(gen.error?.message).toMatch(/interrupted/) + expect(gen.status).toBe('error') expect(gen.isLoading).toBe(false) - expect(gen.status).toBe('idle') - // The seeded in-flight identity is exposed as the read-only `runId`. - expect(gen.runId).toBe(videoResumeSnapshot.resumeState?.runId) + expect(gen.runId).toBeNull() }) it('should expose generate, stop, reset, and updateBody methods', () => { diff --git a/packages/ai-svelte/tests/test-utils.ts b/packages/ai-svelte/tests/test-utils.ts index ac69418d6..22f73c2b9 100644 --- a/packages/ai-svelte/tests/test-utils.ts +++ b/packages/ai-svelte/tests/test-utils.ts @@ -1,5 +1,4 @@ // Re-export test utilities from ai-client -import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' export { createMockConnectionAdapter, @@ -7,7 +6,7 @@ export { createToolCallChunks, } from '../../ai-client/tests/test-utils' -export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { +export function createInterruptResumeSnapshot() { const pendingInterrupts = [ { id: 'staged-interrupt', @@ -38,7 +37,6 @@ export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { ] return { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts, } diff --git a/packages/ai-vue/src/index.ts b/packages/ai-vue/src/index.ts index 6ecd7454d..86b39b403 100644 --- a/packages/ai-vue/src/index.ts +++ b/packages/ai-vue/src/index.ts @@ -94,9 +94,4 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, - type GenerationResumeSnapshot, - type GenerationResumeState, - type GenerationResumeStatus, - type GenerationPendingArtifact, } from '@tanstack/ai-client' -export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index 60f46992d..8dcab6a5c 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -25,11 +25,7 @@ export interface UseGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index 804afd2fc..67eaae42c 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -25,11 +25,7 @@ export interface UseGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-speech.ts b/packages/ai-vue/src/use-generate-speech.ts index 31206b33d..247559917 100644 --- a/packages/ai-vue/src/use-generate-speech.ts +++ b/packages/ai-vue/src/use-generate-speech.ts @@ -23,11 +23,7 @@ import type { DeepReadonly, ShallowRef } from 'vue' */ export interface UseGenerateSpeechOptions extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 63c8f196c..659eaeee4 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -15,8 +15,6 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, @@ -65,8 +63,6 @@ export interface UseGenerateVideoOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -190,9 +186,7 @@ export function useGenerateVideo( const isLoading = shallowRef(false) const error = shallowRef(undefined) const status = shallowRef('idle') - const runId = shallowRef( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const runId = shallowRef(null) let disposed = false // Conditional spread on `body`: `VideoGenerationClientOptions.body` is a @@ -207,9 +201,6 @@ export function useGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -266,7 +257,7 @@ export function useGenerateVideo( if (disposed) return videoStatus.value = s }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs: { runId: string } | null) => { if (disposed) return runId.value = rs?.runId ?? null }, diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 37f6acfd0..fece42a70 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -17,8 +17,6 @@ import type { GenerationFetcher, GenerationPersistenceOptions, GenerationRestoredResult, - GenerationResumeSnapshot, - GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' import type { DeepReadonly, ShallowRef } from 'vue' @@ -68,8 +66,6 @@ export interface UseGenerationOptions { * it falls back to `id` purely to satisfy the wire. */ threadId?: string - /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ - initialResumeSnapshot?: GenerationResumeSnapshot /** * Server-driven hydration handler for `persistence: true` when the * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / @@ -189,9 +185,7 @@ export function useGeneration< const isLoading = shallowRef(false) const error = shallowRef(undefined) const status = shallowRef('idle') - const runId = shallowRef( - options.initialResumeSnapshot?.resumeState?.runId ?? null, - ) + const runId = shallowRef(null) let disposed = false // Conditional spread on `body`: `GenerationClientOptions.body` is a strict @@ -205,9 +199,6 @@ export function useGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), ...(options.hydrateGeneration !== undefined && { hydrateGeneration: options.hydrateGeneration, }), @@ -252,7 +243,7 @@ export function useGeneration< if (disposed) return status.value = s }, - onResumeStateChange: (rs: GenerationResumeState | null) => { + onResumeStateChange: (rs) => { if (disposed) return runId.value = rs?.runId ?? null }, diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index 73aa1600f..db2b87d93 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -25,11 +25,7 @@ export interface UseSummarizeOptions< TOutput = SummarizationResult, > extends Pick< UseGenerationOptions, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index 234f190ef..5f014e754 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -29,11 +29,7 @@ export interface UseTranscriptionOptions< TranscriptionResult, TOutput >, - | 'persistence' - | 'threadId' - | 'initialResumeSnapshot' - | 'hydrateGeneration' - | 'joinRun' + 'persistence' | 'threadId' | 'hydrateGeneration' | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/tests/test-utils.ts b/packages/ai-vue/tests/test-utils.ts index 727baf3fe..06c99e0a4 100644 --- a/packages/ai-vue/tests/test-utils.ts +++ b/packages/ai-vue/tests/test-utils.ts @@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils' import { defineComponent } from 'vue' import { useChat } from '../src/use-chat' import type { UseChatOptions } from '../src/types' -import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' // Re-export test utilities from ai-client export { @@ -11,7 +10,7 @@ export { createToolCallChunks, } from '../../ai-client/tests/test-utils' -export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { +export function createInterruptResumeSnapshot() { const pendingInterrupts = [ { id: 'staged-interrupt', @@ -42,7 +41,6 @@ export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { ] return { - schemaVersion: 2, resumeState: { threadId: 'thread-1', runId: 'run-1' }, pendingInterrupts, } diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index 9420b6b20..f7ecb9e99 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -17,7 +17,6 @@ import type { } from '@tanstack/ai' import type { ConnectConnectionAdapter, - GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' import type { DeepReadonly } from 'vue' @@ -78,12 +77,10 @@ function createVideoChunks(jobId: string, url: string): Array { ] as unknown as Array } -const videoResumeSnapshot: GenerationResumeSnapshot = { - resumeState: { - threadId: 'thread-resume', - runId: 'run-resume', - }, - status: 'running', +const videoResumeSnapshot = { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, } function createReplayVideoChunks(): Array { @@ -257,33 +254,6 @@ describe('useGeneration', () => { expect(result.status.value).toBe('error') expect(result.error.value?.message).toBe('Generation failed') }) - - it('does not auto-fire a generation on mount from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface. - const snapshot: GenerationResumeSnapshot = { - resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, - status: 'running', - } - const { adapter, connect } = createRunContextCaptureAdapter( - createGenerationChunks({ id: '1' }), - ) - const { result } = renderHook(() => - useGeneration({ - threadId: 'no-auto-fire', - connection: adapter, - initialResumeSnapshot: snapshot, - }), - ) - - await flushPromises() - await nextTick() - - expect(connect).not.toHaveBeenCalled() - expect(result.isLoading.value).toBe(false) - expect(result.status.value).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(result.runId.value).toBe(snapshot.resumeState?.runId) - }) }) describe('stop and reset', () => { @@ -1096,27 +1066,32 @@ describe('useGenerateVideo', () => { expect(result.status.value).toBe('idle') }) - it('does not auto-fire a video generation on mount from a persisted running snapshot', async () => { - // Regression guard for the removed generation resume surface (video). + it('does not auto-fire a video generation on mount from a hydrated running snapshot', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createReplayVideoChunks(), ) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: videoResumeSnapshot, + activeRun: null, + })) const { result } = renderHook(() => useGenerateVideo({ threadId: 'video-no-auto-fire', - connection: adapter, - initialResumeSnapshot: videoResumeSnapshot, + // No `joinRun`, so the restored run cannot be tailed. + connection: { ...adapter, hydrateGeneration }, + persistence: true, }), ) await flushPromises() await nextTick() + // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() + expect(result.error.value?.message).toMatch(/interrupted/) + expect(result.status.value).toBe('error') expect(result.isLoading.value).toBe(false) - expect(result.status.value).toBe('idle') - // The seeded in-flight identity is exposed as the read-only `runId`. - expect(result.runId.value).toBe(videoResumeSnapshot.resumeState?.runId) + expect(result.runId.value).toBeNull() }) it('should require either connection or fetcher', () => { diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 31fcbb187..e7cf2eef1 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -7,11 +7,10 @@ description: > client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. - Also covers generation hooks (useGenerateImage etc.), same two modes as chat: - client-driven (adapter) persists a lightweight resume snapshot under - generation: (threadId is REQUIRED with persistence); server-driven - (persistence: true) hydrates the last generation from the server on mount, - nothing cached. + Also covers generation hooks (useGenerateImage etc.), which take only the + server-driven mode: persistence: true hydrates the last generation for the + (REQUIRED) threadId from the server on mount and repaints status/result/error, + nothing is cached in the browser. No extra package: the adapters ship in the framework packages. type: sub-skill library: tanstack-ai From c2f02d89995dc27247a585bcc1d988cf38d363d5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 31 Jul 2026 13:41:03 +0200 Subject: [PATCH 2/2] docs(persistence): tidy the split adapter pages after the merge Post-merge pass with the docs skill over the file that conflicted: - collapse the double blank lines the split left before three headings - `build-your-own-adapter` no longer holds a walkthrough, so stop saying "type this page out" and "all four stores" on a page that presents seven - rewrap two paragraphs the ported edits ran long --- docs/persistence/build-your-own-adapter.md | 9 ++++----- docs/persistence/store-reference.md | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 9bb53b2a1..254212c2c 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -206,7 +206,7 @@ threads per user, add `created_at`/`updated_at` audit columns, add a tenant id. Keep added columns nullable or defaulted so the store's inserts still succeed. The TanStack AI stores never read or write columns they do not know about. -**Adopt part of it.** You rarely need all four stores in the same database. Put +**Adopt part of it.** You rarely need every store in the same database. Put `messages` and `runs` in your primary database and nothing else, then fill the rest from another source with `composePersistence`: @@ -226,7 +226,6 @@ must touch both is two writes; design retries and idempotency for that yourself. The store invariants (idempotent `createOrResume`, insert-if-absent `create`) are what make those retries safe, which is exactly why they are invariants. - ## Verify with the conformance suite Do not eyeball it. `@tanstack/ai-persistence` ships the same conformance test @@ -244,7 +243,8 @@ runPersistenceConformance('my sqlite adapter', () => ``` The suite covers all seven stores, the four chat state stores and the three -generation stores, so an adapter lists whatever it deliberately omits. A chat-only adapter skips the generation half: +generation stores, so an adapter lists whatever it deliberately omits. A +chat-only adapter skips the generation half: ```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' @@ -283,10 +283,9 @@ green, your adapter is a drop-in for `withPersistence` (and, with the generation stores, `withGenerationPersistence`). The `examples/ts-react-chat` app runs exactly this test against its SQLite backend, which provides all seven. - ## Let your coding agent write it -You do not have to type this page out. `@tanstack/ai-persistence` ships +You do not have to write any of this by hand. `@tanstack/ai-persistence` ships [Agent Skills](../getting-started/agent-skills) that turn it into a recipe your assistant follows against **your** stack: it reads your existing ORM config, schema file, and database handle, appends the four tables to the schema you diff --git a/docs/persistence/store-reference.md b/docs/persistence/store-reference.md index 4108f44de..495a766f3 100644 --- a/docs/persistence/store-reference.md +++ b/docs/persistence/store-reference.md @@ -333,7 +333,6 @@ asserts it. Ignoring `range` and returning the whole file is what makes entire artifact for every seek. A reference-only backend that stores no bytes skips `blobs` altogether instead. - ## Where to go next - [Build a chat adapter](./build-your-own-chat-adapter): these contracts implemented against SQLite.