feat(cloudflare): Hyperdrive Postgres database adapter - #1614
Conversation
🦋 Changeset detectedLatest commit: 144abd9 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 |
@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 |
docs | b874c50 | Jun 24 2026, 07:06 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 144abd9 | Jun 25 2026, 03:15 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 144abd9 | Jun 25 2026, 03:13 PM |
There was a problem hiding this comment.
The adapter is a sensible addition and the two core changes (lazy locals.emdash.db getter and deferred close() until stream-end) are the right fixes for request-scoped connection adapters on Workers. I did not run the test suite / build / lint (no shell), so I’m taking the author’s reported results as unverified.
Approach-level concern: this is a feature PR, and AGENTS.md requires a maintainer-approved Discussion for features. The PR checklist explicitly says a Discussion has not been opened. That is a process blocker for the maintainer to resolve regardless of code quality; I am not treating it as a code defect, but it should be addressed before merge.
What I checked: the adapter contract in virtual-modules.d.ts, the new hyperdrive.ts adapter, the config-time descriptor, the middleware lifecycle refactor (finishScoped, wrapResponseForScopedClose, lazy db getter), and the existing D1 adapter for comparison.
Headline conclusion: the lifecycle plumbing looks correct, but there are real gaps:
- Request-scoped Hyperdrive queries lose instrumentation/metrics.
createRequestScopedDbbuilds its Kysely withoutlog: kyselyLogOption(). The D1 adapter passes it; the runtime singleton passes it; omitting it here meansdb.*Server-Timing counters andEMDASH_QUERY_LOGrecords won’t capture per-request Hyperdrive queries. finishScopedcan leak a connection ifcommit()throws on the success path. On error it closes immediately, but ifrun()succeeds andscoped.commit()throws,close()is never called.commit()is a no-op for Hyperdrive, but the helper is generic and a future adapter could leak.- The isolate-singleton connection in
createDialectis retained across requests. The PR itself explains that pg sockets are bound to the request that opened them, yetcreateDialectcreates a persistent pool that stays open for the worker lifetime (cold-start migrations and cron). Any future code path that touchesthis._dbfrom inside an HTTP request — including plugin contexts and the sandbox runner built against the cold-start singleton — risks workerd’s cross-request I/O guard. Cron is not an HTTP request, but the singleton is created during the first HTTP request if scheduled tasks haven’t run yet, and it is never drained after migrations finish. - No regression tests for the core request-scoping changes. The only new tests validate the config-time descriptor;
finishScoped, the lazy getter, and stream-end close are not covered.
None of these are catastrophic for a D1 deployment, but #1 is an observable behavioral regression for Hyperdrive users and #3 is the exact cross-request-socket problem the PR’s own comments warn about, just shifted from the request path to the singleton path.
| if (!binding?.connectionString) return null; | ||
|
|
||
| const pool = createPool(binding.connectionString, opts.config.max ?? DEFAULT_MAX); | ||
| const db = new Kysely<any>({ dialect: new PostgresDialect({ pool }) }); |
There was a problem hiding this comment.
[needs fixing] The per-request Kysely is built without the log option, so request metrics (db.* Server-Timing counters) and EMDASH_QUERY_LOG recording are silently dropped for Hyperdrive. The D1 request-scoped adapter and the core runtime singleton both pass kyselyLogOption().
| const db = new Kysely<any>({ dialect: new PostgresDialect({ pool }) }); | |
| import { kyselyLogOption } from "emdash/database/instrumentation"; | |
| // ... | |
| const db = new Kysely<any>({ | |
| dialect: new PostgresDialect({ pool }), | |
| log: kyselyLogOption(), | |
| }); |
| scoped.close?.(); | ||
| throw error; | ||
| } | ||
| scoped.commit(); |
There was a problem hiding this comment.
[suggestion] On the success path, if scoped.commit() throws, close() is never called and a connection-backed adapter could leak the request-scoped pool. The error path handles this, but the success path does not.
| scoped.commit(); | |
| try { | |
| scoped.commit(); | |
| } catch (commitError) { | |
| scoped.close?.(); | |
| throw commitError; | |
| } | |
| return scoped.close ? wrapResponseForScopedClose(response, scoped.close) : response; |
| // The singleton only runs cold-start migrations and scheduled() tasks, both | ||
| // sequential — a single connection is enough, and keeping it to 1 leaves the | ||
| // bulk of Hyperdrive's connection budget for the per-request pools. | ||
| return new PostgresDialect({ pool: createPool(binding.connectionString, 1) }); |
There was a problem hiding this comment.
[suggestion] createDialect creates a long-lived pg.Pool attached to the isolate-cached runtime. Because workerd sockets are bound to the request that opened them, retaining this pool across HTTP requests can hit the cross-request I/O guard. The PR correctly avoids the request path, but the singleton is also used for cron and can be reached from plugin/sandbox contexts created at cold-start. Consider draining or recreating this pool outside of migrations so an idle Hyperdrive socket does not outlive the request that created it.
Overlapping PRsThis PR modifies files that are also changed by other open PRs: This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
… scoped db lifecycle Addresses review feedback on the Hyperdrive adapter: - Pass log: kyselyLogOption() to the request-scoped Kysely so per-request Postgres queries are captured by db.* Server-Timing counters and EMDASH_QUERY_LOG, matching the D1 adapter and the runtime singleton. - Extract finishScoped + wrapResponseForScopedClose from middleware.ts into astro/middleware/scoped-db.ts so the request-scoped db lifecycle is unit testable without the virtual:emdash/* module graph. - Defend commit() and close() on every error/failure path in finishScoped so a throwing commit or teardown can neither mask the propagating error nor leak the connection. - Add regression tests for stream-end close, client-disconnect close, bodyless/no-close paths, and the commit/close error-masking branches.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 144abd9 | Jun 25 2026, 03:13 PM |
There was a problem hiding this comment.
Second pass on #1614.
The Hyperdrive adapter is still the right approach, and the three concrete gaps from the first review are resolved:
- Request-scoped Hyperdrive queries are now instrumented:
createRequestScopedDbpasseslog: kyselyLogOption(), so Server-Timing counters andEMDASH_QUERY_LOGcapture those queries just like D1 and the runtime singleton (#1 fixed). finishScopednow guardscommit()on the success path and closes the connection if it throws, and the close/commit error paths are also guarded so exceptions there can't mask the render error (#2 fixed).- There is real unit-test coverage for
finishScopedandwrapResponseForScopedClose, including commit-failure, close-failure, and ordering regressions (#4 fixed).
What remains is the same cross-request socket concern (the previous #3). The per-isolate singleton connection built by hyperdrive.ts#createDialect is cached on EmDashRuntime._db and reused by the Cloudflare scheduled() handler. Because EmDashRuntime is ordinarily created during the first HTTP request, that singleton socket is opened in an HTTP-request context and is then reused from a later Cron Trigger invocation. That violates the same workerd cross-request I/O rule the adapter warns about: "a database connection … is bound to the request that opened it — it cannot be reused by a later request." The per-request pool fixes the HTTP path, but cron is not covered.
I also don't see any new AGENTS.md violations: no interpolated SQL, no unwrapped admin strings, changesets are present for both packages, and the virtual:emdash/dialect contract is updated.
I did not run the test suite, linter, or builds (no shell); I'm taking the author's reported results as unverified.
Headline conclusion: the lifecycle plumbing is now correct for HTTP requests, but the worker-lifetime singleton still leaks a connection across event boundaries and can break Cron Triggers on Hyperdrive. That should be addressed before the feature is merged.
Findings
-
[needs fixing]
packages/cloudflare/src/db/hyperdrive.ts:92-97createDialectbuilds a per-isolatepg.Poolthat is never drained. That pool is cached onEmDashRuntime._dband reused by thescheduled()handler. Because the runtime is usually initialized during the first HTTP request, the singleton pool's socket is opened in that request's context and later reused from a Cron Trigger invocation on the same isolate. This is exactly the cross-request I/O hazard the module header warns about ("Cannot perform I/O on behalf of a different request"). The per-request scoping fixes HTTP handlers, but the background path still needs an event-scoped connection rather than a worker-lifetime one. -
[needs fixing]
packages/core/src/emdash-runtime.ts:554-565runScheduledTasksroutes cron/scheduled work throughthis.db. In a Cron Trigger there is no per-request ALS context, sothis.dbfalls back to the singletonthis._db. If the Hyperdrive singleton was created during a prior HTTP request (the normal warm-isolate case), its socket was opened in that request context; reusing it here can hang or fail under workerd's cross-request I/O guard. The background path should use a connection created inside the cron event, or the adapter should expose a way to create a fresh background-scoped DB perscheduled()invocation. -
[suggestion]
packages/core/src/astro/middleware/stream-end-metrics.ts:32ASTRO_COOKIES_SYMBOLis already exported fromscoped-db.tsand used bymiddleware.ts. Defining it again here risks drift if Astro ever changes the well-known symbol. Import the shared copy instead:import { ASTRO_COOKIES_SYMBOL } from "./scoped-db.js";
…ies symbol
Second-pass review feedback on the Hyperdrive adapter:
- Document that the adapter currently supports the content read/write path
only. The per-isolate singleton connection is captured at construction by
the cron handler, plugin hook contexts, media providers, and the sandbox
runner; on a warm isolate its socket belongs to an earlier request and
workerd refuses to reuse it across events. Call this out in the hyperdrive()
JSDoc, the adapter module header, and the changeset, and correct the prior
comment that implied the scheduled() cron path was safe. Closing the gap
needs the core runtime to thread an event-scoped connection through those
subsystems, tracked separately.
- Dedupe ASTRO_COOKIES_SYMBOL: stream-end-metrics.ts now imports the shared
copy from scoped-db.ts instead of redefining Symbol.for("astro.cookies").
* feat(cloudflare): request-scoped Hyperdrive Postgres adapter + streaming-safe scoped db close
* docs(cloudflare): recommend Smart Placement hint with hyperdrive() adapter
* chore(cloudflare): format hyperdrive adapter
* docs(cloudflare): document disabling Hyperdrive query caching for read-after-write
* fix(cloudflare): instrument request-scoped Hyperdrive queries; harden scoped db lifecycle
Addresses review feedback on the Hyperdrive adapter:
- Pass log: kyselyLogOption() to the request-scoped Kysely so per-request
Postgres queries are captured by db.* Server-Timing counters and
EMDASH_QUERY_LOG, matching the D1 adapter and the runtime singleton.
- Extract finishScoped + wrapResponseForScopedClose from middleware.ts into
astro/middleware/scoped-db.ts so the request-scoped db lifecycle is unit
testable without the virtual:emdash/* module graph.
- Defend commit() and close() on every error/failure path in finishScoped so a
throwing commit or teardown can neither mask the propagating error nor leak
the connection.
- Add regression tests for stream-end close, client-disconnect close,
bodyless/no-close paths, and the commit/close error-masking branches.
* docs(cloudflare): document Hyperdrive request-path scope; dedupe cookies symbol
Second-pass review feedback on the Hyperdrive adapter:
- Document that the adapter currently supports the content read/write path
only. The per-isolate singleton connection is captured at construction by
the cron handler, plugin hook contexts, media providers, and the sandbox
runner; on a warm isolate its socket belongs to an earlier request and
workerd refuses to reuse it across events. Call this out in the hyperdrive()
JSDoc, the adapter module header, and the changeset, and correct the prior
comment that implied the scheduled() cron path was safe. Closing the gap
needs the core runtime to thread an event-scoped connection through those
subsystems, tracked separately.
- Dedupe ASTRO_COOKIES_SYMBOL: stream-end-metrics.ts now imports the shared
copy from scoped-db.ts instead of redefining Symbol.for("astro.cookies").
* docs(cloudflare): link Hyperdrive limitation to tracking issue emdash-cms#1622
---------
Co-authored-by: Matt Kane <mkane@cloudflare.com>
* feat(cloudflare): request-scoped Hyperdrive Postgres adapter + streaming-safe scoped db close
* docs(cloudflare): recommend Smart Placement hint with hyperdrive() adapter
* chore(cloudflare): format hyperdrive adapter
* docs(cloudflare): document disabling Hyperdrive query caching for read-after-write
* fix(cloudflare): instrument request-scoped Hyperdrive queries; harden scoped db lifecycle
Addresses review feedback on the Hyperdrive adapter:
- Pass log: kyselyLogOption() to the request-scoped Kysely so per-request
Postgres queries are captured by db.* Server-Timing counters and
EMDASH_QUERY_LOG, matching the D1 adapter and the runtime singleton.
- Extract finishScoped + wrapResponseForScopedClose from middleware.ts into
astro/middleware/scoped-db.ts so the request-scoped db lifecycle is unit
testable without the virtual:emdash/* module graph.
- Defend commit() and close() on every error/failure path in finishScoped so a
throwing commit or teardown can neither mask the propagating error nor leak
the connection.
- Add regression tests for stream-end close, client-disconnect close,
bodyless/no-close paths, and the commit/close error-masking branches.
* docs(cloudflare): document Hyperdrive request-path scope; dedupe cookies symbol
Second-pass review feedback on the Hyperdrive adapter:
- Document that the adapter currently supports the content read/write path
only. The per-isolate singleton connection is captured at construction by
the cron handler, plugin hook contexts, media providers, and the sandbox
runner; on a warm isolate its socket belongs to an earlier request and
workerd refuses to reuse it across events. Call this out in the hyperdrive()
JSDoc, the adapter module header, and the changeset, and correct the prior
comment that implied the scheduled() cron path was safe. Closing the gap
needs the core runtime to thread an event-scoped connection through those
subsystems, tracked separately.
- Dedupe ASTRO_COOKIES_SYMBOL: stream-end-metrics.ts now imports the shared
copy from scoped-db.ts instead of redefining Symbol.for("astro.cookies").
* docs(cloudflare): link Hyperdrive limitation to tracking issue emdash-cms#1622
---------
Co-authored-by: Matt Kane <mkane@cloudflare.com>
What does this PR do?
Adds a
hyperdrive()database adapter to@emdash-cms/cloudflareso EmDash can run on Cloudflare Workers backed by a PostgreSQL (or Postgres-compatible, e.g. PlanetScale Postgres) database through a Hyperdrive binding. Hyperdrive handles connection pooling and query caching; EmDash's existing PostgreSQL dialect runs the queries.Making this work on Workers required two small core fixes that benefit any connection-backed, request-scoped adapter (D1 and other stateless bindings are unaffected):
locals.emdash.dbis now a lazy getter. It was eagerly captured whenlocals.emdashis built — which happens before the per-request scoped DB is installed in ALS — so routes always received the per-isolate singleton. For a stateless binding (D1) that's equivalent, but for a request-bound connection (pg over Hyperdrive) the singleton belongs to the cold-start request, and reusing it from a warm request hangs on workerd's cross-request I/O guard.commit()produced"driver has already been destroyed". A new optionalclose()hook on the request-scoped contract is invoked once the body has fully flushed (immediately for bodyless responses);commit()still runs pre-response so D1's bookmark cookie is unaffected.The adapter is request-scoped: each request gets its own
pg.Pool+ Kysely, opened and closed within that request, because a Worker connection cannot be reused across requests.Hyperdrive's query cache is default-on and must be turned off for an EmDash configuration. EmDash runs its own caching layer and relies on read-after-write consistency — the admin and setup wizard write a row and immediately read it back. With caching on, Hyperdrive can serve the pre-write result within its TTL, which corrupted setup during testing (
"collection already exists", then half-created tables missing columns) and would show editors stale content. Disable it:This is documented in the
hyperdrive()JSDoc and called out in the changeset.Verified end-to-end against a real PlanetScale Postgres database provisioned through Cloudflare + Hyperdrive: migrations, seeding (content + media to R2), and all public routes (home, posts list, post detail, RSS) render consistently with no hangs. Pairing the deploy with a Smart Placement hint (
placement.region: "aws:us-east-1") co-locates the Worker with the database (cf-placement: remote-IAD).Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change) — 189 cloudflare + 4105 core tests passpnpm formathas been runhyperdrive()@emdash-cms/cloudflareminor,emdashpatchAI-generated code disclosure
Screenshots / test output
Live verification (PlanetScale Postgres via Hyperdrive):
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-postgres-adapter. Updated automatically when the playground redeploys.