@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.
Files changed (72) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +109 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/bin.mjs +11 -0
  5. package/dist/index.d.mts +852 -0
  6. package/dist/index.d.ts +852 -0
  7. package/dist/index.mjs +19 -0
  8. package/dist/packem_chunks/handler.mjs +76 -0
  9. package/dist/packem_chunks/handler10.mjs +22 -0
  10. package/dist/packem_chunks/handler11.mjs +192 -0
  11. package/dist/packem_chunks/handler12.mjs +131 -0
  12. package/dist/packem_chunks/handler13.mjs +65 -0
  13. package/dist/packem_chunks/handler14.mjs +58 -0
  14. package/dist/packem_chunks/handler15.mjs +79 -0
  15. package/dist/packem_chunks/handler16.mjs +41 -0
  16. package/dist/packem_chunks/handler17.mjs +105 -0
  17. package/dist/packem_chunks/handler18.mjs +172 -0
  18. package/dist/packem_chunks/handler19.mjs +89 -0
  19. package/dist/packem_chunks/handler2.mjs +114 -0
  20. package/dist/packem_chunks/handler20.mjs +94 -0
  21. package/dist/packem_chunks/handler21.mjs +311 -0
  22. package/dist/packem_chunks/handler3.mjs +204 -0
  23. package/dist/packem_chunks/handler4.mjs +33 -0
  24. package/dist/packem_chunks/handler5.mjs +49 -0
  25. package/dist/packem_chunks/handler6.mjs +91 -0
  26. package/dist/packem_chunks/handler7.mjs +42 -0
  27. package/dist/packem_chunks/handler8.mjs +174 -0
  28. package/dist/packem_chunks/handler9.mjs +16 -0
  29. package/dist/packem_chunks/planDevCommand.mjs +543 -0
  30. package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
  31. package/dist/packem_chunks/runDeployCommand.mjs +504 -0
  32. package/dist/packem_chunks/runInitCommand.mjs +652 -0
  33. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
  34. package/dist/packem_chunks/runResetCommand.mjs +41 -0
  35. package/dist/packem_chunks/runRpcCommand.mjs +68 -0
  36. package/dist/packem_shared/COMMANDS-1V_KEx35.mjs +905 -0
  37. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
  38. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
  39. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
  40. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
  41. package/dist/packem_shared/command-BDXcJCCJ.mjs +14 -0
  42. package/dist/packem_shared/createLogger-CHPNjFw2.mjs +73 -0
  43. package/dist/packem_shared/defaultSpawner-DxI3mebw.mjs +43 -0
  44. package/dist/packem_shared/diffSnapshots-RR2ZE8Ya.mjs +161 -0
  45. package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
  46. package/dist/packem_shared/features-ocSSpZtS.mjs +24 -0
  47. package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
  48. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
  49. package/dist/packem_shared/output-format-7gyGR3h8.mjs +17 -0
  50. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
  51. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
  52. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
  53. package/dist/packem_shared/runAddCommand-BZGkRnBs.mjs +693 -0
  54. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
  55. package/dist/packem_shared/schemaIrToSnapshot-aBTo7TM5.mjs +43 -0
  56. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
  57. package/package.json +61 -18
  58. package/skills/README.md +29 -0
  59. package/skills/lunora/SKILL.md +83 -0
  60. package/skills/lunora-create-package/SKILL.md +129 -0
  61. package/skills/lunora-deploy/SKILL.md +150 -0
  62. package/skills/lunora-functions/SKILL.md +182 -0
  63. package/skills/lunora-migration-helper/SKILL.md +194 -0
  64. package/skills/lunora-performance-audit/SKILL.md +143 -0
  65. package/skills/lunora-quickstart/SKILL.md +240 -0
  66. package/skills/lunora-realtime/SKILL.md +177 -0
  67. package/skills/lunora-setup-auth/SKILL.md +170 -0
  68. package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
  69. package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
  70. package/skills/lunora-setup-mail/SKILL.md +151 -0
  71. package/skills/lunora-setup-scheduler/SKILL.md +157 -0
  72. 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.