feat(persistence): durable generation media-byte storage - #998
feat(persistence): durable generation media-byte storage#998tombeckenham wants to merge 22 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚀 Changeset Version Preview17 package(s) bumped directly, 34 bumped as dependents. 🟥 Major bumps
🟨 Minor bumps
🟩 Patch bumps
|
|
View your CI Pipeline Execution ↗ for commit a1f39a2
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-durable-stream
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-memory
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-persistence
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
Layer a lightweight, read-only resume snapshot onto media generation. As a run streams, the client builds a GenerationResumeSnapshot (run identity, status, errors, result metadata + artifact refs — never media bytes) and writes it to an optional GenerationServerPersistence store. - ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot reducer; GenerationClient/VideoGenerationClient observe chunks, persist snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot(); disposed guard. No resume() action (stream re-attach is PR #955). - ai-event-client: optional threadId/runId on generation events. - react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot options; expose resumeSnapshot/resumeState (+ pending/result artifacts). - example: Persisted mode on the image generation route. - docs: persistence/generation-persistence.md + nav entry. Pairs with the existing withGenerationPersistence server middleware.
Drop the bespoke `GenerationServerPersistence` type and the `{ server }`
option wrapper. The `persistence` option is now a bare storage adapter
reusing the shared `ChatStorageAdapter` contract (aliased as
`GenerationPersistence`), so `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` work for generations
exactly as they do for chat — matching main's ergonomics.
…istence (no call-site generic)
Default `localStoragePersistence` / `sessionStoragePersistence` /
`indexedDBPersistence` to a value-agnostic `TValue` so a bare, unannotated
call works for BOTH chat and generation persistence — the consuming
`persistence` option constrains the stored value. Generation docs/example now
use `localStoragePersistence({ keyPrefix })` with no type declaration.
PR #955 (resumable streams) is merged, so delivery durability is available today — it was wrongly described as an unlanded future feature. Rewrite the generation-persistence doc: the server example now wires a durability adapter + GET handler, and the delivery section explains that a dropped mid-generation connection re-attaches through the same adapters useChat uses. Clarify that the read-only snapshot carries run state (incl. runId) across reloads, while hooks do not auto-resume on mount.
34c8b69 to
765bcfd
Compare
f795de7 to
21d283a
Compare
…e revival - kiira: replace phantom @tanstack/ai-persistence-drizzle import with the hand-rolled adapter from build-your-own-adapter (CI was red on this) - hydrate the resume snapshot from persistence.getItem on construction, validated via new parseGenerationResumeSnapshot(unknown) export; initialResumeSnapshot seed takes precedence - namespace storage keys as generation:<id> so chat and generation clients sharing an id and adapter no longer collide - write terminal snapshots on stop() (idle) and transport-level errors (error); reset() clears memory + removeItem; RUN_STARTED drops stale result/error/pendingArtifacts from the previous run; plain-fetcher runs now record a complete snapshot built from the fetcher result - capture video jobId into the snapshot from video:job:created - add schemaVersion: 1 to persisted snapshots - gate persistence writes on material change (ignore lastEvent-only churn), warn once per failure transition, clear resumePersistenceError on success - mountDevtools() revives a disposed client (React StrictMode replay); generate() checks disposed before mounting devtools - onResumeSnapshotChange now receives undefined when reset() clears - fix mojibake em dashes in 12 hook files
…t coverage - rewrite docs/persistence/generation-persistence.md around the implemented behavior: hydration on mount, generation:<id> keys, resumeState vs resumeSnapshot semantics, honest reconnect story, no media-URL claim; drop the inert threadId/runId spreads from the server sample - fix the example's Persisted panel: distinguish in-flight run from last-run outcome; reload now actually shows the persisted record - revert ai-event-client: BaseEventContext already carries threadId/runId, the 36 added lines were redundant redeclarations; changeset no longer bumps that package and now describes hydration + lifecycle accurately - normalize wrong hook JSDoc (Server-side → client-side storage; read-only seed claims; run/cursor wording) and mark artifact fields dormant - React hooks: post-dispose guards on callbacks/setters, StrictMode revive via mount effect, stable empty artifact arrays, re-export persistence types (+ PersistedArtifactRef) - tests: replace the two vacuous reducer tests with real externalUrl positive/negative and stop coverage; add reducer seed-merge, RUN_STARTED stale-field-drop, video jobId capture, parseGenerationResumeSnapshot suite; add client lifecycle suite (hydration, seed precedence, corrupt storage, stop/reset/transport-error, write gating, StrictMode revive); add React hydration/StrictMode/artifact-exposure hook tests
…hots Provider-free harness (api.generation-persistence streams a fixed AG-UI sequence; aimock-exempt) + page using useGenerateImage with localStoragePersistence. Proves: snapshot written under tanstack-ai:generation:<id> with no media bytes, hydrated after reload with no auto-run, and removed by reset().
… + media-generation skills
…ration ordering - solid: build the client outside reactive tracking (untrack) — the old createMemo second-arg was a seed, not deps, so option reads were tracked and a change orphaned an undisposed client; stable empty artifact arrays - svelte: explicit generate() now revives a disposed client (mountDevtools) since Svelte has no remount effect; reactive bindings revive with it - vue: stable empty artifact array constants (shallowRef identity) - angular: JSDoc for persistence/initialResumeSnapshot on inject-generate-video - all four: re-export GenerationPersistence/GenerationResumeSnapshot/ GenerationResumeState/GenerationResumeStatus/GenerationPendingArtifact + PersistedArtifactRef from package index; hydration + reset()/removeItem tests against Map-backed adapters - ai-client: kick off snapshot hydration only after callbacksRef is assigned (removes a sync-adapter ordering hazard)
…enerate casts UseGenerationReturn gains a defaulted second generic (TInput extends Record<string, any> = Record<string, any>) so generate is typed (input: TInput) => Promise<void>. useGeneration returns the type it actually builds — the unsound internal narrow-to-wide cast and the five wrapper-level casts back down to the concrete input type all disappear, and direct useGeneration consumers get a precisely typed generate. Existing UseGenerationReturn<MyOutput> references keep compiling via the default.
…svelte/angular Same fix as fbc3dc3 for the remaining four frameworks: the base return interface (UseGenerationReturn / CreateGenerationReturn / InjectGenerationResult) gains a defaulted second generic (TInput extends Record<string, any> = Record<string, any>) so generate is typed (input: TInput) => Promise<void>. The base hooks return the type they actually build and the internal narrow-to-wide casts plus every wrapper-level 'generate as' cast are deleted. Video hooks were already cast-free (they build their own client). Defaults keep existing single-generic references compiling.
…Value = any) Revert the web-storage factory defaults to TValue = ChatPersistedState, as shipped in #984. The any default erased type safety on every direct adapter use (getItem returned any; a store built for one domain assigned silently to the other's hook) and carried three oxlint suppressions — while buying nothing for inline usage, where contextual typing infers the value type from the persistence option regardless of the default. The one affected pattern, a standalone store for generations, now states its type: localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e call sites updated; runtime behavior unchanged.
Server-side artifact + blob storage for generated media, layered on withGenerationPersistence. - @tanstack/ai: result-transform machinery (resultTransforms/artifactInputs on GenerationMiddlewareContext, applyGenerationResultTransforms), threadId/ runId on the image/audio/speech/transcription activities, and generation:artifacts emission from streamGenerationResult. - @tanstack/ai-utils: base64ToUint8Array. - @tanstack/ai-persistence: ArtifactStore + BlobStore contracts, in-memory impls in memoryPersistence(), and withGenerationPersistence byte-persistence (writes bytes to blobs, records ArtifactRecord, attaches PersistedArtifactRef, emits generation:artifacts) with extractArtifacts/nameArtifact options.
…+ blobs, serve route, extractArtifacts/nameArtifact)
Add retrieveArtifact(persistence, id) and retrieveBlob(persistence, idOrRecord) so a serve handler fetches a persisted generation artifact's metadata and bytes without hand-rolling the blob key. artifactBlobKey is the shared key builder used by both withGenerationPersistence (write) and retrieveBlob (read).
… core Resolve the rebase of the artifact/blob path onto the reworked ai-persistence: - memoryPersistence() keeps the base's no-locks decision and gains only artifacts + blobs; locks stay separate via InMemoryLockStore + withLocks. - AIPersistenceStores / storeKeys gain artifacts + blobs but not locks. - validateGenerationPersistenceStores and InvalidGenerationPersistence now carry both invariants: runs is required, and artifacts/blobs come as a pair. - withGenerationPersistence keeps the base's TEMPORARY/WRONG-SHAPE warning; the byte path does not fix the requestId-vs-runId keying, so the note now says so. - Type test widens memoryPersistence() past exact ChatPersistence equality; artifact pairing test gains a runs store so it exercises the pairing rule.
21d283a to
a1f39a2
Compare
1e99cad to
61b8d07
Compare
🎯 Changes
Stack 2 of 2. Stacked on #997 — please review/merge that first; this PR's base is its branch, so the diff here is only the server half. Split out of #987 and rebuilt on the current
feat/persistence-core.Durable media-byte storage for generations (server, opt-in).
When the persistence backend provides both an
artifacts(ArtifactStore) and ablobs(BlobStore) store,withGenerationPersistencewrites each generated file's bytes to the blob store (keyartifacts/<runId>/<artifactId>), records anArtifactRecord, attachesPersistedArtifactRefs to the result, and emitsgeneration:artifactsso the client records them. Extraction is customizable viaextractArtifacts/nameArtifact.memoryPersistence()now ships both stores; any backend implementing the two contracts works. To serve a stored artifact,@tanstack/ai-persistenceexportsretrieveArtifact(persistence, id)andretrieveBlob(persistence, idOrRecord)(plusartifactBlobKey).Packages
@tanstack/ai— result-transform machinery (resultTransforms/artifactInputsonGenerationMiddlewareContext,applyGenerationResultTransforms),threadId/runIdoptions on the image/audio/speech/transcription activities, andgeneration:artifactsemission fromstreamGenerationResult.@tanstack/ai-utils—base64ToUint8Array.@tanstack/ai-persistence—ArtifactStore+BlobStorecontracts, in-memory impls inmemoryPersistence(), byte persistence inwithGenerationPersistence,retrieveArtifact/retrieveBlob.docs/persistence/generation-persistence.md.Reconciling with the contract-only core
The original commits predate
62c99754d refactor(persistence)!: ship the contract, not the backends, so the rebase onto the reworkedai-persistenceneeded real decisions. These are isolated in the finalfix(persistence): reconcile …commit, separate from the cherry-picks:memoryPersistence()keeps the base's no-locks decision and gains onlyartifacts+blobs; locks stay separate viaInMemoryLockStore+withLocks. Same forAIPersistenceStores/storeKeys.validateGenerationPersistenceStoresandInvalidGenerationPersistencenow carry both invariants:runsis required (base), andartifacts/blobscome as a pair (this PR).withGenerationPersistenceis preserved. The original patch deleted it without fixing the underlying problem, so the note now also records therequestId-vs-runIdmismatch below.memoryPersistence()to exactChatPersistenceequality, and the artifact-pairing test gains arunsstore so it exercises the pairing rule rather than tripping the required-runs rule first.types.tsstates the testkit is "the authoritative compatibility gate" and that new invariants must be promoted into it.ArtifactStore/BlobStoreadd documented invariants (notablyBlobStore.listcursor/prefix semantics) with zero suites. Now that backends are app-owned, this is the only thing holding an implementation to the rules.RunRecord.runIdandArtifactRecord.runIddisagree. The run record is keyed onctx.requestId; artifacts usectx.runId ?? ctx.requestId. With a caller-suppliedrunId,runs.get(runId)is empty whileartifacts.list(runId)returns rows — the stores can't be joined. This is exactly what the preserved TEMPORARY/WRONG-SHAPE note warns about.generateVideois the only media activity with noartifactInputs/applyGenerationResultTransforms, yetmiddleware.tscarries an unreachableactivity === 'video'branch and the docs lead with video as the motivating case. Either wire it or drop the dead branch and scope it out explicitly.fetch(descriptor.url)throw isn't isolated byapplyGenerationResultTransforms, so a blip fetching an expiring provider URL discards the paid-for result, fails the run, and orphans already-written blobs.size: 0, and the documented serve route sendscontent-length: 0alongside a real body.GET ?id=<artifactId>returns any artifact's bytes, andartifactIdisDate.now()+ 5 chars ofMath.random().sourcePartDescriptorsdefaults mime to${type}/mpeg(yielding invalidimage/mpeg);parseDataUrldoesn't match parameterized data URLs and its fallback base64-decodes the whole URL.✅ Checklist
pnpm run test:pr. — ran the affected subset:build,test:types,test:lib(ai-persistence at 99 tests),test:oxlintall green.🚀 Release Impact