anolilab/lunora/lunora-setup-hyperdrive

Claude Code and agent skill by anolilab/lunora

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

Connects an existing, externally owned Postgres/MySQL database to a Lunora app through Cloudflare Hyperdrive. Lunora does not own its schema. Covers `@lunora/hyperdrive`, the action-only, non-reactive `ctx.sql` client (`createHyperdrive` + `fromPostgresJs`/`fromNodePg`/`fromMysql2`, wired with the app builder's `.hyperdrive()`), the `HYPERDRIVE` binding and `wrangler hyperdrive create`, and making external rows live through a projection or a `.source()` ingest table. Use when the user wants to…

Install

npx vskill@latest install anolilab/lunora/lunora-setup-hyperdrive

SKILL.md

Lunora Setup Hyperdrive

@lunora/hyperdrive exposes a Cloudflare Hyperdrive binding as a driver-agnostic ctx.sql client that is available only inside an action. It integrates a database Lunora doesn't own. Don't use it to replace defineSchema, because you'd lose realtime, OCC, optimistic updates, the offline queue and the advisors.

`ctx.sql` is not reactive. Lunora cannot see this database, so subscriptions
do not re-run when its rows change, including rows you write through ctx.sql.
Queries are non-deterministic, so ctx.sql exists only on ActionCtx.
The hyperdrive_outside_action advisor lint flags any use in a
query/mutation. To make the data live, see Step 5. If you instead want Lunora-owned tables
stored in Postgres/MySQL and fully reactive, use lunora-setup-hyperdrive-global.

Step 1: Install the package + one driver

bash
pnpm add @lunora/hyperdrive
pnpm add postgres # → fromPostgresJs ($1, $2 placeholders)
# or: pnpm add pg → fromNodePg ($1, $2)
# or: pnpm add mysql2 → fromMysql2 (?)

The drivers are optional peer dependencies, so none ships with the package. The package never rewrites SQL, so use your driver's native placeholders.

Step 2: Create the binding

Nothing to write by hand: lunora dev and lunora deploy add a HYPERDRIVE binding to wrangler.jsonc with a placeholder id.

  • Dev. ctx.sql runs on an embedded Postgres (PGlite), data in

.wrangler/state/lunora-pglite. Install its two optional packages:

bash
pnpm add -D @electric-sql/pglite @electric-sql/pglite-socket

To use your own database, set localConnectionString on the binding or CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<BINDING>; neither is overridden.

  • Deploy. Give lunora deploy the origin and a token that may edit Hyperdrive:
bash
LUNORA_HYPERDRIVE_URL="postgres://user:pass@host:5432/db" lunora deploy # gitleaks:allow -- placeholder, not a real secret

(CLOUDFLARE_API_TOKEN with Hyperdrive: Edit, and CLOUDFLARE_ACCOUNT_ID; the variable is LUNORA_<BINDING>_URL.) Lunora creates the config, or reuses one of the same name, and writes its id into wrangler.jsonc. The connection string is never written or logged.

To manage the config yourself, run wrangler hyperdrive create my-db --connection-string=... and put the id in the binding. Validation fails without a binding.

Hyperdrive caches read results (up to 60 s plus 15 s stale by default) and a write does not invalidate them. For reads that must see your own writes, add a second config created with --caching-disabled and bind it under another name.

Step 3: Regenerate types

bash
lunora codegen

When ctx.sql is used, codegen adds readonly sql: SqlClient to ActionCtx only. It also emits a .hyperdrive() method on the generated app builder.

Step 4: Wire the client on the app builder

ctx.sql is readonly, so assigning it in a handler is a TS2540 error. Codegen can't build the client because it depends on your driver, so supply a thunk instead. It runs once per shard construction, not per request:

ts title="src/server.ts"
import type { HyperdriveLike } from "@lunora/hyperdrive";
import { createHyperdrive, fromPostgresJs } from "@lunora/hyperdrive";
import { defineApp } from "../lunora/_generated/app.js";
const app = defineApp<Env>()
.shard((env) => env.SHARD)
.hyperdrive((env) =>
fromPostgresJs(async () => {
const { default: postgres } = await import("postgres");
return postgres(createHyperdrive(env.HYPERDRIVE as HyperdriveLike).connectionString, { fetch_types: false, max: 5 });
}),
)
.build();
export const ShardDO = app.ShardDO;

The adapters take a loader, not a driver. It runs on the first query, so the driver stays out of the Worker's startup graph; never import postgres from "postgres" statically in the entry. For pg, the loader may await client.connect() before returning.

Then query from any action:

ts
import { LunoraError } from "lunorash/server";
import { action, v } from "#lunora/_generated/server.js";
export const listLegacyOrders = action.input({ orgId: v.string() }).action(async ({ ctx, args: { orgId } }) => {
if (!ctx.auth.userId) {
throw new LunoraError("UNAUTHORIZED", "not signed in");
}
// `orgId` comes from the client: the join scopes it to orgs the caller belongs to.
return ctx.sql.query<{ id: string; total: number }>(
"select o.id, o.total from orders o join org_members m on m.org_id = o.org where o.org = $1 and m.user_id = $2",
[orgId, ctx.auth.userId],
);
});

Verify: call the action from the studio's function runner and check that it returns rows.

Step 5 (optional): Make external data reactive

Pick one approach:

A. Projection from an action. This works when your app is the writer. Write the row into a defineSchema table through a mutation. That write is tracked:

ts
import { LunoraError } from "lunorash/server";
import { internal } from "#lunora/_generated/internal.js";
const [row] = await ctx.sql.query<{ id: string; total: number }>("select id, total from orders where id = $1", [id]);
if (!row) {
throw new LunoraError("NOT_FOUND", `order ${id} not found`);
}
// Re-runs live queries over `orders`. ctx.run* takes a generated reference, not a "file:fn" string.
await ctx.runMutation(internal.orders.upsert, { id: row.id, total: row.total });

B. A `.source()` table. This works when other systems also write the database. Lunora polls the external query on the shard's alarm, diffs the result, materializes it into the DO's SQLite and notifies subscribers, so you don't write an action or a cron:

ts
// lunora/schema.ts
messages: defineTable({ body: v.string(), channelId: v.string() })
.shardBy("channelId")
.source({
binding: "HYPERDRIVE",
query: 'select id, body, channel_id as "channelId" from messages where channel_id = $1',
tenantBy: (shardKey) => [shardKey], // required under .shardBy(): the tenant boundary
}),
ts
// worker entry: one client per binding name
.sourceClient((env, binding) => fromPostgresJs(async () => {
const { default: postgres } = await import("postgres");
return postgres((env[binding] as { connectionString: string }).connectionString, { fetch_types: false, max: 5 });
}))

How .source() behaves:

  • Freshness depends on the poll interval: every alarm tick by default,

refresh: { everyMs } to throttle it, or "manual".

  • The table is read-only from Lunora: rows come only from the ingest loop.
  • The default mode: "full-pull" re-reads the whole slice on every tick, which

works up to about 10k rows. For larger slices, use mode: "incremental" with a cursor, plus either reconcileEveryMs or softDeleteColumn so deletes are still detected.

  • A source table can't also be .global().

Common Pitfalls

  1. `ctx.sql` in a query/mutation. It's action-only. Move the SQL into an action.
  2. Expecting `ctx.sql` writes to update subscriptions. They don't. Use Step 5.
  3. Placeholder `id` at deploy. The deploy stops until LUNORA_<BINDING>_URL is set

(Lunora then creates the config) or the real id is pasted in. An empty id is not provisioned: wrangler rejects it, so remove the entry and let Lunora add it again.

  1. Wrong placeholders. Use $1 for postgres/pg and ? for mysql2.
  2. Array or JSON params on postgres.js with `fetch_types: false`. A JS array bound to

= ANY($1) fails as a malformed array literal: bind JSON.stringify(list) to IN (SELECT jsonb_array_elements_text($1::text::jsonb)). Cast JSON params $1::text::jsonb, since a bare $1::jsonb gets your string JSON-encoded twice.

  1. Unscoped `.source()` under `.shardBy()`. Without tenantBy, every tenant's

rows are copied into each shard. defineSchema throws, and the external_source_unscoped lint fails the build.

Security

  • Security scanPassed, 96 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
5

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/anolilab/lunora/lunora-setup-hyperdrive/badge)](https://verified-skill.com/skills/anolilab/lunora/lunora-setup-hyperdrive)

Something wrong with this skill or its page? Tell us.