WebDyne::Cloudflare::Hyperdrive
Asynchronous PostgreSQL and MySQL queries through a request's Cloudflare Hyperdrive binding. This API borrows DBI conventions; it is not a DBI driver and does not require DBI, Moose or an ORM. It requires the Hyperdrive-enabled JavaScript extension and a ZeroPerl runtime with awaited cleanup support.
use Future::AsyncAwait;
use WebDyne::Cloudflare::Hyperdrive;
my $db_or=WebDyne::Cloudflare::Hyperdrive->new(scope => $scope_hr, binding => 'DB');
my $customer_hr=await $db_or->selectrow_hashref(
'SELECT id, name FROM customers WHERE id=$1', undef, $customer_id);
my $order_id=await $db_or->transaction(async sub {
my ($tx_or)=@_;
my $row_ar=await $tx_or->selectrow_arrayref(
'INSERT INTO orders (customer_id) VALUES ($1) RETURNING id', undef, $customer_id);
await $tx_or->do('INSERT INTO order_items (order_id, sku) VALUES ($1, $2)',
undef, $row_ar->[0], $sku);
return $row_ar->[0];
});
await $db_or->disconnect();
Construction and queries
new(scope => $scope_hr, binding => 'DB') validates the request capability without
opening a network connection. Binding defaults to DB. Other constructor options
are rejected. Each object owns an independent lazy logical connection.
selectrow_arrayref($sql, $attr_hr, @bind) and
selectrow_hashref($sql, $attr_hr, @bind) return Futures resolving to the first row
or undef. selectall_arrayref($sql, $attr_hr, @bind) returns a Future resolving to
all array rows, or hash rows with { Slice => {} }. No rows gives an empty array.
Hash rows use the last column when names repeat; use SQL aliases for joins.
do($sql, $attr_hr, @bind) returns a Future resolving to affected-row count,
0E0 for a known zero (true as a boolean), or -1 for an unknown count. Use a
select method for INSERT/UPDATE/DELETE RETURNING when the returned rows are needed.
Use undef or an empty hash for absent attributes. Slice is accepted only by
selectall_arrayref; unknown attributes are errors. The attribute position is never
treated as a bind value. Use PostgreSQL $1, $2 placeholders or MySQL ?
placeholders and bind values separately. SQL dialects are not translated and no
automatic LIMIT is added. Values cannot stand in for identifiers. Multiple SQL statements
and direct transaction/session-control commands are unsupported.
prepare($sql, $attr_hr) constructs a local
Statement synchronously. It does not issue SQL PREPARE.
Statement execution buffers bounded results; its fetch methods are synchronous.
blob($bytes) constructs an explicit binary parameter wrapper. Normal scalars
are text parameters; use SQL casts to disambiguate inferred PostgreSQL types.
Pass undef for SQL NULL and JSON::PP booleans for booleans. Encode JSON documents
as text explicitly. Arbitrary references and text containing NUL are rejected.
Transactions and lifetime
begin_work(), commit() and rollback() return Futures resolving to 1.
Await each operation. Nested transactions are unsupported. Errors within a
transaction require rollback before continuing.
transaction($callback_cr) returns a Future, passes a transaction-scoped database
facade to the callback, awaits its Future, commits on success, and returns its
scalar result (including false or undef). On callback failure it rolls back and
rethrows the original exception. If rollback also fails, a Hyperdrive Error retains
the primary error and secondary cleanup failure. A lost commit response can have
an unknown outcome; it is never retried or described as a successful rollback.
Await earlier operations before entering a callback transaction, and await every operation started by the callback. Unfinished callback operations are an error. While the callback owns the connection, parent database/statement calls are blocked. Use only its facade. Explicit transaction controls and disconnect are unavailable on that facade. It and its statements expire when the callback ends.
The host serializes SQL operations on each connection. Overlapping execution of one statement is rejected. Separate database objects can operate independently within the configured request connection limit. This does not promise concurrent Perl WASM execution: the runtime schedules its interpreter.
disconnect() returns an idempotent Future resolving to 1; it rolls back unfinished
work and invalidates the database and its statements immediately. Unused objects
disconnect without opening a connection. Await it when convenient; request teardown
also owns rollback/close. Never rely on a Perl destructor for asynchronous cleanup.
Cancelling an operation's Future invalidates the database handle. Disconnect it; otherwise bounded request teardown performs cleanup. Cancellation is not proof that the database stopped executing or that a write did not happen. Failed managed cleanup or an ambiguous commit similarly requires disconnecting the handle.
Values, errors and limits
With PostgreSQL, SQL NULL becomes undef; booleans become JSON::PP booleans. Small integers and
finite floats become numbers. BIGINT and NUMERIC remain exact text, dates/times
retain PostgreSQL text and microseconds, bytea becomes bytes, JSON/JSONB remains
JSON text, and arrays/other types remain PostgreSQL text. JSON null therefore
differs from SQL NULL. Non-finite floats are the strings NaN, Infinity or -Infinity.
Timestamps retain the server's output timezone representation; no session timezone
is changed. Column OIDs are available through Statement metadata.
Host failures throw Error objects with SQLSTATE where available. Local argument/lifetime errors throw descriptive exceptions. Synchronous methods can throw immediately; valid I/O calls return Futures. Database diagnostics can contain submitted values and should not be copied into public HTTP responses.
Worker defaults: four logical connections per request, 1 MiB request, 4 MiB result, 10,000 rows, 5 s connect, 10 s query and 5 s cleanup. Configure overrides in the JavaScript extension, not Perl query attributes. Results are not silently truncated. The driver can allocate a large individual field before applying the result bound; this is not an absolute process memory ceiling or a streaming cursor API.
A deadline closes the client connection; it does not confirm cancellation at the origin database. The origin may continue running the statement while Hyperdrive settles its pooled connection. Subsequent work can wait for that pool and hit its own deadline. Do not immediately retry writes after a timeout: their outcome can be unknown. Configure database-side statement limits separately when required.
MySQL and compatible databases
The JavaScript entry point selects PostgreSQL (postgres: or postgresql:) or
MySQL (mysql:) from each Hyperdrive binding's connection string. Keep the same
Perl constructor and hyperdriveBindings configuration. No driver argument or
new ZeroPerl runtime is required; PostgreSQL and MySQL bindings can coexist.
Credentials stay in JavaScript. The tested MySQL driver is pinned mysql2 3.24.4.
my $statement_or=$db_or->prepare('INSERT INTO customers (name) VALUES (?)');
await $statement_or->execute($name);
my $id=$statement_or->insert_id(); # Exact decimal string, including large IDs.
my $customer_hr=await $db_or->selectrow_hashref(
'SELECT id, name FROM customers WHERE id=?', undef, $id);
MySQL executes a single text-protocol query. prepare() remains local; it does
not use server-side prepared statements. The adapter substitutes only unquoted
? placeholders outside comments, using UTF-8 hex expressions for text and hex
literals for blobs. This avoids dependence on backslash escaping or SQL mode.
It rejects mismatched parameter counts, ?? identifier placeholders, executable
MySQL/MariaDB comments and quoted SQL literals containing backslashes. Bind those
strings instead. LIMIT/OFFSET placeholders accept validated unsigned decimal
integers (including zero). Ordinary quoted strings and comments may contain literal ?.
Supported statement families are SELECT, INSERT, UPDATE, DELETE, REPLACE, WITH, EXPLAIN, SHOW, DESCRIBE/DESC and ordinary CREATE/ALTER/DROP/TRUNCATE/RENAME/ ANALYZE/OPTIMIZE/CHECK statements. The latter group is rejected inside transactions because MySQL can commit implicitly. USE, SET, locks, XA, CALL, LOAD DATA, SQL PREPARE/EXECUTE, multiple statements and multiple result sets are unsupported. Use transactional tables (InnoDB) for rollback guarantees; table engines, triggers, and server/provider restrictions remain the application's responsibility.
MySQL BIGINT and DECIMAL values, JSON documents and dates/times remain strings;
DATETIME(6) retains microseconds. Small integers and finite floating values become
numbers. TINYINT(1)/BOOLEAN is numeric 0/1, not a PostgreSQL boolean. Binary fields
return bytes. SQL NULL remains undef and JSON null remains text null. MySQL
column metadata uses driver => 'mysql', type, flags and charset; it does
not invent PostgreSQL OIDs. Array rows retain duplicate columns.
rows() and execute() use mysql2's affectedRows for DML. With the default
FOUND_ROWS flag an UPDATE counts matched rows, including unchanged values.
MySQL statement accessors insert_id(), affected_rows() and warning_count()
expose DML metadata; SELECT and PostgreSQL results return undef for these fields.
MySQL code() is symbolic (for example ER_DUP_ENTRY), while sqlstate() and
errno() expose the server's SQLSTATE and numeric error. As with PostgreSQL,
a SQL error inside a transaction requires rollback before further work.
Qualified with Aiven MySQL 8.4.8 through Hyperdrive and Perl/WASM, plus direct adapter tests against MySQL 8.4.11 and MariaDB 11.8.9. PlanetScale/Vitess has not been qualified here; compatible protocol support does not establish identical SQL, DDL, or transaction behavior for every provider.