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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,9 @@ jobs:

- name: Check Durable driver (query)
run: cargo check -p drizzle --target wasm32-unknown-unknown --no-default-features --features durable,query

- name: Check Hyperdrive driver
run: cargo check -p drizzle --target wasm32-unknown-unknown --no-default-features --features hyperdrive

- name: Check Hyperdrive driver (query)
run: cargo check -p drizzle --target wasm32-unknown-unknown --no-default-features --features hyperdrive,query
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 35 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,13 @@ arrayvec = { version = "0.7", default-features = false, features = ["serde"] }
postgres = { version = ">=0.19.11, <0.20" }
postgres-native-tls = { version = ">=0.5.3, <0.6" }
native-tls = { version = ">=0.2.18, <0.3", features = ["vendored"] }
tokio-postgres = { version = ">=0.7.18, <0.8", default-features = false, features = [
"runtime",
] }
# `runtime` is deliberately NOT enabled here. It pulls `tokio/net` + `tokio/time`,
# neither of which builds for wasm32-unknown-unknown, and it only gates
# `Config::connect` (TCP/UDS dialing) — every query, transaction, and prepare API
# lives outside it. Native features add `tokio-postgres/runtime` back explicitly;
# the Cloudflare Workers `hyperdrive` feature leaves it off and dials through
# `Config::connect_raw` over a `worker::Socket`.
tokio-postgres = { version = ">=0.7.18, <0.8", default-features = false }
# AWS Aurora Data API (RDS Data Service) — Postgres + MySQL over HTTP.
# Optional, pulls in aws-smithy/hyper/tower/tokio transitively. Gated behind
# the `aws-data-api` feature in `drizzle-postgres` and the root `drizzle` crate.
Expand Down Expand Up @@ -329,10 +333,38 @@ postgres-sync = [
tokio-postgres = [
"postgres",
"dep:tokio-postgres",
# Native dialing: `Config::connect` over TCP/UDS. Not part of the workspace
# dep's own feature list so wasm builds can opt out — see `hyperdrive`.
"tokio-postgres/runtime",
"dep:tokio",
"drizzle-postgres?/tokio-postgres",
"drizzle-macros/tokio-postgres",
]
# Cloudflare Hyperdrive (async `PostgreSQL`, WASM-only) — enable when targeting
# wasm32-unknown-unknown inside a Cloudflare Worker. This is not a separate
# driver: it is the `tokio-postgres` driver with the runtime-bound dialer
# swapped for `Config::connect_raw` over the `worker::Socket` returned by the
# `Hyperdrive` binding. Every gate the `tokio-postgres` feature opens, this one
# opens too; the only extra code is the connect helper in
# `postgres::hyperdrive`.
#
# `tokio-postgres/js` routes postgres-protocol's SCRAM nonce RNG through the
# browser `getrandom` backend, and `uuid/js` does the same for the v4 RNG
# drizzle-migrations pulls in (mirrors the `d1` feature).
hyperdrive = [
"std",
"postgres",
"dep:tokio-postgres",
# Weak (`?`) on purpose: this crate has both an optional dep and a feature
# named `tokio-postgres`, and a plain `tokio-postgres/js` would resolve to
# the *feature* — dragging `runtime` (and `tokio/net` → `mio`) back in.
"tokio-postgres?/js",
"dep:worker",
"dep:uuid",
"uuid/js",
"drizzle-postgres?/tokio-postgres",
"drizzle-macros/tokio-postgres",
]
# AWS Aurora Serverless Data API (HTTP-based Postgres driver).
# Does not share wire decoding with tokio-postgres / postgres — the Data API
# pre-decodes values into `Field` variants, so row decoding goes through
Expand Down
2 changes: 1 addition & 1 deletion bench/runner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ drizzle-core = { workspace = true, features = ["std", "col32"] }
drizzle-seed = { workspace = true, default-features = false, features = ["sqlite", "postgres", "chrono"] }
chrono = { workspace = true, features = ["serde"] }
postgres = { workspace = true, features = ["with-chrono-0_4"] }
tokio-postgres = { workspace = true, features = ["with-chrono-0_4"] }
tokio-postgres = { workspace = true, features = ["runtime", "with-chrono-0_4"] }
rusqlite = { workspace = true }
turso = { workspace = true }
libsql = { workspace = true, optional = true }
Expand Down
3 changes: 3 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ postgres-sync = [
]
tokio-postgres = [
"dep:tokio-postgres",
# The workspace dep leaves `runtime` off so wasm targets can use
# tokio-postgres without `tokio/net`; the CLI dials real sockets.
"tokio-postgres/runtime",
"dep:postgres-native-tls",
"dep:native-tls",
"dep:tokio",
Expand Down
219 changes: 219 additions & 0 deletions src/builder/postgres/hyperdrive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
//! Cloudflare Hyperdrive connector (async, WASM-only).
//!
//! Hyperdrive is Cloudflare's connection pooler and edge cache for existing
//! `PostgreSQL` databases. Inside a Worker, the binding hands out a
//! `worker::Socket` already connected to the pooler, and the pooler speaks the
//! plain `PostgreSQL` wire protocol.
//!
//! This module is **not a separate driver**. `worker::Socket` implements
//! tokio's `AsyncRead`/`AsyncWrite`, so
//! [`tokio_postgres::Config::connect_raw`] hands back the very same
//! [`tokio_postgres::Client`] the native driver wraps. Everything downstream —
//! query surface, transactions, savepoints, prepared statements and the
//! statement cache, relational queries, `migrate`, `push`, `introspect` — is
//! the [`tokio`](crate::postgres::tokio) driver compiled verbatim for
//! `wasm32-unknown-unknown`. The only thing this module adds is the dial.
//!
//! # Requirements
//!
//! - `target_arch = "wasm32"` — the binding only links inside a Worker runtime.
//! - The `worker` crate.
//!
//! ```toml
//! [dependencies]
//! drizzle = { version = "*", features = ["hyperdrive", "uuid"] }
//! worker = { version = "*" }
//! ```
//!
//! ```toml
//! # wrangler.toml
//! [[hyperdrive]]
//! binding = "HYPERDRIVE"
//! id = "<your-hyperdrive-id>"
//! ```
//!
//! # TLS
//!
//! Hyperdrive terminates TLS at the edge and the Worker reaches the pooler over
//! a local, already-authenticated channel, so the documented pattern is
//! [`NoTls`](tokio_postgres::NoTls) — which is what [`connect`] uses.
//!
//! Dialing a database *directly* (no Hyperdrive) with
//! `worker::Socket::builder()` does need TLS. That path is out of scope for
//! [`connect_raw`], which is also `NoTls`: use `worker`'s own
//! `postgres_tls::PassthroughTls` with `Config::connect_raw` (enable the
//! `worker` crate's `tokio-postgres` feature), then hand the resulting client
//! to [`Drizzle::new`](crate::postgres::tokio::Drizzle::new).
//!
//! # Quick start
//!
//! ```rust
//! # let _ = r####"
//! use drizzle::postgres::prelude::*;
//! use drizzle::postgres::hyperdrive;
//! use worker::{event, Context, Env, Request, Response};
//!
//! #[PostgresTable]
//! struct User {
//! #[column(serial, primary)]
//! id: i32,
//! name: String,
//! }
//!
//! #[derive(PostgresSchema)]
//! struct AppSchema {
//! user: User,
//! }
//!
//! #[event(fetch)]
//! async fn fetch(_req: Request, env: Env, _ctx: Context) -> worker::Result<Response> {
//! let (db, AppSchema { user }) =
//! hyperdrive::connect(&env.hyperdrive("HYPERDRIVE")?, AppSchema::new())
//! .await
//! .map_err(|e| worker::Error::RustError(e.to_string()))?;
//!
//! db.insert(user)
//! .values([InsertUser::new("Alice")])
//! .execute()
//! .await
//! .map_err(|e| worker::Error::RustError(e.to_string()))?;
//!
//! let users: Vec<SelectUser> = db
//! .select(())
//! .from(user)
//! .all()
//! .await
//! .map_err(|e| worker::Error::RustError(e.to_string()))?;
//!
//! Response::ok(format!("{} users", users.len()))
//! }
//! # "####;
//! ```
//!
//! # Migrations
//!
//! Prefer applying migrations out of band (CI, or `drizzle migrate` against the
//! database's direct connection string) — a Worker invocation is short-lived
//! and many run concurrently. When the Worker must migrate itself,
//! [`Drizzle::migrate`](crate::postgres::tokio::Drizzle::migrate) works
//! unchanged: it takes the same `pg_advisory_lock`, so concurrent invocations
//! serialize rather than race, and
//! [`migrate_with_repair`](crate::postgres::tokio::Drizzle::migrate_with_repair)
//! reconciles a migration interrupted by a Worker eviction.
//!
//! ```rust
//! # let _ = r####"
//! use drizzle_migrations::Tracking;
//!
//! // Embeds the migration files at compile time (expands to a Vec).
//! let migrations = drizzle::include_migrations!("./migrations");
//!
//! let (mut db, schema) = hyperdrive::connect(&env.hyperdrive("HYPERDRIVE")?, AppSchema::new()).await?;
//! db.migrate(&migrations, Tracking::POSTGRES).await?;
//! # "####;
//! ```
//!
//! `migrate` needs `&mut Drizzle` with no outstanding clones, so run it before
//! handing clones to other tasks.
//!
//! # Lifetime of the connection
//!
//! [`tokio_postgres`] splits a connection into a [`Client`] and a driver future
//! that owns the socket. The future is spawned with
//! [`wasm_bindgen_futures::spawn_local`], so it lives as long as the Worker
//! invocation that created it and is torn down with the isolate. A `Client`
//! therefore must not outlive the request that dialed it — connect per
//! invocation and let Hyperdrive's pooler absorb the cost.
//!
//! # Integer precision
//!
//! Unlike the D1 and Durable Objects drivers, values never cross a JS number
//! boundary here: only raw bytes traverse the socket and `postgres-types`
//! decodes the binary wire format in wasm. `i64`, `numeric`, and `bytea`
//! round-trip exactly.

use drizzle_core::error::DrizzleError;
use tokio_postgres::{Config, NoTls};
use worker::{Hyperdrive, Socket};

use crate::builder::postgres::tokio_postgres::Drizzle;

/// `tokio_postgres::Error`'s `Display` is just "db error"; the server's actual
/// message lives in the `DbError` source.
fn describe(error: &tokio_postgres::Error) -> String {
error
.as_db_error()
.map_or_else(|| error.to_string(), ToString::to_string)
}

/// Connects to `PostgreSQL` through a Cloudflare Hyperdrive binding.
///
/// Returns the same `(Drizzle, Schema)` tuple as
/// [`Drizzle::new`](crate::postgres::tokio::Drizzle::new), for destructuring.
///
/// The connection string carried by the binding points at the local pooler
/// endpoint; TLS is terminated by Hyperdrive at the edge, so the wire to the
/// pooler is dialed with [`NoTls`].
///
/// # Errors
///
/// Returns [`DrizzleError::Other`] if the binding cannot open a socket, if its
/// connection string does not parse as a [`Config`], or if the `PostgreSQL`
/// startup handshake fails.
pub async fn connect<S: Copy>(
hyperdrive: &Hyperdrive,
schema: S,
) -> drizzle_core::error::Result<(Drizzle<S>, S)> {
let socket = hyperdrive.connect().map_err(|e| {
DrizzleError::Other(format!("hyperdrive: failed to open socket: {e}").into())
})?;

let config = hyperdrive
.connection_string()
.parse::<Config>()
.map_err(|e| {
DrizzleError::Other(format!("hyperdrive: invalid connection string: {e}").into())
})?;

connect_raw(&config, socket, schema).await
}

/// Connects over an already-opened [`Socket`] using an explicit [`Config`].
///
/// Use this when the socket does not come from a Hyperdrive binding — e.g. a
/// direct `worker::Socket::builder().connect(host, port)` — or when the
/// connection parameters need adjusting (`application_name`, `options`, a
/// different `dbname`) before the handshake.
///
/// The connection driver future is spawned with
/// [`wasm_bindgen_futures::spawn_local`]; if it ever resolves with an error the
/// error is written to the Worker console, since there is no join handle to
/// surface it through.
///
/// # Errors
///
/// Returns [`DrizzleError::Other`] if the `PostgreSQL` startup handshake fails.
pub async fn connect_raw<S: Copy>(
config: &Config,
socket: Socket,
schema: S,
) -> drizzle_core::error::Result<(Drizzle<S>, S)> {
let (client, connection) = config.connect_raw(socket, NoTls).await.map_err(|e| {
DrizzleError::Other(format!("hyperdrive: connect failed: {}", describe(&e)).into())
})?;

// The driver future owns the socket and must be polled for the client to
// make progress. Workers are single-threaded, so `spawn_local` (which does
// not require `Send`) is the only option — and the right one: the task is
// dropped with the isolate at the end of the invocation.
worker::wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = connection.await {
worker::console_error!(
"hyperdrive: connection closed with error: {}",
describe(&error)
);
}
});

Ok(Drizzle::new(client, schema))
}
8 changes: 7 additions & 1 deletion src/builder/postgres/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,15 @@ macro_rules! postgres_builder_constructors {
#[cfg(feature = "postgres-sync")]
pub mod postgres_sync;

#[cfg(feature = "tokio-postgres")]
// `hyperdrive` is the same driver on a different dialer — it compiles this
// module verbatim and only adds `hyperdrive::connect`.
#[cfg(any(feature = "tokio-postgres", feature = "hyperdrive"))]
pub mod tokio_postgres;

/// Cloudflare Hyperdrive connector for the [`tokio_postgres`] driver.
#[cfg(all(feature = "hyperdrive", target_arch = "wasm32"))]
pub mod hyperdrive;

#[cfg(feature = "aws-data-api")]
pub mod aws_data_api;

Expand Down
4 changes: 2 additions & 2 deletions src/builder/postgres/prepared_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pub fn postgres_sync_param_types(
types
}

#[cfg(feature = "tokio-postgres")]
#[cfg(any(feature = "tokio-postgres", feature = "hyperdrive"))]
pub const fn tokio_postgres_param_type(
value: &drizzle_postgres::values::PostgresValue<'_>,
) -> Option<tokio_postgres::types::Type> {
Expand Down Expand Up @@ -141,7 +141,7 @@ pub const fn tokio_postgres_param_type(
}
}

#[cfg(feature = "tokio-postgres")]
#[cfg(any(feature = "tokio-postgres", feature = "hyperdrive"))]
pub fn tokio_postgres_param_types(
params: &[drizzle_postgres::values::PostgresValue<'_>],
) -> smallvec::SmallVec<[tokio_postgres::types::Type; 8]> {
Expand Down
2 changes: 1 addition & 1 deletion src/builder/postgres/tokio_postgres/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ crate::drizzle_prepare_impl!();
/// and execution methods (`execute`, `all`, `get`, `transaction`).
///
/// The client is stored behind an [`Arc`], making `Drizzle` cheaply cloneable
/// for sharing across tasks (e.g. with [`tokio::spawn`]).
/// for sharing across tasks (e.g. with `tokio::spawn`).
#[derive(Debug)]
pub struct Drizzle<Schema = ()> {
client: Arc<Client>,
Expand Down
Loading
Loading