Skip to content

fix(ai-persistence): stream artifacts to length-strict stores, serve byte ranges - #1033

Merged
AlemTuzlak merged 3 commits into
mainfrom
1030-ai-persistence-capbodysize-strips-the-bodys-known-length-so-artifacts-cant-be-persisted-to-cloudflare-r2
Jul 31, 2026
Merged

fix(ai-persistence): stream artifacts to length-strict stores, serve byte ranges#1033
AlemTuzlak merged 3 commits into
mainfrom
1030-ai-persistence-capbodysize-strips-the-bodys-known-length-so-artifacts-cant-be-persisted-to-cloudflare-r2

Conversation

@tombeckenham

@tombeckenham tombeckenham commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🎯 Changes

Fixes #1030. 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.

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 again catches nothing and costs the declared length. Those responses (the normal provider-CDN case) now reach BlobStore.put exactly as fetch produced them, so bucket.put(key, body) single-shots them with nothing buffered.

Provider response Body handed to put
content-length, no content-encoding untouched, length intact
chunked (no declared length) counting wrapper
content-encoding: gzip (declared length is compressed) counting wrapper

Also in this PR:

  • BlobPutOptions.expectedLength — the exact decoded length when the origin declared one, for SDKs that want it as an argument (S3's ContentLength) or runtimes that can re-attach it (FixedLengthStream). Deliberately not forwarded on content-encoded replies, where it measures the compressed bytes.
  • Number(null) === 0 made an absent content-length read as a declared length of 0, leaving the early-reject unreachable for chunked replies.
  • maxArtifactBytes: default 100 MiB → 1 GiB, and it now accepts false. The cap is a drain-time counter, not a buffer — it bounds transfer, not memory — and 100 MiB silently failed generated video.
  • BlobStore.get(key, { range }) + BlobObject.range, threaded through retrieveBlob, with parseRangeHeader / resolveBlobRange helpers. Seeking a <video> is built on 206/Content-Range, and Safari refuses to play a source that ignores Range. Required of any store that holds bytes; the get signature stays source-compatible, so an existing custom store surfaces this as a conformance failure, not a type error.
  • 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.
  • Regression test: a fake length-strict store that throws workerd's TypeError, driven end-to-end through generateImage + withGenerationPersistence, plus stream-identity assertions per response shape.
  • Docs + skills + example: keep-generated-files (nothing-is-buffered, serve-video-honour-Range), build-your-own-adapter, the Cloudflare artifact-store and media-generation skills, and ts-react-chat (SQLite store slices with substr; serve route answers 206/416).

Not included: no Playwright E2E for the 206 path — the e2e app deliberately does not depend on @tanstack/ai-persistence, so that would mean adding the dependency plus a byte-serving route. Happy to add it if wanted.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • New Features

    • Added byte-range reads for persisted blobs and video artifacts, including partial responses and range metadata.
    • Added streamed uploads with optional expected lengths, including length-less and multipart uploads.
    • Increased the default artifact download limit to 1 GiB, with an option to disable it.
    • Added handling for invalid or unsatisfiable range requests.
  • Documentation

    • Updated persistence, artifact-serving, media, and Cloudflare R2 guidance for range requests and streaming uploads.
  • Tests

    • Expanded coverage for range handling, streaming uploads, size limits, and response behavior.

…byte ranges

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
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd6a479f-a65f-4d32-b8ba-647317b5b1bd

📥 Commits

Reviewing files that changed from the base of the PR and between f8b4ca6 and 5e3a747.

📒 Files selected for processing (7)
  • .changeset/persistence-stream-length-hint.md
  • docs/persistence/build-your-own-adapter.md
  • docs/persistence/keep-generated-files.md
  • examples/ts-react-chat/src/lib/sqlite-persistence.ts
  • packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md
  • packages/ai-persistence/src/blob-range.ts
  • packages/ai-persistence/tests/blob-range.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • docs/persistence/keep-generated-files.md
  • .changeset/persistence-stream-length-hint.md
  • packages/ai-persistence/src/blob-range.ts
  • docs/persistence/build-your-own-adapter.md
  • examples/ts-react-chat/src/lib/sqlite-persistence.ts

📝 Walkthrough

Walkthrough

Blob persistence now supports advisory upload lengths and ranged reads. Artifact streaming preserves known decoded lengths, applies a 1 GiB default cap, and supports uncapped transfers. Memory, SQLite, R2 guidance, retrieval, routes, and conformance tests cover these behaviors.

Changes

Persistence streaming and ranged reads

Layer / File(s) Summary
Blob contracts and range helpers
packages/ai-persistence/src/types.ts, packages/ai-persistence/src/blob-range.ts, packages/ai-persistence/src/index.ts, packages/ai-persistence/tests/blob-range.test.ts, docs/persistence/build-your-own-adapter.md
Blob contracts define expectedLength, BlobRange, BlobGetOptions, and ranged BlobStore.get. Shared helpers parse and clamp byte ranges.
Length-aware artifact persistence
packages/ai-persistence/src/middleware.ts, packages/ai-persistence/src/testkit/conformance.ts, packages/ai-persistence/tests/generation-artifacts.test.ts, packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md, docs/persistence/keep-generated-files.md, .changeset/persistence-stream-length-hint.md
Artifact downloads preserve trustworthy decoded lengths, conditionally wrap streams for size limits, and forward expectedLength. R2 guidance and tests cover fixed-length and multipart uploads.
Ranged storage and artifact serving
packages/ai-persistence/src/memory.ts, packages/ai-persistence/src/retrieve.ts, examples/ts-react-chat/src/lib/sqlite-persistence.ts, examples/ts-react-chat/src/routes/api.artifacts.ts, packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md, packages/ai/skills/ai-core/media-generation/SKILL.md
Blob stores return clamped slices with full-object metadata. Artifact routes parse ranges and return 206, 416, Content-Range, Content-Length, and Accept-Ranges as applicable.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ArtifactRoute
  participant parseRangeHeader
  participant retrieveBlob
  participant BlobStore
  ArtifactRoute->>parseRangeHeader: Parse Range header
  ArtifactRoute->>retrieveBlob: Request blob range
  retrieveBlob->>BlobStore: Read requested slice
  BlobStore-->>retrieveBlob: Return slice and total size
  retrieveBlob-->>ArtifactRoute: Return response metadata and body
Loading

Possibly related PRs

  • TanStack/ai#999: Modifies related @tanstack/ai-persistence blob retrieval and artifact-serving behavior.
  • TanStack/ai#1011: Extends the blob persistence contracts and retrieval paths introduced by this PR.

Suggested reviewers: alemtuzlak

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds a substantial byte-range retrieval and video-serving feature that is not required by linked issue #1030. Move the byte-range retrieval, HTTP 206/416 handling, and related example and documentation changes to a separate issue or pull request.
Docstring Coverage ⚠️ Warning Docstring coverage is 68.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: preserving stream lengths for strict stores and serving byte ranges.
Description check ✅ Passed The description follows the template, explains the changes, completes the checklist, and documents release impact with a changeset.
Linked Issues check ✅ Passed The changes address issue #1030 by preserving known lengths, adding expectedLength, fixing absent-length handling, updating the Cloudflare guidance, and adding conformance coverage.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 1030-ai-persistence-capbodysize-strips-the-bodys-known-length-so-artifacts-cant-be-persisted-to-cloudflare-r2

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

19 package(s) bumped directly, 32 bumped as dependents.

🟥 Major bumps

Package Version Reason
@tanstack/ai-angular 0.3.1 → 1.0.0 Changeset
@tanstack/ai-durable-stream 0.0.0 → 1.0.0 Changeset
@tanstack/ai-memory 0.0.0 → 1.0.0 Changeset
@tanstack/ai-openai 0.17.1 → 1.0.0 Changeset
@tanstack/ai-openrouter 0.15.10 → 1.0.0 Changeset
@tanstack/ai-persistence 0.0.0 → 1.0.0 Changeset
@tanstack/ai-preact 0.11.1 → 1.0.0 Changeset
@tanstack/ai-react 0.18.1 → 1.0.0 Changeset
@tanstack/ai-sandbox 0.2.4 → 1.0.0 Changeset
@tanstack/ai-solid 0.15.1 → 1.0.0 Changeset
@tanstack/ai-svelte 0.15.1 → 1.0.0 Changeset
@tanstack/ai-vue 0.15.1 → 1.0.0 Changeset
@tanstack/openai-base 0.9.9 → 1.0.0 Changeset
@tanstack/ai-acp 0.2.3 → 1.0.0 Dependent
@tanstack/ai-anthropic 0.16.3 → 1.0.0 Dependent
@tanstack/ai-bedrock 0.1.4 → 1.0.0 Dependent
@tanstack/ai-claude-code 0.2.3 → 1.0.0 Dependent
@tanstack/ai-code-mode 0.3.8 → 1.0.0 Dependent
@tanstack/ai-code-mode-skills 0.3.11 → 1.0.0 Dependent
@tanstack/ai-codex 0.2.3 → 1.0.0 Dependent
@tanstack/ai-elevenlabs 0.2.34 → 1.0.0 Dependent
@tanstack/ai-fal 0.9.12 → 1.0.0 Dependent
@tanstack/ai-gemini 0.20.1 → 1.0.0 Dependent
@tanstack/ai-grok 0.14.9 → 1.0.0 Dependent
@tanstack/ai-grok-build 0.2.3 → 1.0.0 Dependent
@tanstack/ai-groq 0.5.3 → 1.0.0 Dependent
@tanstack/ai-isolate-node 0.1.47 → 1.0.0 Dependent
@tanstack/ai-isolate-quickjs 0.1.47 → 1.0.0 Dependent
@tanstack/ai-mistral 0.2.3 → 1.0.0 Dependent
@tanstack/ai-ollama 0.8.16 → 1.0.0 Dependent
@tanstack/ai-opencode 0.2.3 → 1.0.0 Dependent
@tanstack/ai-react-ui 0.8.15 → 1.0.0 Dependent
@tanstack/ai-sandbox-cloudflare 0.2.4 → 1.0.0 Dependent
@tanstack/ai-sandbox-daytona 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-docker 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-local-process 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-sprites 0.2.1 → 1.0.0 Dependent
@tanstack/ai-sandbox-vercel 0.2.0 → 1.0.0 Dependent
@tanstack/ai-solid-ui 0.7.14 → 1.0.0 Dependent

🟨 Minor bumps

Package Version Reason
@tanstack/ai 0.42.0 → 0.43.0 Changeset
@tanstack/ai-client 0.22.1 → 0.23.0 Changeset
@tanstack/ai-devtools-core 0.4.24 → 0.5.0 Changeset
@tanstack/ai-event-client 0.6.8 → 0.7.0 Changeset
@tanstack/ai-utils 0.3.1 → 0.4.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/ai-mcp 0.2.5 → 0.2.6 Changeset
@tanstack/ai-isolate-cloudflare 0.2.38 → 0.2.39 Dependent
@tanstack/ai-vue-ui 0.2.34 → 0.2.35 Dependent
@tanstack/preact-ai-devtools 0.1.67 → 0.1.68 Dependent
@tanstack/react-ai-devtools 0.2.67 → 0.2.68 Dependent
@tanstack/solid-ai-devtools 0.2.67 → 0.2.68 Dependent
ag-ui 0.0.2 → 0.0.3 Dependent

@nx-cloud

nx-cloud Bot commented Jul 31, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit f8b4ca6

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 4s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-31 09:52:01 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai@1033

@tanstack/ai-acp

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-acp@1033

@tanstack/ai-angular

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-angular@1033

@tanstack/ai-anthropic

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-anthropic@1033

@tanstack/ai-bedrock

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-bedrock@1033

@tanstack/ai-claude-code

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-claude-code@1033

@tanstack/ai-client

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-client@1033

@tanstack/ai-code-mode

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-code-mode@1033

@tanstack/ai-code-mode-skills

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-code-mode-skills@1033

@tanstack/ai-codex

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-codex@1033

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-devtools-core@1033

@tanstack/ai-durable-stream

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-durable-stream@1033

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-elevenlabs@1033

@tanstack/ai-event-client

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-event-client@1033

@tanstack/ai-fal

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-fal@1033

@tanstack/ai-gemini

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-gemini@1033

@tanstack/ai-grok

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-grok@1033

@tanstack/ai-grok-build

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-grok-build@1033

@tanstack/ai-groq

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-groq@1033

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-isolate-cloudflare@1033

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-isolate-node@1033

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-isolate-quickjs@1033

@tanstack/ai-mcp

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-mcp@1033

@tanstack/ai-memory

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-memory@1033

@tanstack/ai-mistral

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-mistral@1033

@tanstack/ai-ollama

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-ollama@1033

@tanstack/ai-openai

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-openai@1033

@tanstack/ai-opencode

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-opencode@1033

@tanstack/ai-openrouter

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-openrouter@1033

@tanstack/ai-persistence

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-persistence@1033

@tanstack/ai-preact

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-preact@1033

@tanstack/ai-react

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-react@1033

@tanstack/ai-react-ui

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-react-ui@1033

@tanstack/ai-sandbox

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox@1033

@tanstack/ai-sandbox-cloudflare

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-cloudflare@1033

@tanstack/ai-sandbox-daytona

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-daytona@1033

@tanstack/ai-sandbox-docker

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-docker@1033

@tanstack/ai-sandbox-local-process

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-local-process@1033

@tanstack/ai-sandbox-sprites

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-sprites@1033

@tanstack/ai-sandbox-vercel

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-vercel@1033

@tanstack/ai-solid

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-solid@1033

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-solid-ui@1033

@tanstack/ai-svelte

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-svelte@1033

@tanstack/ai-utils

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-utils@1033

@tanstack/ai-vue

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-vue@1033

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-vue-ui@1033

@tanstack/openai-base

npm i https://pkg.pr.new/TanStack/ai/@tanstack/openai-base@1033

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/TanStack/ai/@tanstack/preact-ai-devtools@1033

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/TanStack/ai/@tanstack/react-ai-devtools@1033

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/TanStack/ai/@tanstack/solid-ai-devtools@1033

commit: 5e3a747

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/ai-persistence/src/middleware.ts (1)

934-965: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate the declared length as a non-negative integer before you trust it.

Number(contentLength) accepts values that are finite but not a valid byte count. A negative or fractional content-length passes both guards here:

  • The early reject on Line 941 does not fire, because declaredLength > maxBytes is false.
  • decodedLengthIsKnown becomes true, so Line 983 skips capBodySize and Line 1091 forwards the value as expectedLength.

Two consequences follow. The transfer cap has no enforcement left for that response. The store receives an expectedLength that the doc comment on BlobPutOptions.expectedLength forbids: a wrong value fails the write on a runtime that enforces declared lengths.

A conforming network stack rejects a malformed content-length during framing, so the exposure is mainly a non-conforming intermediary or an injected artifactFetch. The check is one predicate, so tighten it here rather than relying on the transport.

🛡️ Proposed fix
     const contentLength = response.headers.get('content-length')
-    const declaredLength =
-      contentLength === null ? undefined : Number(contentLength)
+    // A byte count, or nothing: a value that is not a non-negative integer is
+    // not a length. Treating it as unknown keeps the cap enforced and keeps a
+    // bad `expectedLength` off the store.
+    const parsedLength =
+      contentLength === null ? undefined : Number(contentLength)
+    const declaredLength =
+      parsedLength !== undefined &&
+      Number.isSafeInteger(parsedLength) &&
+      parsedLength >= 0
+        ? parsedLength
+        : undefined
     if (
       maxBytes !== false &&
       declaredLength !== undefined &&
-      Number.isFinite(declaredLength) &&
       declaredLength > maxBytes
     ) {

Then drop the now-redundant finiteness check from decodedLengthIsKnown:

     const encoding = response.headers.get('content-encoding')
     const decodedLengthIsKnown =
       declaredLength !== undefined &&
-      Number.isFinite(declaredLength) &&
       (encoding === null || encoding === 'identity')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence/src/middleware.ts` around lines 934 - 965, Validate
declaredLength as a finite, non-negative integer immediately after parsing
contentLength, and treat invalid values as undefined rather than trusting them.
Update both the maxBytes rejection and decodedLengthIsKnown logic around
declaredLength so only valid byte counts can bypass capBodySize or be forwarded
as expectedLength; remove the redundant finiteness check from
decodedLengthIsKnown.
🧹 Nitpick comments (2)
packages/ai-persistence/src/memory.ts (1)

388-392: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Both blob stores throw synchronously from a Promise-returning get. resolveBlobRange throws a RangeError for an offset outside the object. Both get implementations are non-async, so the throw escapes before a promise exists, and a caller that uses .catch(handler) on the returned value does not catch it. retrieveBlob awaits the call, so the current route path is unaffected; the risk is for any other caller and for adapter authors who copy these implementations.

  • packages/ai-persistence/src/memory.ts#L388-L392: mark get as async and return the value directly instead of wrapping it in Promise.resolve.
  • examples/ts-react-chat/src/lib/sqlite-persistence.ts#L822-L822: mark get as async for the same reason, so the resolveBlobRange throw becomes a rejection.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence/src/memory.ts` around lines 388 - 392, Both BlobStore
get implementations must convert range-validation throws into promise
rejections. In packages/ai-persistence/src/memory.ts lines 388-392, mark get as
async and return the blob object or null directly instead of using
Promise.resolve; make the same async-only change to get in
examples/ts-react-chat/src/lib/sqlite-persistence.ts line 822, preserving
existing retrieval behavior.
packages/ai-persistence/tests/blob-range.test.ts (1)

1-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Place this unit test beside blob-range.ts.

The file is packages/ai-persistence/tests/blob-range.test.ts, but the source is packages/ai-persistence/src/blob-range.ts. Move the test to packages/ai-persistence/src/blob-range.test.ts and change Line [2] from ../src/blob-range to ./blob-range.

As per coding guidelines, “Place unit tests in *.test.ts files alongside the source they cover.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-persistence/tests/blob-range.test.ts` around lines 1 - 79, Move
the blob-range unit test beside the implementation by relocating
blob-range.test.ts from the tests directory to the src directory, and update its
import of parseRangeHeader and resolveBlobRange from the parent src path to the
local ./blob-range path.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/persistence-stream-length-hint.md:
- Line 5: Update the changeset description to limit the zero-copy/direct
single-shot R2 upload claim to responses with trustworthy, declared, unencoded
lengths. Clarify that disabling maxArtifactBytes removes the application-level
size cap, while chunked or otherwise unknown-length streams still use multipart
buffering.

In `@docs/persistence/build-your-own-adapter.md`:
- Around line 1014-1028: Update the adapter’s async get method to avoid
materializing the full bytes BLOB for ranged requests: use a metadata-only
lookup to obtain the row and real byte length, resolve the range, then fetch
only the bounded slice with SQLite substr(bytes, ?, ?). Preserve the existing
full-blob path for requests without options.range and continue passing the
resolved range metadata to blobObject.

In `@examples/ts-react-chat/src/lib/sqlite-persistence.ts`:
- Around line 812-831: Update the ranged branch in get to avoid selectStmt’s
full body read: check options.range before loading the object, fetch metadata
with a body-excluding statement alongside rangeStmt, and use that row with
resolveBlobRange and mapBlobRecord. Adjust mapBlobRecord’s parameter type to
Omit<BlobRow, 'body'>, while preserving selectStmt for non-ranged reads.

In `@examples/ts-react-chat/src/routes/api.artifacts.ts`:
- Around line 64-72: The cache policy in the artifact response incorrectly marks
content as immutable while the same artifact ID can resolve to updated bytes.
Update the artifact URL/blob-key generation used by the route to include a
versioned key, or remove immutable caching from cacheHeaders; preserve
long-lived caching only when each URL uniquely identifies its byte content.

In
`@packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md`:
- Around line 83-87: Update the guidance around withGenerationPersistence and
maxArtifactBytes=false to respect R2 limits: route large known-length
expectedLength uploads away from single-shot bucket.put to multipart uploads,
retain fixed-part handling for unknown-length bodies while enforcing the 5 TiB
and 10,000-part ceilings, and keep the maxArtifactBytes cap when allowInputUrl
is enabled.
- Around line 79-80: Update the multipart assembly logic described in the skill
to collect input chunk references instead of repeatedly copying the accumulated
buffer. Track the current part size, allocate and concatenate once when a part
reaches 8 MiB, and retain any oversized chunk remainder for the next part so
memory remains bounded even when a single input chunk exceeds the limit.
- Around line 225-235: Update the range-read flow around resolveBlobRange and
bucket.get so head metadata and the fetched body come from the same object
version, using the storage API’s conditional or version-tied read mechanism.
When options.range is requested and bucket.head(key) returns no object, return
null immediately; do not fall back to an un-ranged bucket.get. Preserve
un-ranged reads for requests without a range.

In `@packages/ai-persistence/src/blob-range.ts`:
- Around line 77-81: Update the suffix-range branch in resolveBlobRange so any
nonzero suffix against a zero-byte object returns 'unsatisfiable' instead of an
offset of 0. Preserve the existing suffix === 0 handling and normal suffix
offset calculation for nonempty objects, aligning this path with the existing
start >= size guard.

---

Outside diff comments:
In `@packages/ai-persistence/src/middleware.ts`:
- Around line 934-965: Validate declaredLength as a finite, non-negative integer
immediately after parsing contentLength, and treat invalid values as undefined
rather than trusting them. Update both the maxBytes rejection and
decodedLengthIsKnown logic around declaredLength so only valid byte counts can
bypass capBodySize or be forwarded as expectedLength; remove the redundant
finiteness check from decodedLengthIsKnown.

---

Nitpick comments:
In `@packages/ai-persistence/src/memory.ts`:
- Around line 388-392: Both BlobStore get implementations must convert
range-validation throws into promise rejections. In
packages/ai-persistence/src/memory.ts lines 388-392, mark get as async and
return the blob object or null directly instead of using Promise.resolve; make
the same async-only change to get in
examples/ts-react-chat/src/lib/sqlite-persistence.ts line 822, preserving
existing retrieval behavior.

In `@packages/ai-persistence/tests/blob-range.test.ts`:
- Around line 1-79: Move the blob-range unit test beside the implementation by
relocating blob-range.test.ts from the tests directory to the src directory, and
update its import of parseRangeHeader and resolveBlobRange from the parent src
path to the local ./blob-range path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 82db271f-be93-4354-b537-9c5c108b8f68

📥 Commits

Reviewing files that changed from the base of the PR and between 1cb04d5 and 985fe4d.

📒 Files selected for processing (16)
  • .changeset/persistence-stream-length-hint.md
  • docs/persistence/build-your-own-adapter.md
  • docs/persistence/keep-generated-files.md
  • examples/ts-react-chat/src/lib/sqlite-persistence.ts
  • examples/ts-react-chat/src/routes/api.artifacts.ts
  • packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md
  • packages/ai-persistence/src/blob-range.ts
  • packages/ai-persistence/src/index.ts
  • packages/ai-persistence/src/memory.ts
  • packages/ai-persistence/src/middleware.ts
  • packages/ai-persistence/src/retrieve.ts
  • packages/ai-persistence/src/testkit/conformance.ts
  • packages/ai-persistence/src/types.ts
  • packages/ai-persistence/tests/blob-range.test.ts
  • packages/ai-persistence/tests/generation-artifacts.test.ts
  • packages/ai/skills/ai-core/media-generation/SKILL.md

Comment thread .changeset/persistence-stream-length-hint.md Outdated
Comment thread docs/persistence/build-your-own-adapter.md Outdated
Comment thread examples/ts-react-chat/src/lib/sqlite-persistence.ts Outdated
Comment thread examples/ts-react-chat/src/routes/api.artifacts.ts
Comment thread packages/ai-persistence/src/blob-range.ts
@tombeckenham
tombeckenham requested a review from AlemTuzlak July 31, 2026 09:40
RFC 9110 §14.1.1: `bytes=100-50` (last-byte-pos < first-byte-pos) is an
invalid spec, not an unsatisfiable one, so it must be ignored and the whole
representation served. `parseRangeHeader` returned 'unsatisfiable', which
would have failed a request that is supposed to succeed. Checked before
satisfiability so the object's size cannot turn an ignorable spec into a 416.
…age, R2 recipe

- `parseRangeHeader`: any range against a zero-byte object is unsatisfiable.
  The suffix branch resolved `bytes=-1` on an empty artifact to `{ offset: 0 }`,
  which then threw a RangeError out of the store instead of answering 416.
- SQLite store (example + docs reference impl): a ranged read ran
  `SELECT *` and then `substr`, so it loaded the whole object AND the slice.
  Metadata now comes from a projection without `body`; `head` too.
- R2 recipe: parts are cut on an exact boundary with the remainder carried,
  since R2 requires equal-sized parts, and chunks are joined once per part
  rather than re-copied per chunk. Single-shot is capped at 5 GiB, multipart
  fails fast at the 10,000-part ceiling.
- R2 ranged get: `head` and `get` are tied together with
  `onlyIf: { etagMatches }`, so an overwrite between them cannot pair one
  version's Content-Range with another's bytes; a ranged head miss returns
  null instead of falling through to a whole-object read.
- Drop the "no limit" claims: `maxArtifactBytes: false` removes the
  application ceiling, not the backend's.
@AlemTuzlak
AlemTuzlak merged commit bef85d2 into main Jul 31, 2026
10 checks passed
@AlemTuzlak
AlemTuzlak deleted the 1030-ai-persistence-capbodysize-strips-the-bodys-known-length-so-artifacts-cant-be-persisted-to-cloudflare-r2 branch July 31, 2026 10:14
AlemTuzlak added a commit that referenced this pull request Jul 31, 2026
Conflict: docs/persistence/build-your-own-adapter.md. This branch split that page
into build-your-own-adapter / build-your-own-chat-adapter /
build-your-own-generation-adapter / store-reference, while #1033 edited the
sections that moved.

Resolved by keeping the split and porting #1033's changes to their new homes:

- build-your-own-generation-adapter: the SQLite `BlobStore` walkthrough gains
  ranged reads (`resolveBlobRange`, metadata-only `selectMeta`, `substr`-based
  `selectSlice`, `blobObject`'s `range` argument, metadata-only `head`), plus a
  bullet pointing at the `get` contract.
- store-reference: `BlobObject.range`, `BlobPutOptions.expectedLength`,
  `BlobRange`, `BlobGetOptions`, the widened `BlobStore.get` signature, and the
  two new contract sections for `put` (drain a length-less stream) and `get`
  (honour `options.range`).
- keep-generated-files: repoint `#blobstore` at store-reference, and clear the
  em dashes the merged content brought in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ai-persistence: capBodySize strips the body's known length, so artifacts can't be persisted to Cloudflare R2

2 participants