feat(cloudflare): optional cachedBinding for Hyperdrive split caching - #1662
Conversation
Adds an optional second Hyperdrive binding to the hyperdrive() adapter. When cachedBinding is set, anonymous read requests (no session, GET/HEAD) route through a caching-enabled config while authenticated requests and writes stay on the primary caching-disabled binding, preserving read-after-write consistency. The routing reuses the isAuthenticated/ isWrite signals the middleware already threads into createRequestScopedDb. Migrations and the cold-start singleton always use the primary binding; a missing cached binding falls back to the primary. Omitting cachedBinding leaves behavior unchanged.
🦋 Changeset detectedLatest commit: 0f769a9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 0f769a9 | Jun 29 2026, 10:09 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 0f769a9 | Jun 29 2026, 10:09 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 0f769a9 | Jun 29 2026, 10:09 PM |
Adds a Hyperdrive section to the Database Options page (the adapter was undocumented). Covers setup, the required caching-disabled primary binding, Smart Placement, the config options table, and the optional two-config split-caching pattern via cachedBinding for serving anonymous reads from a cache-enabled config while keeping read-after-write consistency for the admin and writes.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | cc61a85 | Jun 29 2026, 09:58 PM |
There was a problem hiding this comment.
Approach: This is the right change for the right problem. Cloudflare’s documented two-Hyperdrive split-cache pattern fits EmDash’s request-scoped adapter model, and the implementation is clean, additive, and backwards-compatible. The routing rule is extracted into a small pure function with focused unit tests.
What I checked: the diff, full adapter files, the virtual-module contract, the middleware signal semantics for isAuthenticated/isWrite, the setup API flow, and the existing D1 session adapter for comparison.
Headline: Clean feature, but the routing signal is too narrow for the documented guarantee. The PR says “every authenticated request and every write stays on the primary binding,” yet isAuthenticated is computed as !!sessionUser in core middleware, so API-token Authorization requests are treated as anonymous and routed to cachedBinding. Internal anonymous GETs such as GET /_emdash/api/setup/status are also routed to the cache, even though the PR background explicitly calls setup a read-after-write path. These need fixing or at least clear caveats before merge.
The cachedBinding routing rule (!isAuthenticated && !isWrite -> cached) was too coarse: the post-setup status check (GET /_emdash/api/setup/status) and the admin setup-gate are anonymous GETs issued before any session exists, yet they read the emdash:setup_complete row written moments earlier on the primary. Routing them to the cache-enabled binding served the stale pre-setup value, looping the setup wizard. Gate the cached binding on the request being a public (non-/_emdash) path in addition to anonymous + read-only, so admin/setup/auth/internal-API reads always use the uncached primary even when unauthenticated. selectBindingName now takes the request url. Adds routing tests for /_emdash paths and updates docs + JSDoc.
… routing API tokens (ec_pat_*) and OAuth tokens (ec_oat_*) authenticate via the Authorization header, not the astro-session cookie, so isAuthenticated (!!sessionUser) was false for them. Request-scoped adapters then treated them as anonymous: D1 routed them to a read replica and Hyperdrive's split caching would route them to the cache-enabled binding -- both breaking read-your-writes for API clients. The auth middleware that resolves the token runs after the main middleware, so locals.user isn't populated yet; detect the Bearer credential directly on the request and OR it into isAuthenticated. Errs toward the primary/uncached connection (the safe direction). Addresses emdashbot review feedback on #1662.
|
Thanks @emdashbot — both points addressed. The review landed on commit 1. Internal anonymous GETs ( 2. Docs/JSDoc updated to describe the actual routing rule, and the deployed split-caching test site verifies public reads still serve from the cached binding while admin/setup/auth stay consistent. |
| <Aside> | ||
| The `/_emdash` carve-out matters: the setup wizard and login-state checks are | ||
| anonymous `GET`s that read a row written moments earlier. Caching them would | ||
| loop the setup wizard or show stale auth state, so they always use the | ||
| uncached primary. | ||
| </Aside> | ||
|
|
||
| <Aside type="caution"> | ||
| Both configurations must point at the **same** database, or anonymous visitors see a different dataset than editors. Anonymous reads of just-published content can be up to the cache's `max_age` stale (Hyperdrive default 60s, max 1h), and this cache is independent of EmDash's own cache invalidation. Only opt in if a short public-read staleness window is acceptable; otherwise omit `cachedBinding` and keep caching disabled. | ||
| </Aside> | ||
|
|
||
| <Aside> | ||
| **Sandboxed plugins are D1-only.** The sandbox plugin bridge talks to a D1 binding directly, independent of the configured adapter, so sandboxed plugins aren't available on a Hyperdrive deployment. | ||
| </Aside> |
There was a problem hiding this comment.
Not a blocker, but having multiple Asides next to each other is harder to read, and defeats the purpose of calling-out important points.
…emdash-cms#1662) * feat(cloudflare): optional cachedBinding for Hyperdrive split caching Adds an optional second Hyperdrive binding to the hyperdrive() adapter. When cachedBinding is set, anonymous read requests (no session, GET/HEAD) route through a caching-enabled config while authenticated requests and writes stay on the primary caching-disabled binding, preserving read-after-write consistency. The routing reuses the isAuthenticated/ isWrite signals the middleware already threads into createRequestScopedDb. Migrations and the cold-start singleton always use the primary binding; a missing cached binding falls back to the primary. Omitting cachedBinding leaves behavior unchanged. * docs: document Hyperdrive adapter and cachedBinding split caching Adds a Hyperdrive section to the Database Options page (the adapter was undocumented). Covers setup, the required caching-disabled primary binding, Smart Placement, the config options table, and the optional two-config split-caching pattern via cachedBinding for serving anonymous reads from a cache-enabled config while keeping read-after-write consistency for the admin and writes. * fix(cloudflare): keep /_emdash reads on the uncached Hyperdrive binding The cachedBinding routing rule (!isAuthenticated && !isWrite -> cached) was too coarse: the post-setup status check (GET /_emdash/api/setup/status) and the admin setup-gate are anonymous GETs issued before any session exists, yet they read the emdash:setup_complete row written moments earlier on the primary. Routing them to the cache-enabled binding served the stale pre-setup value, looping the setup wizard. Gate the cached binding on the request being a public (non-/_emdash) path in addition to anonymous + read-only, so admin/setup/auth/internal-API reads always use the uncached primary even when unauthenticated. selectBindingName now takes the request url. Adds routing tests for /_emdash paths and updates docs + JSDoc. * fix(core): treat Bearer-token requests as authenticated for scoped-db routing API tokens (ec_pat_*) and OAuth tokens (ec_oat_*) authenticate via the Authorization header, not the astro-session cookie, so isAuthenticated (!!sessionUser) was false for them. Request-scoped adapters then treated them as anonymous: D1 routed them to a read replica and Hyperdrive's split caching would route them to the cache-enabled binding -- both breaking read-your-writes for API clients. The auth middleware that resolves the token runs after the main middleware, so locals.user isn't populated yet; detect the Bearer credential directly on the request and OR it into isAuthenticated. Errs toward the primary/uncached connection (the safe direction). Addresses emdashbot review feedback on emdash-cms#1662.
…emdash-cms#1662) * feat(cloudflare): optional cachedBinding for Hyperdrive split caching Adds an optional second Hyperdrive binding to the hyperdrive() adapter. When cachedBinding is set, anonymous read requests (no session, GET/HEAD) route through a caching-enabled config while authenticated requests and writes stay on the primary caching-disabled binding, preserving read-after-write consistency. The routing reuses the isAuthenticated/ isWrite signals the middleware already threads into createRequestScopedDb. Migrations and the cold-start singleton always use the primary binding; a missing cached binding falls back to the primary. Omitting cachedBinding leaves behavior unchanged. * docs: document Hyperdrive adapter and cachedBinding split caching Adds a Hyperdrive section to the Database Options page (the adapter was undocumented). Covers setup, the required caching-disabled primary binding, Smart Placement, the config options table, and the optional two-config split-caching pattern via cachedBinding for serving anonymous reads from a cache-enabled config while keeping read-after-write consistency for the admin and writes. * fix(cloudflare): keep /_emdash reads on the uncached Hyperdrive binding The cachedBinding routing rule (!isAuthenticated && !isWrite -> cached) was too coarse: the post-setup status check (GET /_emdash/api/setup/status) and the admin setup-gate are anonymous GETs issued before any session exists, yet they read the emdash:setup_complete row written moments earlier on the primary. Routing them to the cache-enabled binding served the stale pre-setup value, looping the setup wizard. Gate the cached binding on the request being a public (non-/_emdash) path in addition to anonymous + read-only, so admin/setup/auth/internal-API reads always use the uncached primary even when unauthenticated. selectBindingName now takes the request url. Adds routing tests for /_emdash paths and updates docs + JSDoc. * fix(core): treat Bearer-token requests as authenticated for scoped-db routing API tokens (ec_pat_*) and OAuth tokens (ec_oat_*) authenticate via the Authorization header, not the astro-session cookie, so isAuthenticated (!!sessionUser) was false for them. Request-scoped adapters then treated them as anonymous: D1 routed them to a read replica and Hyperdrive's split caching would route them to the cache-enabled binding -- both breaking read-your-writes for API clients. The auth middleware that resolves the token runs after the main middleware, so locals.user isn't populated yet; detect the Bearer credential directly on the request and OR it into isAuthenticated. Errs toward the primary/uncached connection (the safe direction). Addresses emdashbot review feedback on emdash-cms#1662.
What does this PR do?
Adds an optional
cachedBindingto thehyperdrive()database adapter so EmDash can serve anonymous reads from a caching-enabled Hyperdrive configuration while keeping every authenticated request and every write on the primary, caching-disabled binding.Background
PR #1614 shipped the Hyperdrive adapter and (correctly) requires Hyperdrive query caching to be disabled, because EmDash relies on read-after-write consistency: the admin/setup write a row and immediately read it back, and a default-on query cache can serve the pre-write result within its TTL (corrupting setup, showing editors stale content).
Cloudflare documents a two-configuration pattern — one cached connection for popular reads, one uncached — for exactly this situation. This PR makes that pattern a first-class opt-in for the adapter.
How it works
The middleware already threads
isAuthenticatedandisWriteintocreateRequestScopedDb(added in #1614 for D1 replica routing). This PR uses those signals: a request routes tocachedBindingonly when it is anonymous and read-only (!isAuthenticated && !isWrite); everything else uses the primarybinding. So:cachedBindingthat's missing at runtime → safe fallback to the primary binding.The routing rule is a small pure function (
selectBindingName) so it's unit-testable without a live DB orcloudflare:workers.Fully backwards-compatible: omit
cachedBindingand the adapter behaves exactly as before — the descriptor doesn't even carry the key.Caveats (documented in the JSDoc)
Anonymous reads of just-published content can be up to the cache's
max_agestale (Hyperdrive default 60s, max 1h), and this cache is independent of EmDash's own cache invalidation. It's strictly opt-in for sites where a short public-read staleness window is acceptable.Type of change
Checklist
pnpm typecheckpasses —@emdash-cms/cloudflarecleanpnpm lintpasses —oxlint --type-aware --deny-warnings: 0 warnings, 0 errorspnpm testpasses (or targeted tests for my change) — 200 cloudflare tests pass (9 new)pnpm formathas been runselectBindingNamerouting tests + mockedcreateRequestScopedDbbinding-selection tests + config-time descriptor tests@emdash-cms/cloudflareminorAI-generated code disclosure
Screenshots / test output
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/hyperdrive-cached-binding. Updated automatically when the playground redeploys.