Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/hyperdrive-postgres-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@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.

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.
5 changes: 5 additions & 0 deletions .changeset/request-scoped-db-streaming.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:",
Expand Down
236 changes: 236 additions & 0 deletions packages/cloudflare/src/db/hyperdrive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/**
* 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. 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 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
* "@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 { 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.
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. 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);
// 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) });

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.

[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.

}

/**
* 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<string, unknown>): 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<any>;
/**
* 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<any>({
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 {
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<string, unknown>)[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: "<your-hyperdrive-id-here>" }] },
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;
}
94 changes: 94 additions & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -200,6 +223,77 @@ 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": "<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 <id> --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
* matters:
* ```jsonc
* { "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
* (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.
*
* 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" })
* ```
*/
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";

Expand Down
Loading
Loading