Skip to content

feat(cloudflare): optional cachedBinding for Hyperdrive split caching - #1662

Merged
ascorbic merged 4 commits into
mainfrom
feat/hyperdrive-cached-binding
Jun 30, 2026
Merged

feat(cloudflare): optional cachedBinding for Hyperdrive split caching#1662
ascorbic merged 4 commits into
mainfrom
feat/hyperdrive-cached-binding

Conversation

@scottbuscemi

@scottbuscemi scottbuscemi commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds an optional cachedBinding to the hyperdrive() 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.

// astro.config.mjs
database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" });
// wrangler.jsonc — two configs over the same database
{
  "hyperdrive": [
    { "binding": "HYPERDRIVE", "id": "<caching-disabled-id>" },
    { "binding": "HYPERDRIVE_CACHED", "id": "<caching-enabled-id>" }
  ]
}

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 isAuthenticated and isWrite into createRequestScopedDb (added in #1614 for D1 replica routing). This PR uses those signals: a request routes to cachedBinding only when it is anonymous and read-only (!isAuthenticated && !isWrite); everything else uses the primary binding. So:

  • Logged-in editors → always uncached → read-after-write stays correct.
  • Writes (including anonymous writes, e.g. a public form plugin) → always uncached.
  • Migrations and the cold-start singleton → always the primary binding.
  • A cachedBinding that'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 or cloudflare:workers.

Fully backwards-compatible: omit cachedBinding and 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_age stale (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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — @emdash-cms/cloudflare clean
  • pnpm lint passes — oxlint --type-aware --deny-warnings: 0 warnings, 0 errors
  • pnpm test passes (or targeted tests for my change) — 200 cloudflare tests pass (9 new)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable) — pure selectBindingName routing tests + mocked createRequestScopedDb binding-selection tests + config-time descriptor tests
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a, no admin UI strings
  • I have added a changeset@emdash-cms/cloudflare minor
  • New features link to an approved Discussion — n/a, opened by a maintainer (maintainer approval stands in for the Discussion).

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (via OpenCode)

Screenshots / test output

@emdash-cms/cloudflare  Test Files  14 passed (14)   Tests  200 passed (200)
oxlint --type-aware --deny-warnings   Found 0 warnings and 0 errors.
tsgo --noEmit (packages/cloudflare)   clean

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.

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-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0f769a9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/cloudflare size/L labels Jun 29, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 0f769a9 Jun 29 2026, 10:09 PM

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1662

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1662

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1662

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1662

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1662

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1662

emdash

npm i https://pkg.pr.new/emdash@1662

create-emdash

npm i https://pkg.pr.new/create-emdash@1662

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1662

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1662

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1662

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1662

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1662

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1662

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1662

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1662

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1662

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1662

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1662

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1662

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1662

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1662

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1662

commit: 0f769a9

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do 0f769a9 Jun 29 2026, 10:09 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs cc61a85 Jun 29 2026, 09:58 PM

@emdashbot emdashbot 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.

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.

Comment thread packages/cloudflare/src/index.ts Outdated
Comment thread packages/cloudflare/src/db/hyperdrive.ts Outdated
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jun 29, 2026
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.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jun 29, 2026
… 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.
@scottbuscemi

Copy link
Copy Markdown
Collaborator Author

Thanks @emdashbot — both points addressed. The review landed on commit 5c0d21ef; the fixes are in cc61a85d and 0f769a95.

1. Internal anonymous GETs (/_emdash/api/setup/status, admin setup-gate) cached. Fixed by gating the cached binding on the request being a public, non-/_emdash path in addition to anonymous + read-only. selectBindingName now takes the request url; any request under /_emdash (admin, setup, auth, internal APIs) uses the primary uncached binding even when anonymous. This was confirmed live: after completing setup, GET /_emdash/api/setup/status now returns {"needsSetup":false} and /_emdash/admin redirects to login instead of looping the wizard. (packages/cloudflare/src/db/hyperdrive.ts, EMDASH_BASE_PATH / isEmDashInternalPath, + routing tests for /_emdash paths and a "path merely contains _emdash" guard.)

2. isAuthenticated too narrow (API-token requests treated as anonymous). Took your preferred option and broadened the signal. The auth middleware that resolves tokens runs after the main middleware, so locals.user isn't populated at the adapter call — instead the main middleware now detects the Authorization: Bearer credential directly on the request and ORs it into isAuthenticated. API-token (ec_pat_*) and OAuth (ec_oat_*) reads therefore stay on the primary/uncached connection. This also fixes a latent read-your-writes gap in the D1 replica adapter, which uses the same signal. Conservative (errs toward primary). (packages/core/src/astro/middleware.ts + a middleware test asserting a Bearer request without a session cookie yields isAuthenticated: true; new emdash changeset.)

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.

Comment on lines +342 to +355
<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>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not a blocker, but having multiple Asides next to each other is harder to read, and defeats the purpose of calling-out important points.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jun 30, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jun 30, 2026
@ascorbic
ascorbic merged commit 942fac6 into main Jun 30, 2026
52 checks passed
@ascorbic
ascorbic deleted the feat/hyperdrive-cached-binding branch June 30, 2026 08:31
@emdashbot emdashbot Bot mentioned this pull request Jun 30, 2026
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…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.
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants