@tanstack/ai-persistence
Version:
Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.
716 lines (643 loc) • 31.5 kB
Markdown
---
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 `/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 '/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 '/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** | `-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** | `-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** | `/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-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.