@lunora/hyperdrive
Bring-your-own Postgres/MySQL via Cloudflare Hyperdrive — an action-only ctx.sql, or a reactive .global() backend.
@lunora/hyperdrive lets an action read and write an existing
Postgres/MySQL database through Cloudflare
Hyperdrive — pooled, cached
connections from the edge. It surfaces the binding's connection string and a
driver-agnostic ctx.sql client.
Integrate an existing database — don't replace the Lunora data layer. Hyperdrive points at a database Lunora has no visibility into, so two invariants the rest of Lunora relies on do not hold for it:
- Non-deterministic. A SQL query over the network is an external,
mutable read — exactly like
fetch. It is therefore forbidden inquery/mutationand available only onActionCtx. Thehyperdrive_outside_actionadvisor lint flags anyctx.sqlreached from a query or mutation. - Non-reactive. Live queries track writes to the DO's SQLite / D1. An
UPDATEissued over Hyperdrive produces no Lunora change event, so subscriptions will not re-run when external rows change.
Hyperdrive is the right tool for "read/write my legacy Postgres from an
action," and the wrong tool for "make my Postgres reactive." If you want
external data to be reactive, write a projection of it into a
defineSchema DO/D1 table (see Making external data
reactive).
Install
pnpm add @lunora/hyperdrivenpm install @lunora/hyperdriveyarn add @lunora/hyperdrivebun add @lunora/hyperdriveNo driver is bundled — postgres, pg, and mysql2 are heavy and the choice
is yours. They are declared as optional peer dependencies; install the one
you use:
pnpm add postgres # postgres.js → fromPostgresJs
# or
pnpm add pg # node-postgres → fromNodePg
# or
pnpm add mysql2 # mysql2 → fromMysql2Set up the binding
-
Create a Hyperdrive config pointing at your origin database:
wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host:5432/db" # gitleaks:allow -- placeholder, not a real secretThis prints an
id. -
Add the binding to
wrangler.jsonc. UselocalConnectionStringso local dev (lunora dev) connects straight to your DB without the edge proxy:{ "hyperdrive": [ { "binding": "HYPERDRIVE", "id": "<the id from step 1>", "localConnectionString": "postgres://user:pass@localhost:5432/db", // gitleaks:allow -- placeholder, not a real secret }, ], }
Lunora validates the binding (it errors when binding is missing and warns when
id is empty — a placeholder id can't connect), but it does not
auto-provision the id: that's a remote resource only wrangler hyperdrive create
can mint, so importing @lunora/hyperdrive surfaces a hint rather than writing the
binding for you.
The canonical recipe
Construct your driver from the connection string and wrap it with the matching
adapter to get a ctx.sql. Do this only inside an action:
import { createHyperdrive, fromPostgresJs } from "@lunora/hyperdrive";
import postgres from "postgres";
import { action, v } from "@/lunora/_generated/server";
export const importLegacyOrders = action.input({ orgId: v.string() }).action(async ({ ctx, args: { orgId } }) => {
const { connectionString } = createHyperdrive(ctx.env.HYPERDRIVE);
ctx.sql = fromPostgresJs(postgres(connectionString));
// Read from external Postgres ($1, $2, … placeholders).
const orders = await ctx.sql.query<{ id: string; total: number }>("select id, total from orders where org = $1", [orgId]);
return orders;
});When the codegen detects ctx.sql usage it adds sql: SqlClient to ActionCtx
only — never QueryCtx or MutationCtx — with a JSDoc restating the
determinism/realtime caveat.
Drivers & placeholders
| Driver | Adapter | Placeholders |
|---|---|---|
postgres (postgres.js) | fromPostgresJs | $1, $2, … |
pg (node-postgres) | fromNodePg | $1, $2, … |
mysql2/promise | fromMysql2 | ? |
The package never rewrites SQL — use your driver's native positional syntax.
Making external data reactive
Lunora cannot observe external writes, so a subscription over a defineSchema
table won't re-fire when Postgres changes. To make external data reactive,
project it into a DO/D1 table inside the same action — that write is on
the change-feed, so live queries reading the projection re-run:
import { createHyperdrive, fromPostgresJs } from "@lunora/hyperdrive";
import postgres from "postgres";
import { api } from "@/lunora/_generated/api";
import { action, v } from "@/lunora/_generated/server";
export const syncOrder = action.input({ id: v.string() }).action(async ({ ctx, args: { id } }) => {
const { connectionString } = createHyperdrive(ctx.env.HYPERDRIVE);
ctx.sql = fromPostgresJs(postgres(connectionString));
const [row] = await ctx.sql.query<{ id: string; total: number }>("select id, total from orders where id = $1", [id]);
// This write is tracked — a `query` over `orders` re-runs for subscribers.
await ctx.runMutation(api.orders.upsert, { id: row.id, total: row.total });
});Reactive .global() over Hyperdrive
The @lunora/hyperdrive/global subpath is the inverse trade-off: instead of an
escape hatch onto a DB Lunora doesn't own, it makes a Postgres/MySQL database a
first-class, reactive .global() storage backend, alongside D1. Lunora owns
the schema — a .global() table gets a real column-per-field layout and every
write routes through the shared store core, so live queries stay reactive with no
extra wiring.
You build the writer inside the Durable Object that hosts the .global() store
(the HYPERDRIVE binding is reachable there) and inject it as globalDb. Cache
the driver on the DO instance and rebuild it lazily after hibernation.
import { createPostgresGlobalCtxDb } from "@lunora/hyperdrive/global";
import postgres from "postgres";
const sql = postgres(env.HYPERDRIVE.connectionString);
const globalDb = createPostgresGlobalCtxDb({ query: (text, params) => sql.unsafe(text, params) }, { schema });import { createMysqlGlobalCtxDb } from "@lunora/hyperdrive/global";
import mysql from "mysql2/promise";
// CLIENT_FOUND_ROWS is REQUIRED — the affected-rows OCC guard must see matched
// (not changed) rows, or an idempotent patch reports 0 and raises a false conflict.
const pool = mysql.createPool({ uri: env.HYPERDRIVE.connectionString, flags: ["FOUND_ROWS"] });
const globalDb = createMysqlGlobalCtxDb(pool, { schema });The convenience constructors cover the common path. For custom wiring, the lower-level pieces are also exported:
buildPgExec(client)/buildMysqlExec(connection)— turn a driver into the store'sSqlExec.postgresDialect/mysqlDialect— the engine dialects.createHyperdriveGlobalCtxDb({ engine, exec, ...storeOptions })— the general factory the two convenience constructors call.
Non-goals
- No CDC / logical replication. Lunora does not ingest your Postgres write-ahead log; the projection pattern above is the supported path to reactivity.
- No
ctx.sqlinquery/mutation. Enforced by thehyperdrive_outside_actionadvisor lint. - No bundled driver / ORM. You own driver choice and lifecycle.
Public API
| Export | Purpose |
|---|---|
createHyperdrive(binding) | Lift connectionString + discrete parts off the binding |
fromPostgresJs(client) | Wrap a postgres.js client as a SqlClient |
fromNodePg(client) | Wrap a pg Client/Pool as a SqlClient |
fromMysql2(connection) | Wrap a mysql2/promise connection/pool as a SqlClient |
SqlClient, HyperdriveLike, HyperdriveConnection, PostgresJsLike, NodePgLike, Mysql2Like | Type-only |
From @lunora/hyperdrive/global (reactive .global() backend):
| Export | Purpose |
|---|---|
createPostgresGlobalCtxDb(client, options) | Build a reactive Postgres .global() writer (globalDb) |
createMysqlGlobalCtxDb(connection, options) | Build a reactive MySQL .global() writer (needs FOUND_ROWS) |
createHyperdriveGlobalCtxDb({ engine, exec, … }) | General factory the two convenience constructors call |
buildPgExec(client) / buildMysqlExec(conn) | Wrap a driver as the store's SqlExec |
postgresDialect / mysqlDialect | The engine dialects |
See also
- Concepts: queries & mutations — why actions are the only non-deterministic context
- Concepts: advisors — the
hyperdrive_outside_actionlint - Concepts: real-time — what the change-feed tracks