From 985fe4d158cc777cd16a8612e70462498aaea7f8 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:58:45 +1000 Subject: [PATCH 1/3] fix(ai-persistence): stream artifacts to length-strict stores, serve byte ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap-enforcing TransformStream wrapper stripped the declared length off every URL-fetched artifact body, so workerd's `R2Bucket.put` rejected all of them with `TypeError: Provided readable stream must have a known length`. - Wrap only when the response does not already bound itself. A trustworthy `content-length` is checked against the cap up front and HTTP framing holds the origin to it, so those bodies now reach `BlobStore.put` exactly as `fetch` produced them — length intact, single-shot into R2, nothing buffered. Chunked and content-encoded replies still get the counter. - Add `BlobPutOptions.expectedLength`, the exact decoded length when the origin declared one, for SDKs that want the length as an argument (S3). Not forwarded on content-encoded replies, where it measures compressed bytes. - Fix `Number(null) === 0` reading an absent `content-length` as a declared length of 0. - `maxArtifactBytes`: default 100 MiB -> 1 GiB (it bounds transfer, not memory), and accept `false` to drop the ceiling entirely. - Add `BlobStore.get(key, { range })` + `BlobObject.range`, threaded through `retrieveBlob`, with `parseRangeHeader` / `resolveBlobRange` helpers. Video seeking is built on 206/Content-Range and Safari will not play a source that ignores Range. - Conformance: length-less stream puts (with and without the hint) and ranged reads, so a store that only handles byte bodies or ignores ranges fails the suite instead of failing on first real use. - Docs, Cloudflare + media-generation skills, and the ts-react-chat example (SQLite store slices with `substr`, serve route answers 206/416) updated to match. Closes #1030 --- .changeset/persistence-stream-length-hint.md | 19 + docs/persistence/build-your-own-adapter.md | 80 +++- docs/persistence/keep-generated-files.md | 140 ++++++- .../src/lib/sqlite-persistence.ts | 32 +- .../ts-react-chat/src/routes/api.artifacts.ts | 60 ++- .../build-cloudflare-artifact-store/SKILL.md | 220 +++++++++- packages/ai-persistence/src/blob-range.ts | 90 ++++ packages/ai-persistence/src/index.ts | 6 + packages/ai-persistence/src/memory.ts | 30 +- packages/ai-persistence/src/middleware.ts | 98 ++++- packages/ai-persistence/src/retrieve.ts | 15 +- .../ai-persistence/src/testkit/conformance.ts | 132 ++++++ packages/ai-persistence/src/types.ts | 67 ++- .../ai-persistence/tests/blob-range.test.ts | 79 ++++ .../tests/generation-artifacts.test.ts | 383 ++++++++++++++++++ .../skills/ai-core/media-generation/SKILL.md | 7 + 16 files changed, 1406 insertions(+), 52 deletions(-) create mode 100644 .changeset/persistence-stream-length-hint.md create mode 100644 packages/ai-persistence/src/blob-range.ts create mode 100644 packages/ai-persistence/tests/blob-range.test.ts diff --git a/.changeset/persistence-stream-length-hint.md b/.changeset/persistence-stream-length-hint.md new file mode 100644 index 000000000..36ea9aea2 --- /dev/null +++ b/.changeset/persistence-stream-length-hint.md @@ -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 for a zero-copy path onto R2, 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 `