Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/persistence-stream-length-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@tanstack/ai-persistence': minor
---

Streamed artifact bodies can now be persisted to length-strict blob stores (Cloudflare R2), `maxArtifactBytes` can be turned off, and `BlobStore.get` can serve byte ranges.

**The bug.** URL-fetched artifacts arrived at `BlobStore.put` as a `TransformStream`-wrapped body β€” the wrapper that enforces `maxArtifactBytes` as the body drains. A transform's readable side carries no declared length, so runtimes that require one for a single-shot upload (workerd's `R2Bucket.put`) rejected every URL-sourced artifact with `TypeError: Provided readable stream must have a known length`. Byte bodies never hit this, which is why the old conformance suite (byte bodies only) and any store that buffers were unaffected.

**The wrapper is now applied only when it is load-bearing.** A trustworthy `content-length` is checked against the cap up front, and HTTP framing holds the origin to it β€” a body cannot exceed a length it declared β€” so counting the bytes again adds nothing and costs the declared length. Those responses (the common case for a provider CDN) now reach `BlobStore.put` exactly as `fetch` produced them, length intact, so `R2Bucket.put` single-shots them with nothing buffered. The counter still wraps the two response shapes that genuinely need it: a chunked reply (no declared length at all) and a content-encoded one (whose declared length measures the compressed bytes, so the decoded stream can be a decompression bomb).

