PackagesHyperdrive

@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:

  1. Non-deterministic. A SQL query over the network is an external, mutable read — exactly like fetch. It is therefore forbidden in query/mutation and available only on ActionCtx. The hyperdrive_outside_action advisor lint flags any ctx.sql reached from a query or mutation.
  2. Non-reactive. Live queries track writes to the DO's SQLite / D1. An UPDATE issued 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/hyperdrive
npm install @lunora/hyperdrive
yarn add @lunora/hyperdrive
bun add @lunora/hyperdrive

No 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        → fromMysql2

Set up the binding

  1. 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 secret

    This prints an id.

  2. Add the binding to wrangler.jsonc. Use localConnectionString so 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

DriverAdapterPlaceholders
postgres (postgres.js)fromPostgresJs$1, $2, …
pg (node-postgres)fromNodePg$1, $2, …
mysql2/promisefromMysql2?

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 });
});

Per-agent shape ingest (multitenant Postgres → per-tenant DOs)

A common shape of the pattern above: a multitenant Postgres is the source of truth, and you want each tenant (or agent) to work against only its own slice, materialized into its own sharded Durable Object — with clients riding the same live slice. .shardBy() gives each tenant a private DO + SQLite; pullSourceRows (read side) and materializeExternalRows (write side) bridge the Postgres slice into it; defineShape carries it to clients with no extra wiring.

// lunora/schema.ts — one DO per tenant, plus a shape clients subscribe to
export default defineSchema({
    documents: defineTable({ title: v.string(), body: v.string(), orgId: v.string() }).shardBy("orgId").externallyManaged(), // rows are written by the ingest bridge, not user mutations
});

export const tenantDocs = defineShape({ table: "documents", where: () => ({}) });
// The action pulls THIS tenant's slice (the shard key binds into the WHERE — the
// tenant-isolation boundary), then a mutation materializes it.
import { createHyperdrive, fromPostgresJs, pullSourceRows } from "@lunora/hyperdrive";
import postgres from "postgres";

import { api } from "@/lunora/_generated/api";
import { action, mutation, v } from "@/lunora/_generated/server";
import { materializeExternalRows } from "@lunora/do";

export const refreshTenant = action.input({ orgId: v.string() }).action(async ({ ctx, args: { orgId } }) => {
    const { connectionString } = createHyperdrive(ctx.env.HYPERDRIVE);
    ctx.sql = fromPostgresJs(postgres(connectionString));

    const docs = await pullSourceRows(ctx.sql, {
        query: 'select id, title, body, org_id as "orgId" from documents where org_id = $1',
        params: [orgId], // ← tenant scope. NEVER omit this on a sharded source.
        map: (row) => ({ body: row.body, orgId: row.orgId, title: row.title }),
    });

    await ctx.runMutation(api.documents.ingest, { orgId, docs });
});

export const ingest = mutation.input({ orgId: v.string(), docs: v.array(v.any()) }).mutation(async ({ ctx, args: { docs } }) => {
    // Empty baseline = upsert-only (inserts + updates, no deletes). The materialized
    // rows append to the CDC log, so `tenantDocs` subscribers are poked live.
    await materializeExternalRows(ctx.db, docs, new Map(), { table: "documents" });
});

Clients consume the same slice with the shape they'd use over any table:

const docs = useShape(api.shapes.tenantDocs, {}); // RLS-filtered, live, per-tenant

Refresh on a schedule with @lunora/scheduler (ctx.scheduler.runAfter(...) / a cron) so each tenant's slice stays current.

Tenant scoping is the correctness boundary. Per-shard SQLite isolation only controls where rows land — not what the query pulls. The shard key MUST bind into the source WHERE (as a parameter), or every tenant's DO would replicate the whole table. Deletes: the empty-baseline call above is upsert-only; to propagate upstream deletes, pass the table's current membership as the baseline so materializeExternalRows can diff it.

Or skip the boilerplate: the declarative .source() modifier

The manual bridge above is the escape hatch. For the common case, declare the source on the table and Lunora runs the whole loop for you — full-pull diff, tenant scoping, and a poll cadence — on the DO's alarm (no action/mutation/cron to write):

// lunora/schema.ts
export default defineSchema({
    documents: defineTable({ title: v.string(), body: v.string(), orgId: v.string() })
        .shardBy("orgId")
        .source({
            binding: "HYPERDRIVE_DOCS",
            query: 'select id, title, body, org_id as "orgId" from documents where org_id = $1',
            tenantBy: (shardKey) => [shardKey], // mandatory under .shardBy() — the tenant boundary
        }),
});

export const tenantDocs = defineShape({ table: "documents", where: () => ({}) });

You provide the driver once, when constructing the shard DO — one resolver for every sourced binding (build the SqlClient exactly as above). Lunora memoizes it per binding and polls each tenant's slice on the alarm:

createShardDO({
    sourceClient: (env, binding) => fromPostgresJs(postgres((env[binding] as { connectionString: string }).connectionString)),
});

Tenant scoping is enforced two ways: defineSchema throws at load if a sourced

  • .shardBy() table omits tenantBy (the runtime fail-safe), and the external_source_unscoped advisor lint flags it earlier, at build time, in your terminal + Studio. defineSchema likewise rejects combining .source() with .global() (contradictory tiers). Clients consume the slice with useShape(api.shapes.tenantDocs) — no client change, since it is an ordinary table.

Refresh cadence. Omit refresh to poll on every DO alarm tick (the floor), or pass refresh: { everyMs } to throttle a large slice to at most one pull per interval. refresh: "manual" tells Lunora not to auto-poll — use it when you drive the refresh yourself (the manual pullSourceRowsmaterializeExternalRows bridge above, on your own schedule). mode: "incremental" is reserved for a future release; defineSchema rejects it today (only the default full-pull is implemented).

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's SqlExec.
  • 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.sql in query/mutation. Enforced by the hyperdrive_outside_action advisor lint.
  • No bundled driver / ORM. You own driver choice and lifecycle.

Public API

ExportPurpose
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
pullSourceRows(sql, { query, params, idColumn?, map? })Run a tenant query + project rows to Lunora docs (read side of the ingest bridge)
projectSourceRow(row, { idColumn?, map? })Project one external row → a document with _id
SqlClient, HyperdriveLike, HyperdriveConnection, PostgresJsLike, NodePgLike, Mysql2Like, ProjectOptions, PullSourceOptionsType-only

From @lunora/hyperdrive/global (reactive .global() backend):

ExportPurpose
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 / mysqlDialectThe engine dialects

See also