@lunora/cli 0.0.0 → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +105 -0
- package/README.md +109 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/bin.mjs +11 -0
- package/dist/index.d.mts +852 -0
- package/dist/index.d.ts +852 -0
- package/dist/index.mjs +19 -0
- package/dist/packem_chunks/handler.mjs +76 -0
- package/dist/packem_chunks/handler10.mjs +22 -0
- package/dist/packem_chunks/handler11.mjs +192 -0
- package/dist/packem_chunks/handler12.mjs +131 -0
- package/dist/packem_chunks/handler13.mjs +65 -0
- package/dist/packem_chunks/handler14.mjs +58 -0
- package/dist/packem_chunks/handler15.mjs +79 -0
- package/dist/packem_chunks/handler16.mjs +41 -0
- package/dist/packem_chunks/handler17.mjs +105 -0
- package/dist/packem_chunks/handler18.mjs +172 -0
- package/dist/packem_chunks/handler19.mjs +89 -0
- package/dist/packem_chunks/handler2.mjs +114 -0
- package/dist/packem_chunks/handler20.mjs +94 -0
- package/dist/packem_chunks/handler21.mjs +311 -0
- package/dist/packem_chunks/handler3.mjs +204 -0
- package/dist/packem_chunks/handler4.mjs +33 -0
- package/dist/packem_chunks/handler5.mjs +49 -0
- package/dist/packem_chunks/handler6.mjs +91 -0
- package/dist/packem_chunks/handler7.mjs +42 -0
- package/dist/packem_chunks/handler8.mjs +174 -0
- package/dist/packem_chunks/handler9.mjs +16 -0
- package/dist/packem_chunks/planDevCommand.mjs +543 -0
- package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
- package/dist/packem_chunks/runDeployCommand.mjs +504 -0
- package/dist/packem_chunks/runInitCommand.mjs +652 -0
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
- package/dist/packem_chunks/runResetCommand.mjs +41 -0
- package/dist/packem_chunks/runRpcCommand.mjs +68 -0
- package/dist/packem_shared/COMMANDS-1V_KEx35.mjs +905 -0
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
- package/dist/packem_shared/command-BDXcJCCJ.mjs +14 -0
- package/dist/packem_shared/createLogger-CHPNjFw2.mjs +73 -0
- package/dist/packem_shared/defaultSpawner-DxI3mebw.mjs +43 -0
- package/dist/packem_shared/diffSnapshots-RR2ZE8Ya.mjs +161 -0
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
- package/dist/packem_shared/features-ocSSpZtS.mjs +24 -0
- package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
- package/dist/packem_shared/output-format-7gyGR3h8.mjs +17 -0
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
- package/dist/packem_shared/runAddCommand-BZGkRnBs.mjs +693 -0
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
- package/dist/packem_shared/schemaIrToSnapshot-aBTo7TM5.mjs +43 -0
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
- package/package.json +61 -18
- package/skills/README.md +29 -0
- package/skills/lunora/SKILL.md +83 -0
- package/skills/lunora-create-package/SKILL.md +129 -0
- package/skills/lunora-deploy/SKILL.md +150 -0
- package/skills/lunora-functions/SKILL.md +182 -0
- package/skills/lunora-migration-helper/SKILL.md +194 -0
- package/skills/lunora-performance-audit/SKILL.md +143 -0
- package/skills/lunora-quickstart/SKILL.md +240 -0
- package/skills/lunora-realtime/SKILL.md +177 -0
- package/skills/lunora-setup-auth/SKILL.md +170 -0
- package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
- package/skills/lunora-setup-mail/SKILL.md +151 -0
- package/skills/lunora-setup-scheduler/SKILL.md +157 -0
- package/skills/lunora-setup-storage/SKILL.md +154 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-hyperdrive
|
|
3
|
+
description: Connects an existing Postgres/MySQL database to a Lunora app from an action via Cloudflare Hyperdrive. Use for `@lunora/hyperdrive`, `ctx.sql`, `createHyperdrive` + `fromPostgresJs`/`fromNodePg`/`fromMysql2`, the `HYPERDRIVE` binding, `wrangler hyperdrive create`, and the action-only / non-reactive guardrails (live queries don't track external writes).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup Hyperdrive
|
|
7
|
+
|
|
8
|
+
Wire an **existing** Postgres/MySQL database into a Lunora app using
|
|
9
|
+
`@lunora/hyperdrive`, which surfaces a [Cloudflare
|
|
10
|
+
Hyperdrive](https://developers.cloudflare.com/hyperdrive/) binding's connection
|
|
11
|
+
string and a driver-agnostic `ctx.sql` client — usable **only inside an
|
|
12
|
+
`action`**.
|
|
13
|
+
|
|
14
|
+
> **Integrate, don't replace.** Hyperdrive talks to a database Lunora has no
|
|
15
|
+
> visibility into. Queries through `ctx.sql` are **non-deterministic** (forbidden
|
|
16
|
+
> in `query`/`mutation`, enforced by the `hyperdrive_outside_action` advisor
|
|
17
|
+
> lint) and external writes are **invisible to Lunora live queries** —
|
|
18
|
+
> subscriptions will NOT re-run when external rows change. Use it to read/write a
|
|
19
|
+
> legacy DB from an action; if you want that data reactive, write a projection
|
|
20
|
+
> into a `defineSchema` DO/D1 table.
|
|
21
|
+
|
|
22
|
+
## When to Use
|
|
23
|
+
|
|
24
|
+
- Reading/writing an existing Postgres or MySQL database from a Lunora action.
|
|
25
|
+
- Incrementally migrating off a legacy DB while standing up Lunora alongside it.
|
|
26
|
+
- Backfilling/syncing external rows into reactive Lunora tables (projection).
|
|
27
|
+
|
|
28
|
+
## When Not to Use
|
|
29
|
+
|
|
30
|
+
- You want the external data to be **reactive** out of the box — it can't be;
|
|
31
|
+
Hyperdrive writes don't hit the change-feed. Project into a `defineSchema`
|
|
32
|
+
table instead.
|
|
33
|
+
- You're reaching for `ctx.sql` inside a `query` or `mutation` — forbidden by
|
|
34
|
+
design (the advisor lint flags it). Move the access into an `action`.
|
|
35
|
+
- The app has no Lunora backend yet — use `lunora-quickstart` first.
|
|
36
|
+
- You'd be replacing `defineSchema` entirely — then you lose realtime, OCC,
|
|
37
|
+
optimistic updates, the offline queue, and the advisors. Use Lunora's own data
|
|
38
|
+
layer for app state.
|
|
39
|
+
|
|
40
|
+
## Workflow
|
|
41
|
+
|
|
42
|
+
1. Install `@lunora/hyperdrive` and one SQL driver.
|
|
43
|
+
2. Create the Hyperdrive config (`wrangler hyperdrive create`) and add the
|
|
44
|
+
`HYPERDRIVE` binding to `wrangler.jsonc`.
|
|
45
|
+
3. Regenerate types with `lunora codegen` so `ctx.sql` appears on `ActionCtx`.
|
|
46
|
+
4. Read/write the external DB from an action via `ctx.sql`.
|
|
47
|
+
5. (Optional) Project external rows into a `defineSchema` table to make them
|
|
48
|
+
reactive.
|
|
49
|
+
|
|
50
|
+
## Step 1: Install the package + a driver
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm add @lunora/hyperdrive
|
|
54
|
+
# choose ONE driver (optional peer deps — none is bundled):
|
|
55
|
+
pnpm add postgres # postgres.js → fromPostgresJs ($1, $2 placeholders)
|
|
56
|
+
# or
|
|
57
|
+
pnpm add pg # node-postgres → fromNodePg ($1, $2 placeholders)
|
|
58
|
+
# or
|
|
59
|
+
pnpm add mysql2 # mysql2 → fromMysql2 (? placeholders)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Step 2: Create the binding
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host:5432/db" # gitleaks:allow -- placeholder, not a real secret
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This prints an `id`. Add the binding to `wrangler.jsonc`, using
|
|
69
|
+
`localConnectionString` so `lunora dev` connects to your DB directly (no edge
|
|
70
|
+
proxy locally):
|
|
71
|
+
|
|
72
|
+
```jsonc
|
|
73
|
+
{
|
|
74
|
+
"hyperdrive": [
|
|
75
|
+
{
|
|
76
|
+
"binding": "HYPERDRIVE",
|
|
77
|
+
"id": "<the id from the command above>",
|
|
78
|
+
"localConnectionString": "postgres://user:pass@localhost:5432/db", // gitleaks:allow -- placeholder, not a real secret
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Lunora validates this binding (errors if `binding` is missing; warns if `id` is
|
|
85
|
+
empty/placeholder — it can't connect), but it does **not** auto-write the `id`:
|
|
86
|
+
that's a remote resource only `wrangler hyperdrive create` can mint. Importing
|
|
87
|
+
`@lunora/hyperdrive` surfaces a hint reminding you to add it.
|
|
88
|
+
|
|
89
|
+
## Step 3: Regenerate types
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
lunora codegen
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
When codegen sees `ctx.sql` used, it adds `sql: SqlClient` to **`ActionCtx`
|
|
96
|
+
only** — never `QueryCtx`/`MutationCtx` — with a JSDoc restating the
|
|
97
|
+
determinism/realtime caveat.
|
|
98
|
+
|
|
99
|
+
## Step 4: Use `ctx.sql` from an action
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createHyperdrive, fromPostgresJs } from "@lunora/hyperdrive";
|
|
103
|
+
import postgres from "postgres";
|
|
104
|
+
|
|
105
|
+
import { action, v } from "@lunora/server";
|
|
106
|
+
|
|
107
|
+
export const listLegacyOrders = action.input({ orgId: v.string() }).action(async ({ ctx, args: { orgId } }) => {
|
|
108
|
+
const { connectionString } = createHyperdrive(ctx.env.HYPERDRIVE);
|
|
109
|
+
ctx.sql = fromPostgresJs(postgres(connectionString));
|
|
110
|
+
|
|
111
|
+
return ctx.sql.query<{ id: string; total: number }>("select id, total from orders where org = $1", [orgId]);
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The package never rewrites SQL — use your driver's native placeholders (`$1` for
|
|
116
|
+
Postgres, `?` for MySQL).
|
|
117
|
+
|
|
118
|
+
## Step 5 (optional): Make it reactive via a projection
|
|
119
|
+
|
|
120
|
+
External writes don't hit the change-feed, so a subscription won't re-fire on a
|
|
121
|
+
Postgres change. To make external data reactive, write a projection into a
|
|
122
|
+
`defineSchema` table from the same action — that write _is_ tracked:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const [row] = await ctx.sql.query<{ id: string; total: number }>("select id, total from orders where id = $1", [id]);
|
|
126
|
+
|
|
127
|
+
// This write re-runs live queries reading `orders`:
|
|
128
|
+
await ctx.runMutation("orders:upsert", { id: row.id, total: row.total });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Common Pitfalls
|
|
132
|
+
|
|
133
|
+
1. **`ctx.sql` in a query/mutation.** It's action-only by design; the
|
|
134
|
+
`hyperdrive_outside_action` advisor lint flags it. Move the SQL into an
|
|
135
|
+
action.
|
|
136
|
+
2. **Expecting external writes to be reactive.** They aren't — Lunora can't see
|
|
137
|
+
them. Project into a `defineSchema` table if you need reactivity.
|
|
138
|
+
3. **Placeholder / empty `id`.** A Hyperdrive binding with no real `id` can't
|
|
139
|
+
connect (Lunora warns). Run `wrangler hyperdrive create` and paste the id.
|
|
140
|
+
4. **Bundling a driver as a hard dependency.** Drivers are optional peer deps —
|
|
141
|
+
install the one you use; don't assume one ships with the package.
|
|
142
|
+
5. **Wrong placeholder syntax.** `$1, $2` for Postgres drivers, `?` for mysql2.
|
|
143
|
+
The package does not rewrite SQL.
|
|
144
|
+
|
|
145
|
+
## Checklist
|
|
146
|
+
|
|
147
|
+
- [ ] `@lunora/hyperdrive` + one driver (`postgres`/`pg`/`mysql2`) installed.
|
|
148
|
+
- [ ] `wrangler hyperdrive create` run; `HYPERDRIVE` binding added to
|
|
149
|
+
`wrangler.jsonc` with a real `id` and a `localConnectionString` for dev.
|
|
150
|
+
- [ ] `lunora codegen` run so `ctx.sql` is typed on `ActionCtx`.
|
|
151
|
+
- [ ] All `ctx.sql` access lives in actions (advisor clean — no
|
|
152
|
+
`hyperdrive_outside_action`).
|
|
153
|
+
- [ ] If reactivity is needed, external rows are projected into a `defineSchema`
|
|
154
|
+
table.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-hyperdrive-global
|
|
3
|
+
description: Use PlanetScale (Postgres/MySQL via Cloudflare Hyperdrive) as a first-class, REACTIVE `.global()` storage backend for a Lunora app — and migrate an existing D1 `.global()` dataset to it. Use for `@lunora/hyperdrive/global`, `.global({ backend: "hyperdrive" })`, `createHyperdriveGlobalCtxDb`, the app-builder `.hyperdriveGlobal()` declaration, the `HYPERDRIVE` binding pointing at PlanetScale, and `lunora migrate d1-to-hyperdrive`. NOT the same as `@lunora/hyperdrive` (which is an action-only, non-reactive `ctx.sql`).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup: Hyperdrive Global Backend (Postgres/MySQL)
|
|
7
|
+
|
|
8
|
+
Store Lunora `.global()` tables in a **Postgres or MySQL database reached
|
|
9
|
+
through [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/)**
|
|
10
|
+
— **PlanetScale**, Neon, RDS, or any Hyperdrive-reachable database — instead of
|
|
11
|
+
D1, as a **drop-in, fully reactive** backend. (PlanetScale on Cloudflare is the
|
|
12
|
+
headline use case; nothing here is PlanetScale-specific.)
|
|
13
|
+
|
|
14
|
+
> **`@lunora/hyperdrive/global` vs. `@lunora/hyperdrive` (`ctx.sql`).** The
|
|
15
|
+
> `ctx.sql` surface is an action-only, **non-reactive** escape hatch for
|
|
16
|
+
> integrating a legacy database. `@lunora/hyperdrive/global` is different: Lunora
|
|
17
|
+
> **owns** the schema (column-per-field, like D1) and routes every write through
|
|
18
|
+
> its own store core, so live queries stay reactive — the writer is injected as
|
|
19
|
+
> `globalDb` and the shard DO's broadcast hook re-runs subscriptions exactly as
|
|
20
|
+
> with D1.
|
|
21
|
+
|
|
22
|
+
## When to Use
|
|
23
|
+
|
|
24
|
+
- You want `.global()` (cross-tenant) tables to live in PlanetScale rather than
|
|
25
|
+
D1 — e.g. for larger datasets, existing SQL tooling, or unified Cloudflare
|
|
26
|
+
billing.
|
|
27
|
+
- You're migrating an existing D1 `.global()` dataset to PlanetScale.
|
|
28
|
+
|
|
29
|
+
## When Not to Use
|
|
30
|
+
|
|
31
|
+
- You only need to read/write a **legacy** external DB from an action — use
|
|
32
|
+
`@lunora/hyperdrive` (`ctx.sql`) instead; it's lighter and action-scoped.
|
|
33
|
+
- Your global data is small and D1 is fine — bare `.global()` (D1) needs no
|
|
34
|
+
extra binding or driver.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
PlanetScale reuses Lunora's dialect-parameterized store core (the same one D1
|
|
39
|
+
uses): a `SqlDialect` (Postgres or MySQL) shapes the SQL, and a Hyperdrive-backed
|
|
40
|
+
`SqlExec` runs it from inside the Durable Object that hosts the global writer.
|
|
41
|
+
Values are stored SQLite-shaped (boolean → 1/0, JSON → text/json, bigint →
|
|
42
|
+
decimal), so the value codec is shared with D1.
|
|
43
|
+
|
|
44
|
+
## Step 1: Install the package + a driver
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm add @lunora/hyperdrive/global
|
|
48
|
+
# choose the driver matching your engine (optional peer deps — none is bundled):
|
|
49
|
+
pnpm add postgres # PlanetScale Postgres → fromPostgresJs
|
|
50
|
+
# or
|
|
51
|
+
pnpm add mysql2 # PlanetScale MySQL → mysql2/promise
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Step 2: Create the Hyperdrive binding pointing at PlanetScale
|
|
55
|
+
|
|
56
|
+
Create a PlanetScale database from the Cloudflare dashboard (unified billing),
|
|
57
|
+
then a Hyperdrive config over its connection string:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host/db" # gitleaks:allow -- placeholder
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Add the binding to `wrangler.jsonc` (use `localConnectionString` for `lunora dev`):
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
{
|
|
67
|
+
"hyperdrive": [
|
|
68
|
+
{ "binding": "HYPERDRIVE", "id": "<id from the command>", "localConnectionString": "postgres://user:pass@localhost:5432/db" }, // gitleaks:allow -- placeholder
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
> **Read-your-writes:** point the Hyperdrive config at the **primary** (or pin
|
|
74
|
+
> writes to it) so a write then immediate read isn't served by a stale replica.
|
|
75
|
+
|
|
76
|
+
## Step 3: Mark tables `.global({ backend: "hyperdrive" })`
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// lunora/schema.ts
|
|
80
|
+
import { defineSchema, defineTable, v } from "@lunora/server";
|
|
81
|
+
|
|
82
|
+
export default defineSchema({
|
|
83
|
+
settings: defineTable({ key: v.string(), value: v.string() }).global({ backend: "hyperdrive" }),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A bare `.global()` stays on D1. **One backend per app** — mixing D1- and
|
|
88
|
+
PlanetScale-backed global tables in the same app isn't supported yet (codegen
|
|
89
|
+
errors clearly).
|
|
90
|
+
|
|
91
|
+
## Step 4: Wire `.hyperdriveGlobal()` in the app composition
|
|
92
|
+
|
|
93
|
+
Codegen emits a `.hyperdriveGlobal()` builder method. Supply the engine and an `exec`
|
|
94
|
+
built from the Hyperdrive binding (cache the driver on the DO instance; rebuild
|
|
95
|
+
lazily after hibernation):
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { buildPgExec } from "@lunora/hyperdrive/global";
|
|
99
|
+
import { fromPostgresJs } from "@lunora/hyperdrive";
|
|
100
|
+
import postgres from "postgres";
|
|
101
|
+
|
|
102
|
+
export default defineApp<Env>()
|
|
103
|
+
.shard((env) => env.SHARD)
|
|
104
|
+
.hyperdriveGlobal({
|
|
105
|
+
engine: "postgres",
|
|
106
|
+
exec: (env) => buildPgExec(fromPostgresJs(postgres(env.HYPERDRIVE.connectionString))),
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For MySQL, create the pool with the **`FOUND_ROWS`** flag (the optimistic-concurrency
|
|
111
|
+
guard needs matched-row counts, or idempotent writes raise spurious conflicts):
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { buildMysqlExec } from "@lunora/hyperdrive/global";
|
|
115
|
+
import mysql from "mysql2/promise";
|
|
116
|
+
|
|
117
|
+
.hyperdriveGlobal({
|
|
118
|
+
engine: "mysql",
|
|
119
|
+
exec: (env) => buildMysqlExec(mysql.createPool({ uri: env.HYPERDRIVE.connectionString, flags: ["FOUND_ROWS"] })),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Step 5: Regenerate + run
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
lunora codegen # wires config.hyperdriveGlobal and the .hyperdriveGlobal() method
|
|
127
|
+
lunora dev
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Tables auto-provision on first use (the runtime runs the PlanetScale DDL through
|
|
131
|
+
the dialect — no manual migration needed), and subscriptions are reactive.
|
|
132
|
+
|
|
133
|
+
## Migrating an existing D1 dataset → PlanetScale
|
|
134
|
+
|
|
135
|
+
Blue-green: deploy the new PlanetScale-backed worker alongside the old D1 one,
|
|
136
|
+
then copy the `.global()` data:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
lunora migrate d1-to-hyperdrive \
|
|
140
|
+
--from-url https://old-d1.example.com --from-token "$D1_ADMIN_TOKEN" \
|
|
141
|
+
--to-url https://new-ps.example.com --to-token "$PS_ADMIN_TOKEN" \
|
|
142
|
+
--tables settings,orders # omit to move every global table
|
|
143
|
+
# --out dump.ndjson # keep the intermediate dump to inspect
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It streams the source's global rows to NDJSON, imports them into the target
|
|
147
|
+
(whose `.global({ backend: "hyperdrive" })` tables route the writes to
|
|
148
|
+
PlanetScale), and verifies the row counts match. Rows whose `_id` already exists
|
|
149
|
+
are reported as conflicts, not duplicated.
|
|
150
|
+
|
|
151
|
+
> **Direct external writes** to PlanetScale (bypassing Lunora) are invisible to
|
|
152
|
+
> live queries — same as D1. Let Lunora own the writes.
|
|
153
|
+
|
|
154
|
+
## Common Pitfalls
|
|
155
|
+
|
|
156
|
+
1. **Mixing backends.** One global backend per app (codegen errors on a mix).
|
|
157
|
+
2. **Missing driver.** `postgres`/`mysql2` are optional peer deps — install the
|
|
158
|
+
one matching your engine.
|
|
159
|
+
3. **Stale-replica reads.** Point Hyperdrive at the primary for read-your-writes.
|
|
160
|
+
4. **Expecting external writes to be reactive.** They aren't — route writes
|
|
161
|
+
through Lunora (`ctx.db`).
|
|
162
|
+
|
|
163
|
+
## Checklist
|
|
164
|
+
|
|
165
|
+
- [ ] `@lunora/hyperdrive/global` + the engine's driver installed.
|
|
166
|
+
- [ ] Hyperdrive binding (`HYPERDRIVE`) created over the PlanetScale connection
|
|
167
|
+
string, with a `localConnectionString` for dev.
|
|
168
|
+
- [ ] Target tables marked `.global({ backend: "hyperdrive" })` (one backend per app).
|
|
169
|
+
- [ ] `.hyperdriveGlobal({ engine, exec })` wired in the app composition.
|
|
170
|
+
- [ ] `lunora codegen` run; `lunora dev` serves the tables reactively.
|
|
171
|
+
- [ ] (Migrating) `lunora migrate d1-to-hyperdrive` run; row counts verified.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-mail
|
|
3
|
+
description: Adds transactional email to a Lunora app. Use for sending mail (verification, password reset, invites, notifications) via `lunora registry add mail`, the `sendEmail` / `queueEmail` actions, the `SEND_EMAIL` Cloudflare Email Workers binding, Resend, React email templates, and the dev mail catcher.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup Mail
|
|
7
|
+
|
|
8
|
+
Wire transactional email into a Lunora app using the `mail` registry item, which
|
|
9
|
+
is built on `@lunora/mail` (a Cloudflare Email Workers transport with
|
|
10
|
+
header-injection-safe address handling) and exposes a `sendEmail` action plus a
|
|
11
|
+
fire-and-forget `queueEmail` action. In dev, every send is captured into the
|
|
12
|
+
Studio Mail tab instead of going out.
|
|
13
|
+
|
|
14
|
+
## When to Use
|
|
15
|
+
|
|
16
|
+
- Sending app→user mail: invites, notifications, receipts.
|
|
17
|
+
- Delivering verification / password-reset mail from `@lunora/auth`.
|
|
18
|
+
- Using a React (`react-email`) template or a hosted provider (Resend).
|
|
19
|
+
|
|
20
|
+
## When Not to Use
|
|
21
|
+
|
|
22
|
+
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
23
|
+
- Mail is already installed and you just want to send — call
|
|
24
|
+
`ctx.runAction(api.mail.sendEmail, …)` or `client.action("mail/sendEmail", …)`.
|
|
25
|
+
|
|
26
|
+
## Workflow
|
|
27
|
+
|
|
28
|
+
1. Add the `mail` item.
|
|
29
|
+
2. Configure the `SEND_EMAIL` binding (or a provider) and `MAIL_FROM`.
|
|
30
|
+
3. Regenerate types with `lunora codegen`.
|
|
31
|
+
4. Send mail from a function (or the client); render a React template if needed.
|
|
32
|
+
|
|
33
|
+
## Step 1: Add the item
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
lunora registry add mail
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This:
|
|
40
|
+
|
|
41
|
+
1. Adds `@lunora/mail` and `@lunora/server` to `package.json` (run
|
|
42
|
+
`pnpm install` afterwards).
|
|
43
|
+
2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail` actions) into
|
|
44
|
+
your project — it is **yours** to edit.
|
|
45
|
+
3. Adds a `send_email` binding (`SEND_EMAIL`, with a `destination_address`
|
|
46
|
+
placeholder) to `wrangler.jsonc` and scaffolds `MAIL_FROM` (the default
|
|
47
|
+
sender) into `.dev.vars`.
|
|
48
|
+
|
|
49
|
+
## Step 2: Configure delivery
|
|
50
|
+
|
|
51
|
+
| Name | Where | Notes |
|
|
52
|
+
| ---------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
|
|
53
|
+
| `SEND_EMAIL` | `wrangler.jsonc` → `send_email[]` | Cloudflare Email Workers binding. Single-recipient; only verified destinations. |
|
|
54
|
+
| `MAIL_FROM` | var (`.dev.vars` / `wrangler.jsonc`) | Default sender address. |
|
|
55
|
+
| `RESEND_API_KEY` | secret (optional) | Use a hosted provider instead — pass `apiKey` to `createMailer` in `lunora/mail`. |
|
|
56
|
+
|
|
57
|
+
For production with Cloudflare Email Workers, set up
|
|
58
|
+
[Email Routing](https://developers.cloudflare.com/email-routing/): verify a
|
|
59
|
+
destination address and replace the `REPLACE_ME@example.com` placeholder. Prefer
|
|
60
|
+
Resend? Pass `apiKey` (or a custom `transport`) to `createMailer` in your copied
|
|
61
|
+
`lunora/mail/index.ts`.
|
|
62
|
+
|
|
63
|
+
In `lunora dev` (`WORKER_ENV=development`) the scaffold swaps in `@lunora/mail`'s
|
|
64
|
+
**capture transport** automatically: every send — including `@lunora/auth`'s
|
|
65
|
+
verification and forgot-password mail — is intercepted and surfaced in the
|
|
66
|
+
**Studio Mail tab**. Nothing leaves your machine and no provider setup is needed.
|
|
67
|
+
|
|
68
|
+
## Step 3: Regenerate types
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
lunora codegen
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The functions surface in the generated `api` as `api.mail.sendEmail` and
|
|
75
|
+
`api.mail.queueEmail`.
|
|
76
|
+
|
|
77
|
+
## Step 4: Send mail
|
|
78
|
+
|
|
79
|
+
### From another function
|
|
80
|
+
|
|
81
|
+
`sendEmail` is an **action** (sending is non-transactional network I/O). From a
|
|
82
|
+
mutation, schedule it as a follow-up so the request is not blocked:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { mutation, v } from "@lunora/server";
|
|
86
|
+
|
|
87
|
+
import { api } from "./_generated/api";
|
|
88
|
+
|
|
89
|
+
export const inviteUser = mutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
|
|
90
|
+
// ...persist the invite, then send the mail as a follow-up action
|
|
91
|
+
await ctx.scheduler.runAfter(0, api.mail.sendEmail, {
|
|
92
|
+
to: email,
|
|
93
|
+
subject: "You're invited",
|
|
94
|
+
html: "<p>Click the link to join.</p>",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### From a client
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await client.action("mail/sendEmail", {
|
|
103
|
+
to: "alice@example.com",
|
|
104
|
+
subject: "Welcome",
|
|
105
|
+
text: "Thanks for signing up!",
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### With a React email template
|
|
110
|
+
|
|
111
|
+
React elements are not JSON-serializable across the RPC boundary, so the
|
|
112
|
+
`sendEmail` args take `html` / `text`. To use a `react-email` template, render it
|
|
113
|
+
where you call the mailer — edit `lunora/mail/index.ts` to pass `react` straight
|
|
114
|
+
into `mailer.send`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createMailer } from "@lunora/mail";
|
|
118
|
+
import { env } from "cloudflare:workers";
|
|
119
|
+
|
|
120
|
+
import { WelcomeEmail } from "./emails/Welcome";
|
|
121
|
+
|
|
122
|
+
await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM as string }).send({
|
|
123
|
+
to: "alice@example.com",
|
|
124
|
+
subject: "Welcome",
|
|
125
|
+
react: <WelcomeEmail name="Alice" />,
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Common Pitfalls
|
|
130
|
+
|
|
131
|
+
1. **Expecting prod email to "just work".** Dev captures into the Studio;
|
|
132
|
+
production needs the `SEND_EMAIL` binding (a verified destination) or
|
|
133
|
+
`RESEND_API_KEY`.
|
|
134
|
+
2. **Calling `sendEmail` as a query/mutation.** It is an action — invoke it via
|
|
135
|
+
`ctx.runAction` / `ctx.scheduler.runAfter` / `client.action`, never `ctx.db`.
|
|
136
|
+
3. **Using `queueEmail` without a Queue binding.** It requires a Cloudflare
|
|
137
|
+
Queue producer binding; until you add one, `@lunora/mail` throws
|
|
138
|
+
`` `queue` binding is required for mailer.queue() ``. The item does not add
|
|
139
|
+
the Queue for you — see the `mail` README's "Queueing" section.
|
|
140
|
+
4. **Passing a React element through the action args.** Render it inside the
|
|
141
|
+
mailer (`mailer.send({ react })`), not across the RPC boundary.
|
|
142
|
+
|
|
143
|
+
## Checklist
|
|
144
|
+
|
|
145
|
+
- [ ] `lunora registry add mail` run, `pnpm install` done.
|
|
146
|
+
- [ ] `SEND_EMAIL` binding configured (verified destination) or
|
|
147
|
+
`RESEND_API_KEY` set; `MAIL_FROM` set.
|
|
148
|
+
- [ ] `lunora codegen` run so `api.mail.*` is generated.
|
|
149
|
+
- [ ] Mail sent from a function (`ctx.scheduler.runAfter`/`ctx.runAction` with
|
|
150
|
+
`api.mail.sendEmail`) or the client (`client.action("mail/sendEmail", …)`).
|
|
151
|
+
- [ ] Verified the send appears in the Studio Mail tab in dev.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-scheduler
|
|
3
|
+
description: Schedules deferred and recurring work in a Lunora app. Use for `ctx.scheduler.runAfter` / `runAt` (delayed function dispatch), cron jobs via `lunora registry add crons` (the `cronJobs()` builder), the `SchedulerDO` / `SCHEDULER` binding, retries, and the workpool for bounded concurrency.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup Scheduler
|
|
7
|
+
|
|
8
|
+
Schedule work in Lunora two ways, both backed by `@lunora/scheduler` (re-exported
|
|
9
|
+
from `@lunora/server`):
|
|
10
|
+
|
|
11
|
+
- **Deferred dispatch** — `ctx.scheduler.runAfter` / `runAt` from any function,
|
|
12
|
+
to run a function later. Built into the runtime; **no registry install**.
|
|
13
|
+
- **Recurring jobs (crons)** — declare them in `lunora/crons.ts` with
|
|
14
|
+
`cronJobs()`. Add the starter with `lunora registry add crons`.
|
|
15
|
+
|
|
16
|
+
Both run jobs through the `SchedulerDO` Durable Object (binding `SCHEDULER`),
|
|
17
|
+
which owns the alarm and durable storage.
|
|
18
|
+
|
|
19
|
+
## When to Use
|
|
20
|
+
|
|
21
|
+
- Running a function after a delay or at a specific time
|
|
22
|
+
(`runAfter` / `runAt`).
|
|
23
|
+
- Declaring recurring jobs (sweep, digest, report) on a cron schedule.
|
|
24
|
+
- Bounding concurrency for many enqueued jobs (a workpool).
|
|
25
|
+
|
|
26
|
+
## When Not to Use
|
|
27
|
+
|
|
28
|
+
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
29
|
+
- You just need to react to live data changes — use a reactive `query` /
|
|
30
|
+
subscription (`lunora-realtime`), not a scheduled job.
|
|
31
|
+
|
|
32
|
+
## Deferred dispatch — `runAfter` / `runAt`
|
|
33
|
+
|
|
34
|
+
Available on `ctx.scheduler` in any function. Target functions are passed by
|
|
35
|
+
reference from the generated `api` / `internal` proxy:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { mutation, v } from "@lunora/server";
|
|
39
|
+
|
|
40
|
+
import { internal } from "./_generated/api";
|
|
41
|
+
|
|
42
|
+
export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
|
|
43
|
+
// run an internal action 14 days from now
|
|
44
|
+
const { id } = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
|
|
45
|
+
|
|
46
|
+
return { jobId: id };
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `runAfter(delayMs, fnRef, args, options?)` — run after a delay (`delayMs` must
|
|
51
|
+
be a non-negative finite number). `runAt(date, fnRef, args, options?)` — run at
|
|
52
|
+
a `Date` or epoch-ms timestamp.
|
|
53
|
+
- Both return `{ id, scheduledFor }`. Cancel with `ctx.scheduler.cancel(id)`;
|
|
54
|
+
inspect with `ctx.scheduler.get(id)` / `ctx.scheduler.list()`.
|
|
55
|
+
- `options` accepts a `retry` policy (`{ maxAttempts, backoff, baseMs, maxMs }`;
|
|
56
|
+
DO defaults: `maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) and
|
|
57
|
+
a `shardKey` routing hint. On retry exhaustion the job is dead-lettered, never
|
|
58
|
+
silently dropped.
|
|
59
|
+
- The `SchedulerDO` binding (`SCHEDULER`) is **auto-inferred and reconciled**
|
|
60
|
+
into `wrangler.jsonc` by `@lunora/config` once `@lunora/scheduler` is in use —
|
|
61
|
+
run `lunora codegen` / `lunora doctor` to confirm. Scheduled jobs run with no
|
|
62
|
+
end-user identity, so target **internal** functions (`internal.*`).
|
|
63
|
+
|
|
64
|
+
## Recurring jobs — crons
|
|
65
|
+
|
|
66
|
+
### Step 1: Add the starter
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
lunora registry add crons
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This adds `@lunora/server` to `package.json` (run `pnpm install`) and copies
|
|
73
|
+
`lunora/crons.ts` (a `cronJobs()` registry with one illustrative job) and
|
|
74
|
+
`lunora/crons/jobs.ts` (the example `run` internal mutation it fires) into your
|
|
75
|
+
project — both **yours** to edit. No extra DO binding is required.
|
|
76
|
+
|
|
77
|
+
### Step 2: Declare jobs
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { cronJobs } from "@lunora/server";
|
|
81
|
+
|
|
82
|
+
import { internal } from "./_generated/api";
|
|
83
|
+
|
|
84
|
+
const crons = cronJobs();
|
|
85
|
+
|
|
86
|
+
crons.interval("sweep presence", { minutes: 5 }, internal.presence.sweep, { roomId: "lobby" });
|
|
87
|
+
crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest, {});
|
|
88
|
+
crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly, {});
|
|
89
|
+
crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
|
|
90
|
+
crons.cron("custom", "0 */6 * * *", internal.foo.bar, {}); // raw cron escape hatch
|
|
91
|
+
|
|
92
|
+
export default crons;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `name` must be a non-empty **string literal**, unique across the project.
|
|
96
|
+
- `fnRef` must be a static two-segment access on the proxy
|
|
97
|
+
(`internal.<file>.<fn>` or `api.<file>.<fn>`) so codegen can discover it.
|
|
98
|
+
Cron targets must be **internal** functions — a client can never invoke them.
|
|
99
|
+
- All schedules are UTC and validated at definition time
|
|
100
|
+
(`hourUTC: 25` throws immediately).
|
|
101
|
+
|
|
102
|
+
### Step 3: Regenerate types and the schedule
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
lunora codegen
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Codegen discovers each registration by AST, compiles the schedule to a cron
|
|
109
|
+
expression, and emits `lunora/_generated/crons.ts` (the dispatcher the Worker's
|
|
110
|
+
`scheduled()` handler consumes) plus the matching `triggers.crons` entry in
|
|
111
|
+
`wrangler.jsonc`. You never hand-edit the wrangler schedule array.
|
|
112
|
+
|
|
113
|
+
## Bounded concurrency — workpool (optional)
|
|
114
|
+
|
|
115
|
+
For many enqueued jobs that must not all run at once, use a workpool — a named
|
|
116
|
+
logical pool inside the same `SchedulerDO` (no extra binding):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { createWorkpool } from "@lunora/scheduler";
|
|
120
|
+
|
|
121
|
+
const pool = createWorkpool({
|
|
122
|
+
namespace: env.SCHEDULER,
|
|
123
|
+
originUrl: "https://my-app.example.com",
|
|
124
|
+
name: "imports",
|
|
125
|
+
maxConcurrency: 3,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await pool.enqueue(internal.imports.processRow, { rowId });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The DO caps simultaneous dispatch at `maxConcurrency` and queues the rest
|
|
132
|
+
durably. A Cloudflare-Queues-backed variant (`createQueueWorkpool`) leans on
|
|
133
|
+
queue config for concurrency/retries instead — reach for the DO workpool when you
|
|
134
|
+
need per-job cancel / status.
|
|
135
|
+
|
|
136
|
+
## Common Pitfalls
|
|
137
|
+
|
|
138
|
+
1. **Targeting a non-internal function from a job/cron.** Scheduled dispatch has
|
|
139
|
+
no end-user identity — target `internal.*` functions.
|
|
140
|
+
2. **Non-static `fnRef` or `name` in `cronJobs()`.** Codegen discovers them by
|
|
141
|
+
AST; a dynamic reference or computed name can't be found.
|
|
142
|
+
3. **Forgetting `lunora codegen` after editing crons.** The `triggers.crons`
|
|
143
|
+
array and the dispatcher map are codegen output — re-run it.
|
|
144
|
+
4. **Non-idempotent job handlers.** A missed tick may be retried and a slow tick
|
|
145
|
+
can overlap the next — make handlers idempotent.
|
|
146
|
+
|
|
147
|
+
## Checklist
|
|
148
|
+
|
|
149
|
+
- [ ] Deferred work uses `ctx.scheduler.runAfter` / `runAt` against
|
|
150
|
+
`internal.*` functions.
|
|
151
|
+
- [ ] `SCHEDULER` (SchedulerDO) binding present in `wrangler.jsonc`
|
|
152
|
+
(`lunora doctor` clean) when using the scheduler.
|
|
153
|
+
- [ ] Recurring jobs declared in `lunora/crons.ts` via `cronJobs()` (after
|
|
154
|
+
`lunora registry add crons`).
|
|
155
|
+
- [ ] `lunora codegen` run so `_generated/crons.ts` + `triggers.crons` are
|
|
156
|
+
synced.
|
|
157
|
+
- [ ] Job handlers are idempotent.
|