**`BlobPutOptions.expectedLength` (additive).** `withGenerationPersistence` now forwards the artifact's exact decoded byte length to `BlobStore.put` when it is known β€” the `content-length` of an un-encoded artifact response. It is deliberately _not_ forwarded when the response is content-encoded: `fetch` transparently decompresses, so a gzipped reply's `content-length` is the compressed size and the decoded stream can be arbitrarily longer. Stores may use the hint to attach a declared length (e.g. workerd's `FixedLengthStream`) and single-shot the stream, or fall back to multipart when it is absent. Also fixed in the same code: a missing `content-length` header read as a declared length of `0` (`Number(null) === 0`), which kept the early-reject unreachable for chunked replies.

**`BlobStore.get(key, { range })` (additive).** Serving a persisted video means answering HTTP `Range` requests: seeking a `<video>` is built on `206` / `Content-Range`, and Safari refuses to play a source that ignores `Range` entirely. `get` now takes a `BlobGetOptions` with a `range`, returns just that slice, and reports it as `BlobObject.range` while `size` keeps describing the whole object β€” the numbers a `206` needs. `retrieveBlob(persistence, artifact, { range })` passes it through, and two new helpers cover the fiddly halves: `parseRangeHeader(header, size)` resolves a `Range` header (suffix ranges, clamping, and the `416` case) for a serve route, and `resolveBlobRange(size, range)` does the clamping every byte-storing backend needs. Ranged reads are part of the contract for a store that holds bytes: the `get` signature stays source-compatible (the parameter is optional), so an existing custom store surfaces this as a conformance failure rather than a type error.

**`maxArtifactBytes: false`, and a 1 GiB default** (was 100 MiB). The cap is a drain-time counter, not a buffer β€” the URL path streams into the blob store and never holds an artifact in memory β€” so it bounds _transfer_, not memory, and 100 MiB was simply too low for generated video. Passing `false` drops the ceiling and the wrapper on every response, including chunked ones. Keep the cap when `allowInputUrl` lets callers name the URL, or when you want any ceiling at all on what a runaway origin can stream into your bucket (`content-length` is advisory, so uncapped is unbounded).

**Testkit.** `runPersistenceConformance` now exercises `blobs.put` with a length-less `TransformStream`-wrapped body β€” with and without `expectedLength` β€” and `blobs.get` with a byte range, so a store that only handles byte bodies or ignores ranges fails the suite instead of failing on first real use. Custom backends should re-run the suite.

**Skill fix.** `ai-persistence/build-cloudflare-artifact-store` no longer claims the body "flows straight through" to `R2Bucket.put`: its R2 `BlobStore.put` recipe now re-declares `expectedLength` via `FixedLengthStream` and falls back to a one-part-at-a-time multipart upload when the length is unknown, and its `get` maps `range` onto R2's own ranged read.
104 changes: 95 additions & 9 deletions docs/persistence/build-your-own-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,7 @@ media.

```ts
import { DatabaseSync } from 'node:sqlite'
import { defineBlobStore } from '@tanstack/ai-persistence'
import { defineBlobStore, resolveBlobRange } from '@tanstack/ai-persistence'
import type {
BlobBody,
BlobObject,
Expand Down Expand Up @@ -942,9 +942,15 @@ function mapBlobRecord(row: Record<string, unknown>): BlobRecord {
}
}

function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject {
function blobObject(
record: BlobRecord,
bytes: Uint8Array,
range?: { offset: number; length: number },
): BlobObject {
return {
...record,
// `size` keeps describing the whole object; `range` describes these bytes.
...(range ? { range } : {}),
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes.slice())
Expand Down Expand Up @@ -973,6 +979,16 @@ function createBlobStore(db: DatabaseSync) {
)
const selectCreated = db.prepare('SELECT created_at FROM blobs WHERE key = ?')
const selectOne = db.prepare('SELECT * FROM blobs WHERE key = ?')
// Metadata without the bytes, and the bounded slice: a ranged read must not
// load the whole object to hand back a piece of it.
const selectMeta = db.prepare(
`SELECT key, size, etag, content_type, custom_metadata_json,
created_at, updated_at
FROM blobs WHERE key = ?`,
)
const selectSlice = db.prepare(
'SELECT substr(bytes, ?, ?) AS bytes FROM blobs WHERE key = ?',
)
return defineBlobStore({
async put(key, body, options) {
const bytes = await toBytes(body)
Expand Down Expand Up @@ -1005,15 +1021,30 @@ function createBlobStore(db: DatabaseSync) {
: {}),
}
},
async get(key) {
const row = selectOne.get(key)
if (!row) return null
async get(key, options) {
if (!options?.range) {
const row = selectOne.get(key)
if (!row) return null
const bytes =
row.bytes instanceof Uint8Array ? row.bytes : new Uint8Array()
return blobObject(mapBlobRecord(row), bytes)
}
// Metadata first, WITHOUT the bytes, so the clamp costs no I/O...
const meta = selectMeta.get(key)
if (!meta) return null
const served = resolveBlobRange(Number(meta.size), options.range)
// ...then let SQLite cut the slice (`substr` is 1-based and byte-wise
// over a BLOB). Reading the row whole and slicing in JS would load the
// entire object on every video seek β€” the cost ranges exist to avoid.
const slice = selectSlice.get(served.offset + 1, served.length, key)
if (!slice) return null
const bytes =
row.bytes instanceof Uint8Array ? row.bytes : new Uint8Array()
return blobObject(mapBlobRecord(row), bytes)
slice.bytes instanceof Uint8Array ? slice.bytes : new Uint8Array()
return blobObject(mapBlobRecord(meta), bytes, served)
},
async head(key) {
const row = selectOne.get(key)
// Metadata only: never pull the bytes to answer a question about them.
const row = selectMeta.get(key)
return row ? mapBlobRecord(row) : null
},
async delete(key) {
Expand Down Expand Up @@ -1468,6 +1499,8 @@ interface BlobObject extends BlobRecord {
arrayBuffer(): Promise<ArrayBuffer>
text(): Promise<string>
body?: ReadableStream<Uint8Array>
// The slice served, when a range was requested. Absent on a whole read.
range?: { offset: number; length: number }
}

interface BlobListPage {
Expand All @@ -1479,6 +1512,19 @@ interface BlobListPage {
interface BlobPutOptions {
contentType?: string
customMetadata?: Record<string, string>
// Exact byte length of `body`, when the producer knows it. Advisory: use it
// to pick an upload strategy (single-shot vs multipart), never as a
// substitute for counting the bytes you actually store.
expectedLength?: number
}

interface BlobRange {
offset: number // from the start of the object; must be inside it
length?: number // defaults to "to the end"; clamped when it overshoots
}

interface BlobGetOptions {
range?: BlobRange
}

interface BlobListOptions {
Expand All @@ -1489,7 +1535,7 @@ interface BlobListOptions {

interface BlobStore {
put(key: string, body: BlobBody, options?: BlobPutOptions): Promise<BlobRecord>
get(key: string): Promise<BlobObject | null>
get(key: string, options?: BlobGetOptions): Promise<BlobObject | null>
head(key: string): Promise<BlobRecord | null>
delete(key: string): Promise<void>
list(options?: BlobListOptions): Promise<BlobListPage>
Expand All @@ -1505,6 +1551,46 @@ Three contracts to hold for `list`:
paging visits every key exactly once.
- `limit: 0` yields an empty, untruncated page.

And one for `put`: the body can be a `ReadableStream` with **no declared
length**. That is how a URL-fetched artifact arrives whenever the origin does
not declare one it can be held to β€” a chunked reply, or a compressed one whose
`content-length` describes the compressed bytes. (When the origin *does* declare
a usable length, the body reaches you exactly as `fetch` produced it, length
intact, and `expectedLength` carries the same number.) Your store must drain a
length-less stream, not require a length up front. Backends that need a declared
length for a single-shot upload (Cloudflare R2 on workerd is one) can re-attach
`expectedLength` when it is present and stream through a multipart upload when
it is not β€” the `ai-persistence/build-cloudflare-artifact-store` skill ships that
recipe. The conformance testkit exercises the length-less case, so a store that
only handles byte bodies fails the suite.

And one for `get`: honour `options.range` by returning **only that slice**.
`size` keeps reporting the whole object, and the returned `range` reports what
you actually served β€” together they are the `206` response a media player's
seeking depends on. `resolveBlobRange(size, range)` does the clamping (a
`length` past the end is legal and clamps; an `offset` past the end throws,
because a serve route should have answered `416` from `record.size` first):

```ts ignore
import { resolveBlobRange } from '@tanstack/ai-persistence'

async get(key: string, options?: BlobGetOptions) {
const row = await selectBlob(key)
if (!row) return null
if (!options?.range) return blobObject(row, row.body)
const served = resolveBlobRange(row.size, options.range)
// Slice at the storage layer, not after loading the whole object.
const bytes = await selectBlobSlice(key, served.offset, served.length)
return blobObject(row, bytes, served)
}
```

This is not optional for a store that holds bytes β€” the conformance testkit
asserts it. Ignoring `range` and returning the whole file is what makes
`<video>` seeking (and Safari playback at all) fail, and it silently sends the
entire artifact for every seek. A reference-only backend that stores no bytes
skips `blobs` altogether instead.

## Where to go next

- [Controls](./controls): compose stores from different systems.
Expand Down
142 changes: 141 additions & 1 deletion docs/persistence/keep-generated-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,83 @@ export async function GET(request: Request) {
}
```

### Serve video: honour `Range`

The route above is enough for images. Video is not: seeking a `<video>` is
built on HTTP range requests, and a source that answers every `Range` with the
whole file cannot be scrubbed β€” Safari refuses to play it at all, and every
other browser downloads the entire clip before it starts. Pass `range` to
`retrieveBlob` and answer `206`:

```ts group=generation-bytes
import { parseRangeHeader } from '@tanstack/ai-persistence'
import type { ArtifactRecord } from '@tanstack/ai-persistence'

// The same route, seek-aware. `parseRangeHeader` resolves the header against
// the size on the record β€” including suffix ranges (`bytes=-500` is the LAST
// 500 bytes) and the unsatisfiable case. `blob.range` then reports the slice
// actually served, and `artifact.size` the whole file: the two numbers
// `Content-Range` needs.
export async function serveArtifactBytes(
request: Request,
artifact: ArtifactRecord,
) {
const range = parseRangeHeader(request.headers.get('range'), artifact.size)
if (range === 'unsatisfiable') {
return new Response('range not satisfiable', {
status: 416,
headers: { 'content-range': `bytes */${artifact.size}` },
})
}

const blob = await retrieveBlob(
persistence,
artifact,
range ? { range } : undefined,
)
if (!blob) return new Response('not found', { status: 404 })

const body = blob.body ?? (await blob.arrayBuffer())
// `accept-ranges` on every response, including the whole-file one: it is how
// the player learns it may seek at all.
const headers = {
'content-type': artifact.mimeType,
'accept-ranges': 'bytes',
}
if (!blob.range) {
return new Response(body, {
headers: { ...headers, 'content-length': String(artifact.size) },
})
}
const { offset, length } = blob.range
return new Response(body, {
status: 206,
headers: {
...headers,
'content-length': String(length),
'content-range': `bytes ${offset}-${offset + length - 1}/${artifact.size}`,
},
})
}
```

The client side needs nothing special β€” that is the point. Given a route that
answers ranges, the browser drives the rest:

```tsx
import type { PersistedArtifactRef } from '@tanstack/ai'

export function GeneratedVideo({ artifact }: { artifact: PersistedArtifactRef }) {
// `artifact.url` is the app-origin URL `artifactUrl` stamped on the ref.
return <video src={artifact.url} controls preload="metadata" />
}
```

`preload="metadata"` has the player fetch the header bytes with a range request
and show the duration and scrubber without pulling the clip down. A store must
support ranged reads for any of this to work; the
[conformance testkit](./build-your-own-adapter#blobstore) asserts it.

`memoryPersistence` keeps everything in process memory, which is right for
development and tests; point `generationRuns` / `artifacts` / `blobs` at a durable backend
for production. Control what gets captured with `withGenerationPersistence`'s
Expand Down Expand Up @@ -204,7 +281,8 @@ Every artifact fetch, input or output, is bounded three ways:

- The scheme must be `http:` or `https:`.
- It is aborted after `artifactFetchTimeoutMs` (default 30s).
- It is capped at `maxArtifactBytes` (default 100 MiB) as the body drains.
- It is capped at `maxArtifactBytes` (default 1 GiB) as the body drains, or not
at all when you pass `false` β€” see [Nothing is buffered](#nothing-is-buffered).

Input fetches add two more: a loopback / private / link-local host block, and a
refusal to follow redirects, so a `302` cannot hop somewhere the check never
Expand All @@ -215,6 +293,68 @@ private address still passes a literal-IP check. Keep `allowInputUrl` narrow,
and for stronger isolation inject `artifactFetch` to route downloads through an
egress-restricted proxy that can check the address actually connected to.

## Nothing is buffered

A provider URL is **streamed** into the blob store: the middleware never holds
the artifact in memory, and `size` on the record is counted as the bytes drain.
A 2 GB video costs a streaming store (R2, S3, a filesystem) the same memory as
a 2 KB icon. `memoryPersistence` is the exception, and only because holding the
bytes in process *is* what it does β€” it is a dev/test store, not a production
one.

`maxArtifactBytes` is therefore a bound on **transfer**, not on memory. What it
buys is a ceiling on what a runaway or hostile origin can make you pull and
store: `content-length` is advisory, so an origin can declare 1 KB and send
forever. That is the only reason there is a default at all.

### The body reaches your store untouched when it can

Enforcing the cap during the drain means wrapping the body in a
`TransformStream` that counts β€” and a transform's readable side carries **no
declared length**, which is exactly what workerd's `R2Bucket.put` needs for a
single-shot upload. So the wrapper is applied only where it is load-bearing:

| The response | What your store gets |
| ------------------------------------------------------- | ------------------------------------------- |
| declares a `content-length`, no `content-encoding` | the `fetch` body **untouched**, length intact |
| chunked β€” no declared length | the counting wrapper |
| `content-encoding: gzip` (declared length is compressed) | the counting wrapper |

In the first row nothing is lost by skipping the counter: the declared length
was already checked against the cap, and HTTP framing holds the origin to it β€”
a body cannot exceed a length it declared. That is the common case for a
provider CDN, so on Cloudflare the default configuration already streams
straight through:

```ts ignore
// Inside your BlobStore.put on workerd β€” nothing buffered, no multipart, no
// hint needed. (`R2Bucket` comes from @cloudflare/workers-types.)
const putStraightToR2 = (bucket: R2Bucket, key: string, body: BlobBody) =>
bucket.put(key, body)
```

The other two rows genuinely need the counter β€” a chunked reply declares
nothing, and a compressed one can decode to arbitrarily more than it declared
(a decompression bomb). For those, `BlobPutOptions.expectedLength` is absent
too, and a length-strict store falls back to a multipart upload that buffers
one 8 MiB part at a time β€” flat memory, whatever the artifact's size. The
`ai-persistence/build-cloudflare-artifact-store` skill ships that recipe.

To drop the ceiling entirely β€” no counter on any response, no limit on what an
origin can stream into your bucket β€” pass `false`:

```ts group=generation-bytes
const uncappedOptions = withGenerationPersistence(persistence, {
maxArtifactBytes: false,
})
```

Reach for that when you trust the origins you fetch from and would rather have
no ceiling than a generous one. Your storage backend's own limits still apply β€”
R2, for instance, caps a single-shot upload at 5 GiB and a multipart one at
10,000 parts. Keep the cap when `allowInputUrl` lets callers name the URL:
there the origin is by definition not one you control.

## Wire the durable URL through to the client

`artifactUrl` is what makes the stored bytes reachable from the client without
Expand Down
Loading
Loading