From 94b4f5f2e3079919f848151f392e9bc56e542938 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 24 Jun 2026 11:52:14 -0700 Subject: [PATCH 1/7] feat(cloudflare): request-scoped Hyperdrive Postgres adapter + streaming-safe scoped db close --- .changeset/hyperdrive-postgres-adapter.md | 5 + .changeset/request-scoped-db-streaming.md | 5 + packages/cloudflare/package.json | 13 +- packages/cloudflare/src/db/hyperdrive.ts | 204 ++++++++++++++++++ packages/cloudflare/src/index.ts | 59 +++++ .../tests/hyperdrive-config.test.ts | 31 +++ packages/cloudflare/tsdown.config.ts | 1 + packages/core/src/astro/middleware.ts | 111 ++++++++-- packages/core/src/virtual-modules.d.ts | 9 + pnpm-lock.yaml | 8 +- 10 files changed, 422 insertions(+), 24 deletions(-) create mode 100644 .changeset/hyperdrive-postgres-adapter.md create mode 100644 .changeset/request-scoped-db-streaming.md create mode 100644 packages/cloudflare/src/db/hyperdrive.ts create mode 100644 packages/cloudflare/tests/hyperdrive-config.test.ts diff --git a/.changeset/hyperdrive-postgres-adapter.md b/.changeset/hyperdrive-postgres-adapter.md new file mode 100644 index 0000000000..b6822092f7 --- /dev/null +++ b/.changeset/hyperdrive-postgres-adapter.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/cloudflare": minor +--- + +Adds a `hyperdrive()` database adapter for connecting EmDash on Cloudflare Workers to a PostgreSQL (or PostgreSQL-compatible, e.g. PlanetScale Postgres) database through a Hyperdrive binding. Configure it with `database: hyperdrive({ binding: "HYPERDRIVE" })`. Each request gets its own pooled connection that is opened and closed within that request — connections cannot be reused across Worker requests. Requires `pg >= 8.16.3`, the `nodejs_compat` compatibility flag, and a compatibility date of `2024-09-23` or later. Disable Hyperdrive query caching for the configuration so the admin's read-after-write stays consistent. diff --git a/.changeset/request-scoped-db-streaming.md b/.changeset/request-scoped-db-streaming.md new file mode 100644 index 0000000000..75f4efa453 --- /dev/null +++ b/.changeset/request-scoped-db-streaming.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes request-scoped database adapters that hold a real connection (e.g. Postgres over Cloudflare Hyperdrive) so they work on Workers. `locals.emdash.db` is now resolved lazily, so routes get the per-request connection instead of a snapshot of the shared singleton, and a request-scoped connection is now closed only after the response body finishes streaming rather than before — Astro streams HTML while components still query, so closing earlier broke server-rendered pages. No effect on D1 or other stateless bindings. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 47569baf11..ef7b2f44ba 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -17,6 +17,10 @@ "types": "./dist/db/d1.d.mts", "default": "./dist/db/d1.mjs" }, + "./db/hyperdrive": { + "types": "./dist/db/hyperdrive.d.mts", + "default": "./dist/db/hyperdrive.mjs" + }, "./db/do": { "types": "./dist/db/do.d.mts", "default": "./dist/db/do.mjs" @@ -96,12 +100,19 @@ "@astrojs/cloudflare": ">=12.0.0", "@cloudflare/workers-types": ">=4.0.0", "astro": ">=6.0.0-beta.0", - "kysely": ">=0.28.17" + "kysely": ">=0.28.17", + "pg": ">=8.16.3" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + } }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", "@astrojs/cloudflare": "catalog:", "@cloudflare/workers-types": "catalog:", + "@types/pg": "^8.16.0", "publint": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts new file mode 100644 index 0000000000..9d9d6ce124 --- /dev/null +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -0,0 +1,204 @@ +/** + * Cloudflare Hyperdrive runtime adapter - RUNTIME ENTRY + * + * Hyperdrive pools and accelerates connections to an existing PostgreSQL + * (or PostgreSQL-compatible, e.g. PlanetScale Postgres) database, letting a + * Worker reach it over Cloudflare's network with connection pooling and + * query caching. + * + * Connection lifecycle on Workers + * -------------------------------- + * A Worker isolate handles many requests, but a database connection (a TCP + * socket) is bound to the request that opened it — it cannot be reused by a + * later request. A module-global `pg.Pool` therefore breaks: the first request + * works, then subsequent requests reusing the isolate's stale pool hang or + * error with "Cannot perform I/O on behalf of a different request". + * + * So this adapter is request-scoped: `createRequestScopedDb` builds a fresh + * `pg.Pool` + Kysely for each request and closes it once the response body has + * finished streaming. EmDash's middleware stashes that per-request Kysely in + * ALS, and the runtime/loader db getters prefer it over the singleton — so all + * request-path queries use a connection opened in the current request. + * + * `createDialect` still builds the per-isolate singleton Kysely. It is only + * touched outside the request-scoped path — cold-start migrations (which run + * inside the first request, so the connection is valid) and the scheduled() + * cron handler. + * + * This module imports directly from cloudflare:workers to access the binding. + * Do NOT import it at config time — use { hyperdrive } from + * "@emdash-cms/cloudflare" instead. + * + * Requirements (set in the consuming site's wrangler config): + * - `compatibility_flags: ["nodejs_compat"]` + * - `compatibility_date >= "2024-09-23"` + * - `pg >= 8.16.3` installed in the site + */ + +import { env, waitUntil } from "cloudflare:workers"; +import { type Dialect, Kysely, PostgresDialect } from "kysely"; +// `pg` is provided by the consuming site (an optional peer of `emdash`); it is +// kept external from this package's bundle. +import { Pool } from "pg"; + +/** + * Hyperdrive configuration (runtime type — matches the config-time type in + * index.ts). + */ +interface HyperdriveConfig { + binding: string; + max?: number; +} + +/** + * Minimal shape of a Hyperdrive binding. Workers inject `connectionString` + * (and the discrete parts) at runtime; we only need the string for pg. + */ +interface HyperdriveBinding { + connectionString: string; +} + +const DEFAULT_MAX = 5; + +/** + * Build a fresh node-postgres Pool for the given connection string. + * + * Hyperdrive owns the real pool to the origin; the in-Worker pool just feeds + * connections to the current request. The per-request pool is closed + * explicitly once the response has streamed (see `close()` in + * `createRequestScopedDb`), so no idle-reaper is needed. + */ +function createPool(connectionString: string, max: number): Pool { + return new Pool({ + connectionString, + max, + // Disable pg's idle-reaper timer. In workerd a socket is owned by the + // request that opened it; a background timer set in one request that + // later fires and touches that socket performs I/O "on behalf of a + // different request", which workerd hangs on. Pools are torn down + // explicitly instead, so the reaper is unnecessary. + idleTimeoutMillis: 0, + }); +} + +/** + * Create a PostgreSQL dialect backed by a Hyperdrive binding. + * + * Used for the per-isolate singleton Kysely (cold-start migrations and the + * scheduled() handler). Request-path queries go through + * `createRequestScopedDb` instead. + */ +export function createDialect(config: HyperdriveConfig): Dialect { + const binding = requireBinding(config); + // 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) }); +} + +/** + * A cookie interface minimally compatible with Astro's AstroCookies. Declared + * here (not imported from astro) so this module stays free of astro types. + */ +interface CookieJar { + get(name: string): { value: string } | undefined; + set(name: string, value: string, options: Record): void; +} + +export interface RequestScopedDbOpts { + config: HyperdriveConfig; + isAuthenticated: boolean; + isWrite: boolean; + cookies: CookieJar; + url: URL; +} + +export interface RequestScopedDb { + /** Per-request Kysely instance backed by a fresh pg Pool. */ + db: Kysely; + /** + * No per-request state to persist (Hyperdrive routes and caches itself, so + * there are no bookmark cookies). Kept to satisfy the adapter contract. + */ + commit: () => void; + /** + * Close the per-request pool. The middleware calls this once the response + * body has fully streamed — not before — because Astro streams HTML and the + * Live loader issues queries while the body streams; tearing the pool down + * any earlier yields "driver has already been destroyed". Draining is handed + * to `waitUntil` so it never blocks, while the socket stays valid for the + * whole request it was opened in. + */ + close: () => void; +} + +/** + * Create a fresh, request-scoped Kysely backed by its own pg Pool. EmDash + * middleware calls this once per request, stashes `db` in ALS for the duration + * of next(), then closes it once the response body has streamed. + * + * Hyperdrive itself routes reads/writes and handles caching, so this adapter + * does not need bookmark cookies or read-replica constraints — every request + * gets an equivalent connection. + */ +export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedDb | null { + const binding = getBinding(opts.config); + // No binding at runtime: fall back to the singleton path (which will throw + // a descriptive error if the binding is genuinely missing). + if (!binding?.connectionString) return null; + + const pool = createPool(binding.connectionString, opts.config.max ?? DEFAULT_MAX); + const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); + + let closed = false; + return { + db, + // No bookmark/cookie state for Hyperdrive. + commit() {}, + close() { + if (closed) return; + closed = true; + // Destroy the Kysely (and its pool) once the body has streamed. + // waitUntil keeps the isolate alive to drain without delaying the + // response. The socket was opened in this request and is closed within + // it, so there's no cross-request I/O. + waitUntil( + db.destroy().catch((error: unknown) => { + console.error("[emdash][hyperdrive] failed to close request pool:", error); + }), + ); + }, + }; +} + +function getBinding(config: HyperdriveConfig): HyperdriveBinding | null { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Worker binding accessed from untyped env object + const binding = (env as Record)[config.binding] as + | HyperdriveBinding + | undefined; + return binding ?? null; +} + +function requireBinding(config: HyperdriveConfig): HyperdriveBinding { + const binding = getBinding(config); + if (!binding) { + const example = JSON.stringify( + { hyperdrive: [{ binding: config.binding, id: "" }] }, + null, + 2, + ); + throw new Error( + `Hyperdrive binding "${config.binding}" not found in environment. ` + + `Check your wrangler.jsonc configuration:\n\n${example}\n\n` + + `Hyperdrive also requires compatibility_flags: ["nodejs_compat"] and ` + + `compatibility_date >= "2024-09-23".`, + ); + } + if (!binding.connectionString) { + throw new Error( + `Hyperdrive binding "${config.binding}" is present but has no connectionString. ` + + `Ensure the binding points at a valid Hyperdrive configuration.`, + ); + } + return binding; +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index dab1487f2d..1c2b9a89ba 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -105,6 +105,29 @@ export interface D1Config { coalesce?: boolean; } +/** + * Hyperdrive configuration + */ +export interface HyperdriveConfig { + /** + * Name of the Hyperdrive binding in wrangler config. + * @default "HYPERDRIVE" + */ + binding?: string; + + /** + * Maximum size of the in-Worker node-postgres connection pool. + * + * Hyperdrive maintains the real connection pool to your origin database, + * so this only caps connections from the Worker isolate to Hyperdrive. + * Keep it low to stay within Workers' concurrent external connection + * limits. + * + * @default 5 + */ + max?: number; +} + /** * R2 storage configuration */ @@ -200,6 +223,42 @@ export function d1(config: D1Config): DatabaseDescriptor { }; } +/** + * Cloudflare Hyperdrive database adapter (PostgreSQL) + * + * For Cloudflare Workers connecting to an existing PostgreSQL or + * PostgreSQL-compatible database (e.g. PlanetScale Postgres) through a + * Hyperdrive binding. Hyperdrive pools and accelerates the connection; + * EmDash's PostgreSQL dialect runs the queries. + * + * Each request gets its own pooled connection that is opened and closed within + * that request — Worker connections cannot be reused across requests. + * + * Requires in the consuming site: + * - `pg >= 8.16.3` installed + * - `compatibility_flags: ["nodejs_compat"]` + * - `compatibility_date >= "2024-09-23"` + * - A Hyperdrive binding in wrangler config: + * ```jsonc + * { "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "" }] } + * ``` + * + * @example + * ```ts + * database: hyperdrive({ binding: "HYPERDRIVE" }) + * ``` + */ +export function hyperdrive(config: HyperdriveConfig = {}): DatabaseDescriptor { + return { + entrypoint: "@emdash-cms/cloudflare/db/hyperdrive", + config: { binding: config.binding ?? "HYPERDRIVE", max: config.max }, + type: "postgres", + // Each request gets a fresh pg connection that is closed afterwards — + // connections cannot be reused across Worker requests. + supportsRequestScope: true, + }; +} + export type { PreviewDOConfig } from "./db/do-types.js"; export type { DurableObjectsConfig } from "./db/do-sql-types.js"; diff --git a/packages/cloudflare/tests/hyperdrive-config.test.ts b/packages/cloudflare/tests/hyperdrive-config.test.ts new file mode 100644 index 0000000000..b4bcd84eaa --- /dev/null +++ b/packages/cloudflare/tests/hyperdrive-config.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; + +import { hyperdrive } from "../src/index.js"; + +describe("hyperdrive()", () => { + it("returns a postgres DatabaseDescriptor with the hyperdrive entrypoint", () => { + const result = hyperdrive({ binding: "HYPERDRIVE" }); + expect(result).toEqual({ + entrypoint: "@emdash-cms/cloudflare/db/hyperdrive", + config: { binding: "HYPERDRIVE", max: undefined }, + type: "postgres", + supportsRequestScope: true, + }); + }); + + it("defaults the binding to HYPERDRIVE", () => { + const result = hyperdrive(); + expect(result.config).toEqual({ binding: "HYPERDRIVE", max: undefined }); + expect(result.type).toBe("postgres"); + }); + + it("passes through a custom binding and pool max", () => { + const result = hyperdrive({ binding: "PG", max: 10 }); + expect(result.config).toEqual({ binding: "PG", max: 10 }); + }); + + it("requests request-scoped db support (per-request pg connections)", () => { + const result = hyperdrive(); + expect(result.supportsRequestScope).toBe(true); + }); +}); diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index 91f6b1a67f..ace4fd8ca5 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ "src/index.ts", "src/db/d1.ts", + "src/db/hyperdrive.ts", "src/db/do.ts", "src/db/do-sql.ts", "src/db/playground.ts", diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 0ef858da24..4deb22607b 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -356,15 +356,81 @@ const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/; */ function createRequestScopedDb( opts: RequestScopedDbOpts, -): { db: Kysely; commit: () => void } | null { +): { db: Kysely; commit: () => void; close?: () => void } | null { if (typeof virtualCreateRequestScopedDb !== "function") return null; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- adapter returns Kysely; cast to Database since core owns that type const fn = virtualCreateRequestScopedDb as ( o: RequestScopedDbOpts, - ) => { db: Kysely; commit: () => void } | null; + ) => { db: Kysely; commit: () => void; close?: () => void } | null; return fn(opts); } +/** + * Run a request-scoped db's `close()` once the response body has finished + * streaming. Astro streams HTML and components issue DB queries during that + * stream, so a connection-backed adapter (e.g. Postgres over Hyperdrive) must + * not be torn down until the body is flushed. Bodyless responses (redirects, + * 304s, errors) close immediately. A guard makes close idempotent and a stream + * `cancel` (client disconnect) still triggers it so connections never leak. + * + * No-op for adapters without a `close` (D1): the response passes through. + */ +function wrapResponseForScopedClose(response: Response, close: () => void): Response { + let closed = false; + const runClose = () => { + if (closed) return; + closed = true; + try { + close(); + } catch (error) { + console.error("[emdash] request-scoped db close failed:", error); + } + }; + + if (!response.body) { + runClose(); + return response; + } + + const transform = new TransformStream({ + flush: runClose, + cancel: runClose, + }); + const wrapped = new Response(response.body.pipeThrough(transform), response); + const astroCookies = Reflect.get(response, ASTRO_COOKIES_SYMBOL); + if (astroCookies !== undefined) { + Reflect.set(wrapped, ASTRO_COOKIES_SYMBOL, astroCookies); + } + // Byte counts are preserved by the identity transform, but a stale + // Content-Length on a reconstructed streaming Response risks truncation. + wrapped.headers.delete("Content-Length"); + return wrapped; +} + +/** + * Run the request body under a request-scoped db, then settle its lifecycle: + * `commit()` runs before the response is returned (so per-request state like a + * D1 bookmark cookie is persisted in the headers, even if render throws), while + * `close()` (if any) is deferred to stream-end so a connection-backed adapter + * isn't torn down mid-render. On error the connection is closed immediately + * before rethrowing so it never leaks. + */ +async function finishScoped( + scoped: { commit: () => void; close?: () => void }, + run: () => Promise, +): Promise { + let response: Response; + try { + response = await run(); + } catch (error) { + scoped.commit(); + scoped.close?.(); + throw error; + } + scoped.commit(); + return scoped.close ? wrapResponseForScopedClose(response, scoped.close) : response; +} + export const onRequest = defineMiddleware(async (context, next) => { const { request, locals, cookies } = context; const url = context.url; @@ -546,15 +612,10 @@ export const onRequest = defineMiddleware(async (context, next) => { .startsWith("text/html"); return runWithContext(ctx, async () => { if (acceptsHtml) after(() => prefetchLayoutData()); - // commit() in finally: the write reached the primary independently - // of render, so the bookmark cookie must be persisted even if - // render throws -- otherwise a write-then-failed-render leaves the - // next request able to read pre-write state off a lagging replica. - try { - return await runAnon(); - } finally { - anonScoped.commit(); - } + // commit() persists per-request state (e.g. the D1 bookmark cookie) + // before the response is returned, even if render throws; close() + // (connection teardown) is deferred to stream-end. See finishScoped. + return finishScoped(anonScoped, runAnon); }); } return runAnon(); @@ -658,7 +719,17 @@ export const onRequest = defineMiddleware(async (context, next) => { // Direct access (for advanced use cases) storage: runtime.storage, - db: runtime.db, + // Lazy getter, not an eager snapshot: `locals.emdash` is built + // before the per-request scoped db is installed in ALS, so reading + // `runtime.db` here would capture the per-isolate singleton. Routes + // access `emdash.db` later, during the request, when the scoped db + // is active. For a stateless binding (D1) the two are equivalent, + // but for a request-bound connection (pg/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. + get db() { + return runtime.db; + }, getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage), hooks: runtime.hooks, email: runtime.email, @@ -722,16 +793,12 @@ export const onRequest = defineMiddleware(async (context, next) => { const ctx = parent ? { ...parent, db: scoped.db } : { editMode: false, db: scoped.db, metrics }; - return runWithContext(ctx, async () => { - // commit() in finally: persist the bookmark cookie even if render - // throws -- the write already reached the primary, so a failed - // render must not strand the next request on a stale replica read. - try { - return await renderAndFinalize(); - } finally { - scoped.commit(); - } - }); + return runWithContext(ctx, () => + // commit() persists per-request state (e.g. the D1 bookmark cookie) + // before the response returns, even if render throws; close() + // (connection teardown) is deferred to stream-end. See finishScoped. + finishScoped(scoped, renderAndFinalize), + ); } return renderAndFinalize(); diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 6ba3dc8bbd..4f604e2eb9 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -54,6 +54,15 @@ declare module "virtual:emdash/dialect" { export interface RequestScopedDb { db: Kysely; commit: () => void; + /** + * Optional teardown, invoked once the response body has fully streamed + * (or immediately for bodyless responses). Adapters that hold a real + * connection for the request (e.g. a Postgres pool over Hyperdrive) close + * it here — closing in `commit()` would cut the connection mid-render, + * because Astro streams the HTML body and components issue queries while + * it streams. Stateless adapters (D1) omit it. + */ + close?: () => void; } export const createRequestScopedDb: (opts: RequestScopedDbOpts) => RequestScopedDb | null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 230c4fb2cd..6c585aa8d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1510,6 +1510,9 @@ importers: kysely-d1: specifier: ^0.4.0 version: 0.4.0(kysely@0.29.2) + pg: + specifier: '>=8.16.3' + version: 8.18.0 ulidx: specifier: ^2.4.1 version: 2.4.1 @@ -1523,6 +1526,9 @@ importers: '@cloudflare/workers-types': specifier: 'catalog:' version: 4.20260305.1 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 publint: specifier: 'catalog:' version: 0.3.17 @@ -17299,7 +17305,7 @@ snapshots: '@types/pg@8.16.0': dependencies: - '@types/node': 24.10.13 + '@types/node': 25.9.1 pg-protocol: 1.11.0 pg-types: 2.2.0 From c4b89c9d47bd75600caa19ffb7aadfcf061acbda Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 24 Jun 2026 11:57:19 -0700 Subject: [PATCH 2/7] docs(cloudflare): recommend Smart Placement hint with hyperdrive() adapter --- packages/cloudflare/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 1c2b9a89ba..94968afabf 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -243,6 +243,14 @@ export function d1(config: D1Config): DatabaseDescriptor { * { "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "" }] } * ``` * + * For best latency, pair this with a Smart Placement hint so the Worker runs in + * the Cloudflare data center closest to your database's region — the request + * path makes multiple round trips, so co-locating the Worker with the origin + * matters: + * ```jsonc + * { "placement": { "region": "aws:us-east-1" } } + * ``` + * * @example * ```ts * database: hyperdrive({ binding: "HYPERDRIVE" }) From c9ebcd4319e14022ca6302974c4e2727cbc782b1 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 24 Jun 2026 11:59:59 -0700 Subject: [PATCH 3/7] chore(cloudflare): format hyperdrive adapter --- packages/cloudflare/src/db/hyperdrive.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 9d9d6ce124..363e32a19a 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -173,9 +173,7 @@ export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedD function getBinding(config: HyperdriveConfig): HyperdriveBinding | null { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Worker binding accessed from untyped env object - const binding = (env as Record)[config.binding] as - | HyperdriveBinding - | undefined; + const binding = (env as Record)[config.binding] as HyperdriveBinding | undefined; return binding ?? null; } From b874c50554ec5ee20e05bb939c1638bbc197e6ef Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Wed, 24 Jun 2026 12:04:10 -0700 Subject: [PATCH 4/7] docs(cloudflare): document disabling Hyperdrive query caching for read-after-write --- packages/cloudflare/src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 94968afabf..7b42aa8509 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -243,6 +243,17 @@ export function d1(config: D1Config): DatabaseDescriptor { * { "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "" }] } * ``` * + * **Disable Hyperdrive query caching for this configuration.** EmDash runs its + * own caching layer and depends on read-after-write consistency — the admin and + * setup wizard write a row and immediately read it back. Hyperdrive's default-on + * query cache can serve the pre-write result within its TTL, which corrupts + * setup (e.g. "collection already exists" / missing columns) and shows editors + * stale content. Turn it off when creating the config: + * ```sh + * wrangler hyperdrive update --caching-disabled + * # or, at create time: wrangler hyperdrive create ... --caching-disabled + * ``` + * * For best latency, pair this with a Smart Placement hint so the Worker runs in * the Cloudflare data center closest to your database's region — the request * path makes multiple round trips, so co-locating the Worker with the origin From 422d1970c57cc092c8a1f2ab071e163ad1bf10b7 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 25 Jun 2026 14:50:05 +0100 Subject: [PATCH 5/7] 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. --- packages/cloudflare/src/db/hyperdrive.ts | 12 +- packages/core/src/astro/middleware.ts | 75 +----- .../core/src/astro/middleware/scoped-db.ts | 131 ++++++++++ .../tests/unit/middleware/scoped-db.test.ts | 236 ++++++++++++++++++ 4 files changed, 379 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/astro/middleware/scoped-db.ts create mode 100644 packages/core/tests/unit/middleware/scoped-db.test.ts diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 363e32a19a..2489396a11 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -36,6 +36,7 @@ */ import { env, waitUntil } from "cloudflare:workers"; +import { kyselyLogOption } from "emdash/database/instrumentation"; import { type Dialect, Kysely, PostgresDialect } from "kysely"; // `pg` is provided by the consuming site (an optional peer of `emdash`); it is // kept external from this package's bundle. @@ -148,7 +149,16 @@ export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedD if (!binding?.connectionString) return null; const pool = createPool(binding.connectionString, opts.config.max ?? DEFAULT_MAX); - const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); + const db = new Kysely({ + dialect: new PostgresDialect({ pool }), + // Mirror the D1 adapter and the runtime singleton: route per-request + // queries through the instrumentation logger so db.* Server-Timing + // counters and EMDASH_QUERY_LOG capture Hyperdrive queries too. The + // singleton built by createDialect gets this from core (it wraps the + // dialect in a logged Kysely), but this request-scoped Kysely is built + // here, so it must opt in itself. + log: kyselyLogOption(), + }); let closed = false; return { diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 4deb22607b..fa98ea62ac 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -59,6 +59,7 @@ import type { PublishedRef } from "../scheduled-publish.js"; import { isMissingTableError } from "../utils/db-errors.js"; import { createInitLock, type InitLock, initWithLock } from "../utils/init-lock.js"; import type { EmDashConfig } from "./integration/runtime.js"; +import { ASTRO_COOKIES_SYMBOL, finishScoped } from "./middleware/scoped-db.js"; import { wrapBodyForStreamMetrics } from "./middleware/stream-end-metrics.js"; import { prefetchLayoutData } from "./prefetch.js"; import { createPublicPluginApiRouteHandler } from "./public-plugin-api-routes.js"; @@ -273,14 +274,6 @@ export async function runScheduledTasks( return runtime.runScheduledTasks(options); } -/** - * Astro attaches AstroCookies to outgoing responses via a well-known global - * symbol. Cloning a Response (`new Response(body, init)`) drops non-header - * metadata, so any middleware that wraps the response must explicitly forward - * this symbol or `cookies.set()` calls will be silently dropped. - */ -const ASTRO_COOKIES_SYMBOL = Symbol.for("astro.cookies"); - /** * Baseline security headers applied to all responses. * Admin routes get additional headers (strict CSP) from auth middleware. @@ -365,72 +358,6 @@ function createRequestScopedDb( return fn(opts); } -/** - * Run a request-scoped db's `close()` once the response body has finished - * streaming. Astro streams HTML and components issue DB queries during that - * stream, so a connection-backed adapter (e.g. Postgres over Hyperdrive) must - * not be torn down until the body is flushed. Bodyless responses (redirects, - * 304s, errors) close immediately. A guard makes close idempotent and a stream - * `cancel` (client disconnect) still triggers it so connections never leak. - * - * No-op for adapters without a `close` (D1): the response passes through. - */ -function wrapResponseForScopedClose(response: Response, close: () => void): Response { - let closed = false; - const runClose = () => { - if (closed) return; - closed = true; - try { - close(); - } catch (error) { - console.error("[emdash] request-scoped db close failed:", error); - } - }; - - if (!response.body) { - runClose(); - return response; - } - - const transform = new TransformStream({ - flush: runClose, - cancel: runClose, - }); - const wrapped = new Response(response.body.pipeThrough(transform), response); - const astroCookies = Reflect.get(response, ASTRO_COOKIES_SYMBOL); - if (astroCookies !== undefined) { - Reflect.set(wrapped, ASTRO_COOKIES_SYMBOL, astroCookies); - } - // Byte counts are preserved by the identity transform, but a stale - // Content-Length on a reconstructed streaming Response risks truncation. - wrapped.headers.delete("Content-Length"); - return wrapped; -} - -/** - * Run the request body under a request-scoped db, then settle its lifecycle: - * `commit()` runs before the response is returned (so per-request state like a - * D1 bookmark cookie is persisted in the headers, even if render throws), while - * `close()` (if any) is deferred to stream-end so a connection-backed adapter - * isn't torn down mid-render. On error the connection is closed immediately - * before rethrowing so it never leaks. - */ -async function finishScoped( - scoped: { commit: () => void; close?: () => void }, - run: () => Promise, -): Promise { - let response: Response; - try { - response = await run(); - } catch (error) { - scoped.commit(); - scoped.close?.(); - throw error; - } - scoped.commit(); - return scoped.close ? wrapResponseForScopedClose(response, scoped.close) : response; -} - export const onRequest = defineMiddleware(async (context, next) => { const { request, locals, cookies } = context; const url = context.url; diff --git a/packages/core/src/astro/middleware/scoped-db.ts b/packages/core/src/astro/middleware/scoped-db.ts new file mode 100644 index 0000000000..7467125b9c --- /dev/null +++ b/packages/core/src/astro/middleware/scoped-db.ts @@ -0,0 +1,131 @@ +/** + * Request-scoped database lifecycle helpers. + * + * Extracted from middleware.ts so they can be unit-tested without pulling in + * the virtual:emdash/* module graph. The middleware imports these to settle a + * request-scoped db adapter's lifecycle around the response. + */ + +/** + * Astro attaches AstroCookies to outgoing responses via a well-known global + * symbol. Cloning a Response (`new Response(body, init)`) drops non-header + * metadata, so any helper that wraps the response must explicitly forward this + * symbol or `cookies.set()` calls will be silently dropped. `Symbol.for` + * returns the same registry symbol everywhere, so this matches the copy in + * middleware.ts. + */ +export const ASTRO_COOKIES_SYMBOL = Symbol.for("astro.cookies"); + +/** + * Run a request-scoped db's `close()` once the response body has finished + * streaming. Astro streams HTML and components issue DB queries during that + * stream, so a connection-backed adapter (e.g. Postgres over Hyperdrive) must + * not be torn down until the body is flushed. Bodyless responses (redirects, + * 304s, errors) close immediately. A guard makes close idempotent and a stream + * `cancel` (client disconnect) still triggers it so connections never leak. + * + * No-op for adapters without a `close` (D1): the response passes through. + */ +export function wrapResponseForScopedClose(response: Response, close: () => void): Response { + let closed = false; + const runClose = () => { + if (closed) return; + closed = true; + try { + close(); + } catch (error) { + console.error("[emdash] request-scoped db close failed:", error); + } + }; + + if (!response.body) { + runClose(); + return response; + } + + const transform = new TransformStream({ + flush: runClose, + cancel: runClose, + }); + const wrapped = new Response(response.body.pipeThrough(transform), response); + const astroCookies = Reflect.get(response, ASTRO_COOKIES_SYMBOL); + if (astroCookies !== undefined) { + Reflect.set(wrapped, ASTRO_COOKIES_SYMBOL, astroCookies); + } + // Byte counts are preserved by the identity transform, but a stale + // Content-Length on a reconstructed streaming Response risks truncation. + wrapped.headers.delete("Content-Length"); + return wrapped; +} + +/** + * Run the request body under a request-scoped db, then settle its lifecycle: + * `commit()` runs before the response is returned (so per-request state like a + * D1 bookmark cookie is persisted in the headers, even if render throws), while + * `close()` (if any) is deferred to stream-end so a connection-backed adapter + * isn't torn down mid-render. On error the connection is closed immediately + * before rethrowing so it never leaks. + * + * On the error path both `commit()` and `close()` are defended: a throw from + * either is logged and swallowed so it can't replace the propagating render + * error (which is the one the caller needs to see). On the success path + * `commit()` is guarded too — if it throws, the connection is closed before the + * failure is surfaced, so it never leaks. For the current adapters `commit()` + * is a no-op (Hyperdrive) or a cookie write (D1, no `close`) and `close()` is + * fire-and-forget, so these guards only matter for a future connection-backed + * adapter with throwing teardown, but the helper is generic and must not leak + * or mask. + */ +export async function finishScoped( + scoped: { commit: () => void; close?: () => void }, + run: () => Promise, +): Promise { + let response: Response; + try { + response = await run(); + } catch (error) { + // A render error is already propagating; neither commit nor close may + // mask it, and close must still run so the connection doesn't leak. + commitSafely(scoped.commit); + closeSafely(scoped.close); + throw error; + } + try { + scoped.commit(); + } catch (error) { + // commit() failed on the success path: close the connection now (the + // response won't be wrapped, so stream-end close would never run) and + // surface the failure. close is swallowed so it can't mask the commit + // error that the caller needs to see. + closeSafely(scoped.close); + throw error; + } + return scoped.close ? wrapResponseForScopedClose(response, scoped.close) : response; +} + +/** + * Run commit() swallowing any error. Used where an exception is already + * propagating (or about to be thrown) and a commit failure must neither mask it + * nor skip the subsequent close(). + */ +function commitSafely(commit: () => void): void { + try { + commit(); + } catch (error) { + console.error("[emdash] request-scoped db commit failed during error handling:", error); + } +} + +/** + * Run close() swallowing any error. Used on the error/commit-failure paths + * where another exception is the one the caller must see; a throwing teardown + * must not replace it. + */ +function closeSafely(close: (() => void) | undefined): void { + if (!close) return; + try { + close(); + } catch (error) { + console.error("[emdash] request-scoped db close failed during error handling:", error); + } +} diff --git a/packages/core/tests/unit/middleware/scoped-db.test.ts b/packages/core/tests/unit/middleware/scoped-db.test.ts new file mode 100644 index 0000000000..5406b17b49 --- /dev/null +++ b/packages/core/tests/unit/middleware/scoped-db.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, vi } from "vitest"; + +import { + ASTRO_COOKIES_SYMBOL, + finishScoped, + wrapResponseForScopedClose, +} from "../../../src/astro/middleware/scoped-db.js"; + +/** Build a streaming Response whose body emits the given chunks. */ +function streamingResponse(chunks: string[], init?: ResponseInit): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); + return new Response(body, init); +} + +/** Fully drain a response body so any stream-end hooks fire. */ +async function drain(response: Response): Promise { + return response.body ? await new Response(response.body).text() : ""; +} + +describe("wrapResponseForScopedClose", () => { + it("closes immediately for a bodyless response", () => { + const close = vi.fn(); + const response = new Response(null, { status: 302, headers: { location: "/" } }); + + const wrapped = wrapResponseForScopedClose(response, close); + + expect(close).toHaveBeenCalledTimes(1); + // Bodyless responses pass through unchanged. + expect(wrapped).toBe(response); + }); + + it("defers close until the body has fully streamed", async () => { + const close = vi.fn(); + const wrapped = wrapResponseForScopedClose(streamingResponse(["a", "b"]), close); + + // Not closed yet — the body hasn't been read. + expect(close).not.toHaveBeenCalled(); + + const text = await drain(wrapped); + + expect(text).toBe("ab"); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("closes when the body stream is cancelled (client disconnect)", async () => { + const close = vi.fn(); + const wrapped = wrapResponseForScopedClose(streamingResponse(["chunk"]), close); + + await wrapped.body!.cancel(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("is idempotent across flush and cancel", async () => { + const close = vi.fn(); + const wrapped = wrapResponseForScopedClose(streamingResponse(["x"]), close); + + await drain(wrapped); + await wrapped.body!.cancel().catch(() => {}); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("swallows a throwing close so the stream still completes", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const close = vi.fn(() => { + throw new Error("boom"); + }); + const wrapped = wrapResponseForScopedClose(streamingResponse(["data"]), close); + + const text = await drain(wrapped); + + expect(text).toBe("data"); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("forwards the Astro cookies symbol onto the wrapped response", async () => { + const close = vi.fn(); + const response = streamingResponse(["hi"]); + const cookies = { marker: true }; + Reflect.set(response, ASTRO_COOKIES_SYMBOL, cookies); + + const wrapped = wrapResponseForScopedClose(response, close); + + expect(Reflect.get(wrapped, ASTRO_COOKIES_SYMBOL)).toBe(cookies); + await drain(wrapped); + }); + + it("drops a stale Content-Length on the wrapped streaming response", () => { + const close = vi.fn(); + const response = streamingResponse(["hello"], { headers: { "content-length": "5" } }); + + const wrapped = wrapResponseForScopedClose(response, close); + + expect(wrapped.headers.has("content-length")).toBe(false); + }); +}); + +describe("finishScoped", () => { + it("commits then defers close to stream-end for a streaming response", async () => { + const order: string[] = []; + const commit = vi.fn(() => order.push("commit")); + const close = vi.fn(() => order.push("close")); + + const response = await finishScoped({ commit, close }, async () => streamingResponse(["body"])); + + // commit runs before the response is returned; close is still pending. + expect(commit).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + + await drain(response); + + expect(close).toHaveBeenCalledTimes(1); + expect(order).toEqual(["commit", "close"]); + }); + + it("commits and closes immediately when there is no body", async () => { + const commit = vi.fn(); + const close = vi.fn(); + + await finishScoped({ commit, close }, async () => new Response(null, { status: 204 })); + + expect(commit).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("passes the response through unchanged for adapters without close (D1)", async () => { + const commit = vi.fn(); + const original = streamingResponse(["d1"]); + + const result = await finishScoped({ commit }, async () => original); + + expect(result).toBe(original); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it("commits and closes once before rethrowing when run() throws", async () => { + const commit = vi.fn(); + const close = vi.fn(); + const boom = new Error("render failed"); + + await expect( + finishScoped({ commit, close }, async () => { + throw boom; + }), + ).rejects.toBe(boom); + + expect(commit).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("still closes the connection when commit() throws on the success path", async () => { + // Regression: a previous version called commit() unguarded on the + // success path, so a throwing commit skipped close() and leaked the + // connection. + const close = vi.fn(); + const commit = vi.fn(() => { + throw new Error("commit failed"); + }); + + await expect( + finishScoped({ commit, close }, async () => streamingResponse(["body"])), + ).rejects.toThrow("commit failed"); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("does not mask a run() error when commit() also throws on the error path", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const close = vi.fn(); + const commit = vi.fn(() => { + throw new Error("commit failed"); + }); + const renderError = new Error("render failed"); + + await expect( + finishScoped({ commit, close }, async () => { + throw renderError; + }), + ).rejects.toBe(renderError); + + // close still runs even though commit threw during error handling. + expect(close).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("does not mask a run() error when close() throws on the error path", async () => { + // Regression: close() on the error path was unguarded, so a throwing + // teardown would replace the render error the caller needs to see. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const commit = vi.fn(); + const close = vi.fn(() => { + throw new Error("close failed"); + }); + const renderError = new Error("render failed"); + + await expect( + finishScoped({ commit, close }, async () => { + throw renderError; + }), + ).rejects.toBe(renderError); + + expect(commit).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it("surfaces the commit error, not a later close error, on the success path", async () => { + // When commit() fails after a successful render, the caller must see the + // commit failure; a throwing close() during cleanup must not mask it. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const commit = vi.fn(() => { + throw new Error("commit failed"); + }); + const close = vi.fn(() => { + throw new Error("close failed"); + }); + + await expect( + finishScoped({ commit, close }, async () => streamingResponse(["body"])), + ).rejects.toThrow("commit failed"); + + expect(close).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); From e0d67a262e67f42dc5814b60c8cb79f036ccb73a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 25 Jun 2026 15:57:28 +0100 Subject: [PATCH 6/7] 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"). --- .changeset/hyperdrive-postgres-adapter.md | 2 + packages/cloudflare/src/db/hyperdrive.ts | 43 ++++++++++++++----- packages/cloudflare/src/index.ts | 15 +++++++ .../astro/middleware/stream-end-metrics.ts | 12 ++---- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/.changeset/hyperdrive-postgres-adapter.md b/.changeset/hyperdrive-postgres-adapter.md index b6822092f7..2ace641fbf 100644 --- a/.changeset/hyperdrive-postgres-adapter.md +++ b/.changeset/hyperdrive-postgres-adapter.md @@ -3,3 +3,5 @@ --- Adds a `hyperdrive()` database adapter for connecting EmDash on Cloudflare Workers to a PostgreSQL (or PostgreSQL-compatible, e.g. PlanetScale Postgres) database through a Hyperdrive binding. Configure it with `database: hyperdrive({ binding: "HYPERDRIVE" })`. Each request gets its own pooled connection that is opened and closed within that request — connections cannot be reused across Worker requests. Requires `pg >= 8.16.3`, the `nodejs_compat` compatibility flag, and a compatibility date of `2024-09-23` or later. Disable Hyperdrive query caching for the configuration so the admin's read-after-write stays consistent. + +The content read/write path (pages, content API routes, loaders) is fully supported. Cron Triggers (scheduled publishing, plugin cron, system cleanup), plugin hooks that query the database, and sandboxed plugins are not yet supported on this adapter — they use a per-isolate connection that workerd will not reuse across events. Use `d1()` if your deployment depends on those. diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 2489396a11..4aed31e51e 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -20,10 +20,32 @@ * ALS, and the runtime/loader db getters prefer it over the singleton — so all * request-path queries use a connection opened in the current request. * - * `createDialect` still builds the per-isolate singleton Kysely. It is only - * touched outside the request-scoped path — cold-start migrations (which run - * inside the first request, so the connection is valid) and the scheduled() - * cron handler. + * `createDialect` still builds the per-isolate singleton Kysely. Its socket is + * opened by whatever event first queries it — normally the cold-start + * migrations during the first HTTP request — and, because a pg socket is bound + * to the request that opened it, it is only safe to use again from within that + * same event. The request path never does: routes and loaders read through the + * per-request scoped Kysely (ALS), not the singleton. + * + * Known limitation — background and plugin paths still use the singleton + * -------------------------------------------------------------------------- + * Several subsystems capture the runtime's singleton db at construction and do + * not consult the per-request scoped connection: + * - the Cron Trigger handler (`scheduled()` → scheduled publishing, plugin + * cron, system cleanup), + * - plugin hook contexts (a hook's `content` / `media` / `users` / `cron` + * access), + * - media providers and sandboxed plugins. + * + * On a warm isolate the singleton's socket belongs to an earlier request, so + * these paths can fail under workerd's cross-request I/O guard ("Cannot perform + * I/O on behalf of a different request"). It is not a data-corruption risk — the + * work errors and is logged — but it means scheduled publishing and + * database-querying plugin hooks are not yet supported on the Hyperdrive + * adapter. The core read/write path (pages, content API routes, loaders) is + * unaffected. Closing this requires the core runtime to thread an event-scoped + * connection through those subsystems; tracked separately. Until then, use D1 + * for deployments that rely on Cron Triggers or DB-querying plugins. * * This module imports directly from cloudflare:workers to access the binding. * Do NOT import it at config time — use { hyperdrive } from @@ -85,15 +107,16 @@ function createPool(connectionString: string, max: number): Pool { /** * Create a PostgreSQL dialect backed by a Hyperdrive binding. * - * Used for the per-isolate singleton Kysely (cold-start migrations and the - * scheduled() handler). Request-path queries go through - * `createRequestScopedDb` instead. + * Used for the per-isolate singleton Kysely. The request path never touches it + * (it reads through `createRequestScopedDb`); in practice the singleton serves + * cold-start migrations, plus the background/plugin paths noted in the module + * header that are not yet safe across event boundaries on this adapter. */ export function createDialect(config: HyperdriveConfig): Dialect { const binding = requireBinding(config); - // 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. + // Cold-start migrations are sequential, so 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) }); } diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 7b42aa8509..dda45bf603 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -262,6 +262,21 @@ export function d1(config: D1Config): DatabaseDescriptor { * { "placement": { "region": "aws:us-east-1" } } * ``` * + * **Known limitation — request path only (for now).** Each request gets its own + * pg connection, so the content read/write path (pages, content API routes, + * loaders) is fully supported. But several background and plugin paths still use + * the per-isolate singleton connection, whose socket is bound to the request + * that opened it; on a warm isolate workerd refuses to reuse it from a later + * event. Until the core runtime threads an event-scoped connection through them, + * the following are **not yet supported** on the Hyperdrive adapter: + * - Cron Triggers — scheduled publishing, plugin cron, and system cleanup. + * - Plugin hooks that query the database via their plugin context. + * - Media providers and sandboxed plugins that hold the singleton db. + * + * Use `d1()` for deployments that depend on those. (This is a Hyperdrive-adapter + * limitation, not a data-safety risk: affected work errors and is logged rather + * than corrupting anything.) + * * @example * ```ts * database: hyperdrive({ binding: "HYPERDRIVE" }) diff --git a/packages/core/src/astro/middleware/stream-end-metrics.ts b/packages/core/src/astro/middleware/stream-end-metrics.ts index afdf229082..3698b180e4 100644 --- a/packages/core/src/astro/middleware/stream-end-metrics.ts +++ b/packages/core/src/astro/middleware/stream-end-metrics.ts @@ -20,17 +20,13 @@ import { flushRecorder, isInstrumentationEnabled } from "../../database/instrumentation.js"; import { getRequestContext } from "../../request-context.js"; +// Reuse the single source of truth for Astro's well-known cookies symbol +// rather than redefining `Symbol.for("astro.cookies")` here — it must stay in +// lockstep with the copy the rest of the middleware forwards. +import { ASTRO_COOKIES_SYMBOL } from "./scoped-db.js"; export const STREAM_END_PREFIX = "[emdash-stream-end]"; -/** - * Astro attaches AstroCookies to outgoing responses via a well-known global - * symbol. Constructing a new Response drops non-header metadata, so the - * symbol must be forwarded explicitly or `cookies.set()` calls are silently - * dropped. Same pattern as finalizeResponse in ../middleware.ts. - */ -const ASTRO_COOKIES_SYMBOL = Symbol.for("astro.cookies"); - /** Shape of the NDJSON snapshot emitted when the body finishes streaming. */ export interface StreamEndSnapshot { route?: string; From 144abd958689de6936e2aa1b11020cc645ea90ae Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 25 Jun 2026 16:10:29 +0100 Subject: [PATCH 7/7] docs(cloudflare): link Hyperdrive limitation to tracking issue #1622 --- packages/cloudflare/src/db/hyperdrive.ts | 5 +++-- packages/cloudflare/src/index.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 4aed31e51e..042995a339 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -44,8 +44,9 @@ * database-querying plugin hooks are not yet supported on the Hyperdrive * adapter. The core read/write path (pages, content API routes, loaders) is * unaffected. Closing this requires the core runtime to thread an event-scoped - * connection through those subsystems; tracked separately. Until then, use D1 - * for deployments that rely on Cron Triggers or DB-querying plugins. + * connection through those subsystems; tracked in + * https://github.com/emdash-cms/emdash/issues/1622. Until then, use D1 for + * deployments that rely on Cron Triggers or DB-querying plugins. * * This module imports directly from cloudflare:workers to access the binding. * Do NOT import it at config time — use { hyperdrive } from diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index dda45bf603..3bf51adc63 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -267,8 +267,9 @@ export function d1(config: D1Config): DatabaseDescriptor { * loaders) is fully supported. But several background and plugin paths still use * the per-isolate singleton connection, whose socket is bound to the request * that opened it; on a warm isolate workerd refuses to reuse it from a later - * event. Until the core runtime threads an event-scoped connection through them, - * the following are **not yet supported** on the Hyperdrive adapter: + * event. Until the core runtime threads an event-scoped connection through them + * (tracked in https://github.com/emdash-cms/emdash/issues/1622), the following + * are **not yet supported** on the Hyperdrive adapter: * - Cron Triggers — scheduled publishing, plugin cron, and system cleanup. * - Plugin hooks that query the database via their plugin context. * - Media providers and sandboxed plugins that hold the singleton db.