UNPKG

@tanstack/ai-persistence

Version:

Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.

716 lines (643 loc) 31.5 kB
--- name: ai-persistence/build-cloudflare-artifact-store description: Use when a Cloudflare Worker needs durable byte storage for TanStack AI generated media (images, audio, video, transcripts) — writes a BlobStore backed by R2 and an ArtifactStore backed by D1, composes them onto the generation persistence so withGenerationPersistence persists artifact bytes, and serves them back from a Worker GET route. Includes one-line sketches for S3, GCS, Vercel Blob, Supabase, and a dev filesystem BlobStore. --- # Cloudflare Artifact + Blob Store `withGenerationPersistence(persistence)` needs only `stores.generationRuns` to track a generation's lifecycle. Add `stores.artifacts` (metadata) **and** `stores.blobs` (the bytes) — both, or neither — and the middleware also persists the generated media: image/audio/TTS/video/transcription bytes land at blob key `artifacts/<runId>/<artifactId>`, with an `ArtifactRecord` row describing each. The deliverable is **one file in the Worker** — e.g. `src/lib/generation-persistence.ts` — exporting a factory that builds an `AIPersistence` from the request's R2 + D1 bindings, plus a GET route that serves artifact bytes with `retrieveArtifact` / `retrieveBlob`. Read the sibling **ai-persistence/build-cloudflare-adapter** skill for the per-request-binding rule, `wrangler` config shape, D1 migration workflow, and the chat (generation-run/message) side. This skill covers only the two byte-storage stores and how to compose them. ## The two contracts Both come from `@tanstack/ai-persistence`. `defineBlobStore` / `defineArtifactStore` type an object literal inline (autocomplete + contract checking, no separate annotation). ```ts // BlobStore — the byte layer. R2 backs it. interface BlobStore { put: ( key: string, body: BlobBody, options?: BlobPutOptions, ) => Promise<BlobRecord> // metadata + byte accessors; `options.range` reads one slice (for `206`s) get: (key: string, options?: BlobGetOptions) => Promise<BlobObject | null> head: (key: string) => Promise<BlobRecord | null> // metadata only delete: (key: string) => Promise<void> // no-op if absent list: (options?: BlobListOptions) => Promise<BlobListPage> } // ArtifactStore — the metadata layer. D1 backs it. interface ArtifactStore { save: (record: ArtifactRecord) => Promise<void> // insert or overwrite get: (artifactId: string) => Promise<ArtifactRecord | null> list: (runId: string) => Promise<Array<ArtifactRecord>> // [] when none listForThread: (threadId: string) => Promise<Array<ArtifactRecord>> delete: (artifactId: string) => Promise<void> deleteForRun: (runId: string) => Promise<void> } ``` `list` and `listForThread` return records ordered by `createdAt`, then by the ordinal bytewise order of `artifactId`. Compare UTF-8 bytes from left to right. Do not use locale collation. `BlobBody` is `ReadableStream<Uint8Array> | ArrayBuffer | ArrayBufferView | string | Blob`. The non-stream shapes flow straight into `R2Bucket.put` unchanged — but a `ReadableStream` body does **not**, in the general case: workerd's `put` requires a stream with a known length (a `Response` body or the readable half of a `FixedLengthStream`), and the artifact middleware hands you a `TransformStream`-wrapped body whenever it had to cap a fetched body as it drains. Passing that stream to `bucket.put` throws `TypeError: Provided readable stream must have a known length`. **When does that actually happen?** The wrapper only exists to enforce `maxArtifactBytes` during the drain, so the middleware applies it only when nothing else bounds the transfer: | Provider response | Body handed to `put` | R2 path | | --------------------------------------- | --------------------------------- | ------------------- | | `content-length`, no `content-encoding` | untouched, declared length intact | `bucket.put` direct | | chunked (no declared length) | wrapped, length-less | multipart | | `content-encoding: gzip` | wrapped, length-less | multipart | A provider CDN normally sends `content-length`, so the first row is the common case and `bucket.put(key, body)` just works. The recipe below is what makes the other two rows work: it re-declares the length from `BlobPutOptions.expectedLength` when the middleware could vouch for one, and otherwise streams through a multipart upload (one 8 MiB part at a time — flat memory at any artifact size). Write it once and every response shape is covered. `withGenerationPersistence(persistence, { maxArtifactBytes: false })` drops the ceiling and the wrapper altogether, so even a chunked reply arrives untouched. It buys nothing extra for R2 (a chunked body has no length to preserve), so choose it on its own merits: no _application_ limit on what an origin can stream into your bucket. R2's own limits still apply — 5 GiB per single-shot put, and 10,000 multipart parts (~80 GiB at the 8 MiB part size below). Keep the cap when `allowInputUrl` lets callers name the URL. `BlobPutOptions` is `{ contentType?, customMetadata?, expectedLength? }`; `BlobGetOptions` is `{ range?: { offset: number, length?: number } }` and maps onto R2's own `range`; `BlobListOptions` is `{ prefix?, cursor?, limit? }`; `BlobListPage` is `{ objects: BlobRecord[], cursor?, truncated? }`. ## 1. BlobStore backed by R2 `R2Object` carries `size`, `etag`, `httpMetadata.contentType`, `customMetadata`, and `uploaded` (a `Date`). `BlobRecord` wants `createdAt` / `updatedAt` as epoch ms — R2 tracks only the single `uploaded` instant, so map it to both. `get` / `head` are the byte-body vs metadata-only split; `R2ObjectBody` already exposes `body`, `arrayBuffer()`, and `text()`, so a `BlobObject` is essentially the R2 object plus the mapped metadata. ```ts ignore import { defineBlobStore, resolveBlobRange } from '@tanstack/ai-persistence' import type { BlobObject, BlobRecord } from '@tanstack/ai-persistence' // R2 multipart parts must be ≥ 5 MiB and — except for the last — all exactly // the SAME size, so a part reader has to cut on an exact boundary and carry the // remainder. 8 MiB × the 10,000-part ceiling puts the multipart path's limit at // ~80 GiB; raise this for larger objects, and check R2's current object-size // limits before promising more. const MULTIPART_PART_SIZE = 8 * 1024 * 1024 /** * Cut exactly `limit` bytes off the stream (fewer only at EOF), carrying any * overshoot into the next part. * * `carry` is the leftover from the previous call. Chunks are collected by * reference and copied once per part: growing a `Uint8Array` chunk-by-chunk * instead would re-copy the whole part on every read — quadratic work for a * part built from hundreds of small chunks. */ async function readPart( reader: ReadableStreamDefaultReader<Uint8Array>, limit: number, carry: Uint8Array, ): Promise<{ bytes: Uint8Array; carry: Uint8Array; eof: boolean }> { const chunks: Array<Uint8Array> = carry.byteLength > 0 ? [carry] : [] let total = carry.byteLength let eof = false while (total < limit) { const { value, done } = await reader.read() if (done) { eof = true break } chunks.push(value) total += value.byteLength } const joined = new Uint8Array(total) let offset = 0 for (const chunk of chunks) { joined.set(chunk, offset) offset += chunk.byteLength } // Equal-sized parts: hand back exactly `limit` and keep the rest for next // time. At EOF the last part is whatever is left, which R2 allows. if (!eof && total > limit) { return { bytes: joined.subarray(0, limit), carry: joined.subarray(limit), eof, } } return { bytes: joined, carry: new Uint8Array(0), eof } } // R2 takes a single-shot put up to 5 GiB. Above that the upload has to be // multipart even when the length is known. const MAX_SINGLE_SHOT_BYTES = 5 * 1024 * 1024 * 1024 /** * `R2Bucket.put` requires a stream with a known length; a pass-through * TransformStream (what the middleware sends when it had to cap the body) has * none. Re-declare the length when `expectedLength` provides it — the * middleware only sets it when it is the exact decoded byte count — and * otherwise stream through a multipart upload, one buffered part at a time. */ async function putStream( bucket: R2Bucket, key: string, body: ReadableStream<Uint8Array>, options: R2PutOptions, expectedLength: number | undefined, ): Promise<R2Object | null> { if (expectedLength !== undefined && expectedLength <= MAX_SINGLE_SHOT_BYTES) { return bucket.put( key, body.pipeThrough(new FixedLengthStream(expectedLength)), options, ) } // Unknown length, or too big for one shot: multipart, one part at a time. const reader = body.getReader() const first = await readPart(reader, MULTIPART_PART_SIZE, new Uint8Array(0)) if (first.eof) { // The whole body fit in one part — a plain put is enough. return bucket.put(key, first.bytes, options) } const upload = await bucket.createMultipartUpload(key, options) try { const parts: Array<R2UploadedPart> = [ await upload.uploadPart(1, first.bytes), ] let carry = first.carry let partNumber = 2 for (;;) { const part = await readPart(reader, MULTIPART_PART_SIZE, carry) carry = part.carry if (part.bytes.byteLength > 0) { // 10,000 parts is R2's ceiling; failing here beats a confusing error // from `complete` after uploading gigabytes. if (partNumber > 10_000) { throw new Error( `Artifact at ${key} exceeds the multipart part limit — raise MULTIPART_PART_SIZE.`, ) } parts.push(await upload.uploadPart(partNumber, part.bytes)) partNumber += 1 } if (part.eof) break } return await upload.complete(parts) } catch (error) { await upload.abort().catch(() => undefined) throw error } } function toRecord(obj: R2Object): BlobRecord { const uploaded = obj.uploaded.getTime() return { key: obj.key, size: obj.size, etag: obj.etag, ...(obj.httpMetadata?.contentType ? { contentType: obj.httpMetadata.contentType } : {}), ...(obj.customMetadata ? { customMetadata: obj.customMetadata } : {}), createdAt: uploaded, updatedAt: uploaded, } } export function r2BlobStore(bucket: R2Bucket) { return defineBlobStore({ async put(key, body, options) { const r2Options = { ...(options?.contentType ? { httpMetadata: { contentType: options.contentType } } : {}), ...(options?.customMetadata ? { customMetadata: options.customMetadata } : {}), } const obj = body instanceof ReadableStream ? await putStream( bucket, key, body, r2Options, options?.expectedLength, ) : await bucket.put(key, body, r2Options) // R2.put returns null only when an onlyIf precondition fails — not used here. if (!obj) throw new Error(`R2 put failed for ${key}`) return toRecord(obj) }, async get(key, options): Promise<BlobObject | null> { if (!options?.range) { const whole = await bucket.get(key) if (!whole) return null return { ...toRecord(whole), body: whole.body, arrayBuffer: () => whole.arrayBuffer(), text: () => whole.text(), } } // A ranged read is what a serve route turns into `206` — R2 slices in // the bucket, so a seek never streams the whole object. `resolveBlobRange` // clamps a too-long length and throws on an offset past the end (the // route answers `416` from `record.size` before ever calling in). The // `head` buys that clamp deterministically — one class-B op against // bytes you did not need to send. // // Retry a bounded number of times: each attempt measures the object and // reads a slice of THAT version, and only an overwrite landing between // the two calls costs another lap. for (let attempt = 0; attempt < 3; attempt++) { const head = await bucket.head(key) // A ranged read of a key that is not there is a miss, not a // whole-object read: falling through to an un-ranged `get` would // answer a `206` carrying the entire file. if (!head) return null const served = resolveBlobRange(head.size, options.range) const obj = await bucket.get(key, { range: { offset: served.offset, length: served.length }, // Tie the slice to the version `head` measured. Without this, a // `put` landing between the two calls yields a `Content-Range` // computed from one object and bytes from another. onlyIf: { etagMatches: head.etag }, }) // No body means the precondition failed — the object changed under us. if (!obj) return null if (!('body' in obj)) continue return { // `toRecord(obj)` reports the WHOLE object's size even on a ranged // read — R2's `R2Object.size` is the object, not the slice — which // is exactly the contract, and what `Content-Range`'s total needs. ...toRecord(obj), range: served, body: obj.body, arrayBuffer: () => obj.arrayBuffer(), text: () => obj.text(), } } throw new Error(`R2 object ${key} changed under every ranged read.`) }, async head(key) { const obj = await bucket.head(key) return obj ? toRecord(obj) : null }, async delete(key) { await bucket.delete(key) }, async list(options) { // R2 reads `limit: 0` as "use the default", so short-circuit it. if (options?.limit === 0) { return { objects: [] } } const page = await bucket.list({ ...(options?.prefix !== undefined ? { prefix: options.prefix } : {}), ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), ...(options?.limit !== undefined ? { limit: options.limit } : {}), // R2 omits httpMetadata/customMetadata from list rows unless asked. include: ['httpMetadata', 'customMetadata'], }) return { objects: page.objects.map(toRecord), ...(page.truncated ? { cursor: page.cursor, truncated: true } : {}), } }, }) } ``` Invariants that matter (asserted by the conformance testkit): - `get` / `head` return `null` for a missing key; `delete` is a silent no-op. - `put` **overwrites** an existing key. - `put` accepts a `ReadableStream` body with **no declared length** — the middleware streams URL-fetched artifacts as exactly that. This is where the naive "pass the body straight to `bucket.put`" recipe fails at runtime (workerd requires a known length), which is what `putStream` above handles. - `get` honours `options.range`: it returns **only** that slice, reports it as `range`, and keeps `size` on the whole object. That is the `206` a video player's seeking depends on, and R2 slices in the bucket so the bytes never cross the Worker. - `list` filters by `prefix` literally (R2 prefix is a literal byte prefix — no glob), returns keys in ascending order, and pages via the opaque `cursor` when `truncated`. R2's own cursor is opaque and satisfies this directly. `limit: 0` must yield an empty, untruncated page — R2 treats `limit: 0` as "use the default", so special-case it: `if (options?.limit === 0) return { objects: [] }`. ## 2. ArtifactStore backed by D1 `ArtifactRecord` is `{ artifactId, runId, threadId, blobKey?, name, mimeType, size, sourceUrl?, createdAt }` (`createdAt` epoch ms). One flat table is keyed by `artifact_id`. It has run and thread ordered indexes for `list` and `listForThread`. ```sql CREATE TABLE IF NOT EXISTS generation_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 INDEX IF NOT EXISTS generation_artifacts_run_order ON generation_artifacts (run_id, created_at, artifact_id); CREATE INDEX IF NOT EXISTS generation_artifacts_thread_order ON generation_artifacts (thread_id, created_at, artifact_id); ``` ```ts ignore import { defineArtifactStore } from '@tanstack/ai-persistence' import type { ArtifactRecord } from '@tanstack/ai-persistence' interface ArtifactRow { artifact_id: string run_id: string thread_id: string blob_key: string | null name: string mime_type: string size: number source_url: string | null created_at: number } function fromRow(row: ArtifactRow): ArtifactRecord { return { artifactId: row.artifact_id, runId: row.run_id, threadId: row.thread_id, ...(row.blob_key != null ? { blobKey: row.blob_key } : {}), name: row.name, mimeType: row.mime_type, size: row.size, ...(row.source_url != null ? { sourceUrl: row.source_url } : {}), createdAt: row.created_at, } } export function d1ArtifactStore(db: D1Database) { return defineArtifactStore({ async save(record) { // Insert or overwrite (artifact ids are unique). await db .prepare( `INSERT INTO generation_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`, ) .bind( record.artifactId, record.runId, record.threadId, record.blobKey ?? null, record.name, record.mimeType, record.size, record.sourceUrl ?? null, record.createdAt, ) .run() }, async get(artifactId) { const row = await db .prepare(`SELECT * FROM generation_artifacts WHERE artifact_id = ?`) .bind(artifactId) .first<ArtifactRow>() return row ? fromRow(row) : null }, async list(runId) { const { results } = await db .prepare( `SELECT * FROM generation_artifacts WHERE run_id = ? ORDER BY created_at ASC, artifact_id ASC`, ) .bind(runId) .all<ArtifactRow>() return results.map(fromRow) }, async listForThread(threadId) { const { results } = await db .prepare( `SELECT * FROM generation_artifacts WHERE thread_id = ? ORDER BY created_at ASC, artifact_id ASC`, ) .bind(threadId) .all<ArtifactRow>() return results.map(fromRow) }, async delete(artifactId) { await db .prepare(`DELETE FROM generation_artifacts WHERE artifact_id = ?`) .bind(artifactId) .run() }, async deleteForRun(runId) { await db .prepare(`DELETE FROM generation_artifacts WHERE run_id = ?`) .bind(runId) .run() }, }) } ``` Omitting `source_url` / `blob_key` from the record when the column is `NULL` keeps records comparing cleanly against the reference in-memory store. Persist `blob_key` verbatim: a `storageKey` mapper can put the bytes anywhere, so a reader cannot recompute the path — `resolveArtifactBlobKey(record)` falls back to the default convention only for rows written before the column existed. Cloudflare KV is not an equivalent ArtifactStore backend. The required ordering and indexed reads need a transactional indexed database. Use D1 or another transactional indexed database for artifact metadata. Store the bytes in R2. ## 3. Compose and wire Bindings are per-request on Workers, so export a **factory**. Combine the byte stores with a generation-run store (and, if this Worker also does chat, the chat stores). Either build the whole `AIPersistence` with `defineAIPersistence`, or layer the artifact stores onto an existing chat persistence with `composePersistence`: ```ts ignore import { defineAIPersistence, composePersistence, withGenerationPersistence, } from '@tanstack/ai-persistence' import { r2BlobStore } from './r2-blob-store' import { d1ArtifactStore } from './d1-artifact-store' import { d1GenerationRunStore } from './d1-job-store' // your GenerationRunStore /** Call inside a request handler — bindings are not available at module scope. */ export function generationPersistence(env: Env) { return defineAIPersistence({ stores: { generationRuns: d1GenerationRunStore(env.DB), artifacts: d1ArtifactStore(env.DB), blobs: r2BlobStore(env.ARTIFACTS_BUCKET), }, }) } // …or add bytes to a persistence that already has chat + generation runs: // composePersistence(chatAndJobsPersistence(env), { // overrides: { // artifacts: d1ArtifactStore(env.DB), // blobs: r2BlobStore(env.ARTIFACTS_BUCKET), // }, // }) ``` `withGenerationPersistence` throws if exactly one of `artifacts` / `blobs` is present — provide both or neither. Wire it as generation middleware: ```ts ignore import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' import { withGenerationPersistence } from '@tanstack/ai-persistence' import { generationPersistence } from './lib/generation-persistence' export default { async fetch(request: Request, env: Env) { const { prompt, threadId } = await request.json() const stream = generateImage({ adapter: openaiImage('gpt-image-1'), prompt, threadId, // the slot recorded on the job + artifacts stream: true, middleware: [ // Nothing is buffered: the artifact streams from the provider CDN // into R2. Add `maxArtifactBytes: false` if you want no ceiling at // all on what an origin can stream into your bucket (the default is // 1 GiB); it is not needed for the streaming itself. withGenerationPersistence(generationPersistence(env), { threadId }), ], }) return toServerSentEventsResponse(stream) }, } ``` ## 4. Serve the bytes back 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 resolve the blob key from the record internally, so you never build the key yourself. Honour `Range` requests: `<video>` seeking is built on `206` / `Content-Range`, and Safari refuses to play a source that ignores `Range` entirely. Images never notice; a few-hundred-MB clip is unwatchable without it. Pass `range` to `retrieveBlob` — the store slices in R2 — rather than reaching into the bucket binding from the route, which would tie the route to R2 and bypass the store's own key resolution. ```ts ignore import { parseRangeHeader, retrieveArtifact, retrieveBlob, } from '@tanstack/ai-persistence' import { generationPersistence } from './lib/generation-persistence' export async function GET(request: Request, env: Env) { const artifactId = new URL(request.url).searchParams.get('id') ?? '' const persistence = generationPersistence(env) // Authorize before serving — derive the owner from the session, never trust // a client-supplied id. (The record carries runId/threadId to check against.) const record = await retrieveArtifact(persistence, artifactId) if (!record) return new Response('Not found', { status: 404 }) // `parseRangeHeader` resolves the header against the size on the record — // suffix ranges included — so an unsatisfiable range is a 416 here and the // store only ever sees a range it can serve. const range = parseRangeHeader(request.headers.get('range'), record.size) if (range === 'unsatisfiable') { return new Response('Range not satisfiable', { status: 416, headers: { 'content-range': `bytes */${record.size}` }, }) } // Pass the record, not the id: no second metadata lookup. const blob = await retrieveBlob( persistence, record, range ? { range } : undefined, ) if (!blob?.body) return new Response('Not found', { status: 404 }) // `accept-ranges` on every response, including the whole-file one: it is how // a player learns it may seek at all. const headers = { 'content-type': record.mimeType, 'accept-ranges': 'bytes', } if (!blob.range) { return new Response(blob.body, { headers: { ...headers, 'content-length': String(record.size) }, }) } const { offset, length } = blob.range return new Response(blob.body, { status: 206, headers: { ...headers, 'content-length': String(length), 'content-range': `bytes ${offset}-${offset + length - 1}/${record.size}`, }, }) } ``` To hydrate a **server-driven generation client** (`persistence: true` + a stable `threadId`) on mount, also expose `reconstructGeneration(persistence, request)` on a GET that reads `?threadId=` / `?runId=` — see `ai-core/client-persistence`. ## Other backends — the contract is tiny, here's how each maps `BlobStore` is five methods over an object store. Any of these backs it; swap the factory, keep everything else. `put` maps to the SDK's upload, `get` to a download that exposes `body`/`arrayBuffer`/`text`, `head` to a metadata fetch, `delete` to a delete, `list` to a prefixed, cursor-paged list. | Backend | npm | One-line sketch | | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **AWS S3** | `@aws-sdk/client-s3` | `put`→`PutObjectCommand`; `get`→`GetObjectCommand` (`Body` is a stream → `body`, `.transformToByteArray()`/`.transformToString()`); `head``HeadObjectCommand`; `delete``DeleteObjectCommand`; `list``ListObjectsV2Command` (`Prefix`, `ContinuationToken``cursor`, `MaxKeys``limit`, `IsTruncated``truncated`). | | **Google Cloud Storage** | `@google-cloud/storage` | `bucket.file(key)`: `put``.save(body, { contentType, metadata })`; `get``.createReadStream()` for `body` + `.download()` for bytes; `head``.getMetadata()`; `delete``.delete({ ignoreNotFound: true })`; `list``bucket.getFiles({ prefix, maxResults, pageToken })`. | | **Vercel Blob** | `@vercel/blob` | `put``put(key, body, { access: 'public', contentType })`; `get``fetch(head(key).url)` (stream `res.body`); `head``head(key)` (returns `null`catch as absent); `delete``del(key)`; `list``list({ prefix, cursor, limit })` (`hasMore``truncated`). | | **Supabase Storage** | `@supabase/supabase-js` | `storage.from(bucket)`: `put`→`.upload(key, body, { contentType, upsert: true })`; `get`→`.download(key)` (returns a `Blob` → `body`/`arrayBuffer`/`text`); `head`→`.info(key)` or list-one; `delete`→`.remove([key])`; `list`→`.list(prefix, { limit })` (offset/limit paging synthesize a `cursor`). | | **Filesystem (dev only)** | `node:fs/promises` | Root each key under a dir: `put``mkdir(dirname, { recursive: true })` + `writeFile`; `get``createReadStream` for `body` + `readFile`; `head``stat` (`size`, `mtimeMs``updatedAt`); `delete``rm(path, { force: true })`; `list`recursive `readdir` filtered by prefix, sorted, sliced by `limit`, cursor = last key. Not for production — no concurrency guarantees. | For each: `contentType` and `customMetadata` ride the SDK's own metadata fields; `BlobRecord.createdAt`/`updatedAt` come from the object's stored timestamps (epoch ms); return `null` from `get`/`head` on a not-found rather than throwing. ## Verify The shared `runPersistenceConformance` testkit covers all seven stores, including `generationRuns`, `artifacts`, and `blobs` — point it at your factory rather than hand-writing these assertions: ```ts ignore import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { env } from 'cloudflare:test' import { generationPersistence } from '../src/lib/generation-persistence' // A generation-only Worker declares the chat state stores it does not provide. runPersistenceConformance('app-r2', () => generationPersistence(env), { skip: ['messages', 'runs', 'interrupts', 'metadata'], }) ``` Run it against a Miniflare R2 + D1 binding with the migration applied, reset between runs (see **ai-persistence/build-cloudflare-adapter** for the `cloudflare:test` harness pattern). It exercises, among the rest: - `put` then `get` round-trips bytes and metadata; `get`/`head` return `null` for a missing key; `delete` is a silent no-op on an absent key. - `put` accepts a `ReadableStream` body with no declared length (a `TransformStream`-wrapped stream) and records the real drained size — the shape every URL-fetched artifact arrives in. - `get` with a `range` returns just that slice, reports it as `range`, and still reports the whole object's `size` — what a `206` / `Content-Range` response is built from. - `put` overwrites an existing key (and its `contentType`/`customMetadata`). - `list` filters by `prefix` **literally and case-sensitively**, returns ascending keys, pages through the `cursor` when `truncated` without gaps or repeats, and returns an empty untruncated page for `limit: 0`. - The `ArtifactStore`: `save` is insert-or-overwrite, `get` returns `null` when absent, `list(runId)` returns `[]` for an unknown run, `listForThread(threadId)` returns the complete ordered history, and `delete` / `deleteForRun` remove exactly the expected rows. - The `GenerationRunStore`: `createOrResume` idempotency, no-op `update` on an unknown id, and `findLatestForThread` returning the most recently started linked run (terminal ones included). An end-to-end check is the strongest signal: run `generateImage` through `withGenerationPersistence(generationPersistence(env), { threadId })`, then confirm the blob exists at `artifacts/<runId>/<artifactId>` and `retrieveBlob` streams it back.