@lunora/cli 1.0.0-alpha.46 → 1.0.0-alpha.461

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 (142) hide show
  1. package/LICENSE.md +33 -0
  2. package/README.md +3 -1
  3. package/dist/bin.mjs +3 -10
  4. package/dist/cli.d.mts +31 -0
  5. package/dist/cli.d.ts +31 -0
  6. package/dist/cli.mjs +1 -0
  7. package/dist/index.d.mts +1096 -386
  8. package/dist/index.d.ts +1096 -386
  9. package/dist/index.mjs +1 -19
  10. package/dist/packem_chunks/cli.mjs +2 -0
  11. package/dist/packem_chunks/dispatch.mjs +1 -0
  12. package/dist/packem_chunks/handler.mjs +17 -158
  13. package/dist/packem_chunks/handler10.mjs +1 -16
  14. package/dist/packem_chunks/handler11.mjs +1 -22
  15. package/dist/packem_chunks/handler12.mjs +1 -192
  16. package/dist/packem_chunks/handler13.mjs +1 -131
  17. package/dist/packem_chunks/handler14.mjs +1 -65
  18. package/dist/packem_chunks/handler15.mjs +3 -58
  19. package/dist/packem_chunks/handler16.mjs +1 -80
  20. package/dist/packem_chunks/handler17.mjs +1 -43
  21. package/dist/packem_chunks/handler18.mjs +1 -105
  22. package/dist/packem_chunks/handler19.mjs +1 -170
  23. package/dist/packem_chunks/handler2.mjs +1 -114
  24. package/dist/packem_chunks/handler20.mjs +7 -94
  25. package/dist/packem_chunks/handler21.mjs +2 -94
  26. package/dist/packem_chunks/handler22.mjs +1 -0
  27. package/dist/packem_chunks/handler23.mjs +3 -0
  28. package/dist/packem_chunks/handler24.mjs +3 -0
  29. package/dist/packem_chunks/handler25.mjs +1 -0
  30. package/dist/packem_chunks/handler26.mjs +2 -0
  31. package/dist/packem_chunks/handler27.mjs +99 -0
  32. package/dist/packem_chunks/handler28.mjs +15 -0
  33. package/dist/packem_chunks/handler29.mjs +1 -0
  34. package/dist/packem_chunks/handler3.mjs +1 -204
  35. package/dist/packem_chunks/handler30.mjs +1 -0
  36. package/dist/packem_chunks/handler4.mjs +1 -33
  37. package/dist/packem_chunks/handler5.mjs +3 -49
  38. package/dist/packem_chunks/handler6.mjs +1 -91
  39. package/dist/packem_chunks/handler7.mjs +1 -42
  40. package/dist/packem_chunks/handler8.mjs +1 -174
  41. package/dist/packem_chunks/handler9.mjs +2 -315
  42. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  43. package/dist/packem_chunks/runDevCommand.mjs +6 -0
  44. package/dist/packem_chunks/runInitCommand.mjs +212 -1432
  45. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  46. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  47. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-5xxKz-zB.mjs +1 -0
  48. package/dist/packem_shared/EXIT_CODE-DVjUgIaG.mjs +1 -0
  49. package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
  50. package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
  51. package/dist/packem_shared/api-spec-lYyq4vL6.mjs +1 -0
  52. package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
  53. package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
  54. package/dist/packem_shared/cap-error-body-YBKO32BF.mjs +1 -0
  55. package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
  56. package/dist/packem_shared/cli-manifest-DoQ_3bzs.mjs +1 -0
  57. package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
  58. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  59. package/dist/packem_shared/command-B6vu47iR.mjs +1 -0
  60. package/dist/packem_shared/commands-DC3ZX1yk.mjs +13 -0
  61. package/dist/packem_shared/createLogger-4TujevyB.mjs +2 -0
  62. package/dist/packem_shared/createRecordingSpawner-01ZngIwK.mjs +1 -0
  63. package/dist/packem_shared/deploy-target-resolve-BTCW1Iv4.mjs +1 -0
  64. package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
  65. package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
  66. package/dist/packem_shared/free-port-DjbFhaaT.mjs +1 -0
  67. package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
  68. package/dist/packem_shared/import-DoAUdyfU.mjs +13 -0
  69. package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
  70. package/dist/packem_shared/insertSchemaExtension-CbTH3lCQ.mjs +10 -0
  71. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  72. package/dist/packem_shared/mcp-config-file-GR1Y_CVw.mjs +2 -0
  73. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  74. package/dist/packem_shared/output-format-DWe_tkTR.mjs +2 -0
  75. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  76. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  77. package/dist/packem_shared/plan-BH4ZfiFs.mjs +5 -0
  78. package/dist/packem_shared/planDevCommand-I8-NB_CN.mjs +1 -0
  79. package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
  80. package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
  81. package/dist/packem_shared/render-lunora-error-DUo0pA61.mjs +2 -0
  82. package/dist/packem_shared/resolve-Bdc9KEUv.mjs +5 -0
  83. package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
  84. package/dist/packem_shared/runAddCommand-CTblwN9O.mjs +1 -0
  85. package/dist/packem_shared/runDeployCommand-Tgy9tGRV.mjs +1 -0
  86. package/dist/packem_shared/runExportCommand-DiMCQ-Il.mjs +5 -0
  87. package/dist/packem_shared/runMigrateGenerateCommand-Bd2W9t1k.mjs +11 -0
  88. package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
  89. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  90. package/dist/packem_shared/storage-BLLoZRJa.mjs +1 -0
  91. package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
  92. package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
  93. package/dist/packem_shared/vectorize-metadata-BVSF8z2h.mjs +2 -0
  94. package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
  95. package/dist/packem_shared/wrangler-secrets-DlSlWvRH.mjs +1 -0
  96. package/package.json +44 -19
  97. package/skills/README.md +36 -17
  98. package/skills/lunora/SKILL.md +158 -65
  99. package/skills/lunora-cloudflare-audit/SKILL.md +164 -0
  100. package/skills/lunora-create-package/SKILL.md +97 -84
  101. package/skills/lunora-deploy/SKILL.md +178 -105
  102. package/skills/lunora-functions/SKILL.md +367 -60
  103. package/skills/lunora-migration-helper/SKILL.md +168 -137
  104. package/skills/lunora-performance-audit/SKILL.md +137 -95
  105. package/skills/lunora-quickstart/SKILL.md +136 -136
  106. package/skills/lunora-realtime/SKILL.md +217 -97
  107. package/skills/lunora-setup-auth/SKILL.md +203 -101
  108. package/skills/lunora-setup-hyperdrive/SKILL.md +138 -109
  109. package/skills/lunora-setup-hyperdrive-global/SKILL.md +137 -92
  110. package/skills/lunora-setup-mail/SKILL.md +87 -113
  111. package/skills/lunora-setup-scheduler/SKILL.md +133 -97
  112. package/skills/lunora-setup-storage/SKILL.md +189 -95
  113. package/dist/packem_chunks/planDevCommand.mjs +0 -541
  114. package/dist/packem_chunks/runDeployCommand.mjs +0 -594
  115. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
  116. package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
  117. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  118. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  119. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  120. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  121. package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
  122. package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
  123. package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
  124. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  125. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  126. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  127. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  128. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  129. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  130. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  131. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  132. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  133. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  134. package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
  135. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  136. package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
  137. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  138. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  139. package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
  140. package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
  141. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
  142. package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
@@ -1,151 +1,125 @@
1
1
  ---
2
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.
3
+ description: Adds transactional email to a Lunora app with the `mail` registry item (`@lunora/mail`). Covers the server-only `internal.mail.sendEmail` / `queueEmail` actions, the `SEND_EMAIL` Cloudflare Email Workers binding, Resend via `RESEND_API_KEY`, `react-email` templates via `renderEmail`, the dev mail catcher (Studio Mail tab), E2E inbox helpers, and inbound mail. Use when the user wants to send verification, password-reset, invite or notification email, runs `lunora add email` or `lunora registry add mail`, edits `lunora/mail/index.ts`, asks why mail isn't delivered or doesn't show up in the Studio, or wants to receive email.
4
4
  ---
5
5
 
6
6
  # Lunora Setup Mail
7
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.
8
+ The `mail` registry item wraps `@lunora/mail`, which validates addresses against
9
+ header injection before sending. It exposes two `internalAction`s, `sendEmail`
10
+ and `queueEmail`. Both are server-only on purpose: a mailer that lets the client
11
+ pick the recipient, subject and body is an open relay for phishing through your
12
+ verified domain. In dev, every send is captured into the Studio Mail tab and
13
+ nothing is delivered.
13
14
 
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.
15
+ If the project has no Lunora backend yet, start with `lunora-quickstart`.
32
16
 
33
17
  ## Step 1: Add the item
34
18
 
35
19
  ```bash
36
- lunora registry add mail
20
+ lunora add email # asks for the verified destination address
21
+ lunora add email --mail-to ops@example.com
22
+ lunora registry add mail # low-level: leaves the REPLACE_ME@example.com placeholder
37
23
  ```
38
24
 
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`.
25
+ Then run `pnpm install` and `lunora codegen`. The item does the following:
48
26
 
49
- ## Step 2: Configure delivery
27
+ 1. Adds `@lunora/mail` and `@lunora/server`.
28
+ 2. Copies `lunora/mail/index.ts` into the project. The project owns this file. It defines `sendEmail`, which returns `{ id }`, and `queueEmail`, which returns `{ queued: true }`.
29
+ 3. Adds a `send_email` binding named `SEND_EMAIL` (with `destination_address`) to `wrangler.jsonc`.
30
+ 4. Writes `MAIL_FROM` to `.dev.vars`.
50
31
 
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`. |
32
+ Codegen emits the two functions as `internal.mail.sendEmail` and
33
+ `internal.mail.queueEmail`. They are not in the client-reachable `api`. A client
34
+ call to them returns `FUNCTION_NOT_FOUND`.
56
35
 
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`.
36
+ ## Step 2: Choose the delivery path
62
37
 
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.
38
+ The copied file builds its mailer with `createMailerFromEnv`. That function
39
+ picks the transport in this order:
67
40
 
68
- ## Step 3: Regenerate types
41
+ | Condition | Transport |
42
+ | ----------------------------------------------------- | --------------------------------------------------- |
43
+ | `LUNORA_MAIL_CAPTURE=1`, or a dev environment (below) | **Capture** into the Studio Mail tab |
44
+ | `SEND_EMAIL` binding present | Cloudflare Email Workers |
45
+ | no binding, `RESEND_API_KEY` secret set | Resend |
46
+ | none of the above | throws on send (production never captures silently) |
69
47
 
70
- ```bash
71
- lunora codegen
72
- ```
73
-
74
- The functions surface in the generated `api` as `api.mail.sendEmail` and
75
- `api.mail.queueEmail`.
48
+ - **Cloudflare Email Workers.** The binding sends to a single recipient, and only to verified [Email Routing](https://developers.cloudflare.com/email-routing/) destinations. That fits app-to-operator mail. Verify the address and replace the placeholder.
49
+ - **Resend.** Use this to send to arbitrary users. Run `wrangler secret put RESEND_API_KEY` and remove the `send_email` binding. While the binding exists it takes precedence over Resend.
50
+ - **Capture.** Capture turns on when every env-name var (`WORKER_ENV`, `NODE_ENV`, …) looks like dev. `lunora dev` sets `WORKER_ENV=development`, so capture is on in dev. Capture writes to the root shard. If `SHARD` or `LUNORA_ADMIN_TOKEN` is missing, captured mail is discarded and a one-time warning is logged. To deliver for real from dev, set `LUNORA_MAIL_CAPTURE=0`.
76
51
 
77
- ## Step 4: Send mail
52
+ `MAIL_FROM` is required on every path. It accepts `Name <addr@host>` or a bare
53
+ address.
78
54
 
79
- ### From another function
55
+ ## Step 3: Send from a server function
80
56
 
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:
57
+ `sendEmail` is an action because sending is non-transactional network I/O. From
58
+ a mutation, schedule it so the send runs only after the mutation commits:
83
59
 
84
60
  ```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,
61
+ import { internal } from "#lunora/_generated/internal.js";
62
+ import { mutation, v } from "#lunora/_generated/server.js";
63
+
64
+ export const inviteMember = mutation.input({ teamId: v.id("teams"), email: v.string() }).mutation(async ({ ctx, args }) => {
65
+ // Authorize the caller and persist the invite first. The server decides
66
+ // the recipient and the content: never forward a client-chosen to/from/html.
67
+ await ctx.scheduler.runAfter(0, internal.mail.sendEmail, {
68
+ to: args.email,
93
69
  subject: "You're invited",
94
70
  html: "<p>Click the link to join.</p>",
95
71
  });
96
72
  });
97
73
  ```
98
74
 
99
- ### From a client
75
+ Sending to an arbitrary `args.email` needs the Resend path in production: the
76
+ `SEND_EMAIL` binding only reaches its verified destination.
100
77
 
101
- ```ts
102
- await client.action("mail/sendEmail", {
103
- to: "alice@example.com",
104
- subject: "Welcome",
105
- text: "Thanks for signing up!",
106
- });
107
- ```
78
+ From an action, call `await ctx.runAction(internal.mail.sendEmail, { … })`.
108
79
 
109
- ### With a React email template
80
+ If a client needs to trigger a send, write a public action for that one purpose.
81
+ It should take only business inputs (for example `{ orderId }`), check
82
+ `ctx.auth`, derive the recipient on the server, rate-limit the call
83
+ (`@lunora/ratelimit`), and then call `internal.mail.sendEmail`.
110
84
 
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`:
85
+ ### React templates
115
86
 
116
- ```ts
117
- import { createMailer } from "@lunora/mail";
118
- import { env } from "cloudflare:workers";
87
+ A React element can't be serialized across the RPC boundary, so render it to
88
+ `html` and `text` first. Rendering this way keeps dev capture working:
119
89
 
120
- import { WelcomeEmail } from "./emails/Welcome";
90
+ ```tsx
91
+ import { renderEmail } from "@lunora/mail"; // needs `react` installed
121
92
 
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
- });
93
+ import { WelcomeEmail } from "./emails/welcome";
94
+
95
+ const { html, text } = await renderEmail(<WelcomeEmail name={name} />);
96
+ await ctx.runAction(internal.mail.sendEmail, { to, subject: "Welcome", html, text });
127
97
  ```
128
98
 
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.
99
+ For the auth flows, `lunora add auth-emails` adds ready-made templates.
100
+
101
+ ### Queueing (optional)
102
+
103
+ `queueEmail` requires a Cloudflare Queue producer binding passed as
104
+ `createMailerFromEnv(env, { queue })` in `mailer()`, and the item doesn't add one. Without it, the call
105
+ throws `` `queue` binding is required for mailer.queue() ``, except under
106
+ capture, where it sends straight to the capture inbox so dev flows keep working.
107
+ A consumer builds its mailer with `createMailerFromEnv` too, drains the queue
108
+ with `consumeQueuedSend(mailer, message.body)` and then calls `message.ack()`
109
+ for each message. On a throw, branch on `isRetryableMailError(error)`: retry with
110
+ `message.retry()` when it is true, and ack and log when it is false, since a refused
111
+ address or malformed body never sends and a retry only dead-letters it. With a queue bound in dev, the message still goes through the
112
+ queue and the consumer's mailer captures it into the inbox. Queues deliver at least once: with
113
+ per-message acks a later throw retries only the unacknowledged messages, while
114
+ without them a single throw resends the whole batch. The full wiring is in the "Queueing"
115
+ section of the item README.
116
+
117
+ ## Testing and inbound
118
+
119
+ - **E2E.** `@lunora/mail/testing` exports `waitForMail({ baseUrl, adminToken, to, subjectMatch })` and `extractLink(mail, { match })`. Together they let you read the captured inbox and pull out a verification or reset link.
120
+ - **Receiving mail.** `@lunora/mail/inbound` provides `createInboundEmailHandler`, `parseInboundEmail` and `dispatchToLunoraFunction`. They route Email Routing messages into a Lunora function. Mount them with the generated builder's `.onEmail(...)`.
121
+
122
+ ## Verify
123
+
124
+ 1. Run `lunora dev` and trigger a send. With `SHARD` and `LUNORA_ADMIN_TOKEN` set, the message should appear in the Studio Mail tab; if either is missing, capture discards it and logs a warning.
125
+ 2. Before deploying, run `lunora doctor`. It flags the placeholder destination. Confirm that `MAIL_FROM` is set, and that either the binding uses a verified destination or `RESEND_API_KEY` is set with the binding removed.
@@ -1,157 +1,193 @@
1
1
  ---
2
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.
3
+ description: Schedules deferred and recurring work in a Lunora app. Covers `ctx.scheduler.runAfter` / `runAt` (delayed dispatch of a function or workflow), cron jobs in `lunora/crons.ts` via `lunora registry add crons` and the `cronJobs()` builder, the `SchedulerDO` / `SCHEDULER` binding and `LUNORA_ORIGIN_URL`, retries and dead-lettering, and `createWorkpool` for bounded concurrency. Use when the user wants to "run this later", "send a reminder in 3 days", "run every night / every 5 minutes", add a cron, cancel a scheduled job, or sees `ORIGIN_NOT_CONFIGURED` or a missing `SchedulerDO` export.
4
4
  ---
5
5
 
6
6
  # Lunora Setup Scheduler
7
7
 
8
- Schedule work in Lunora two ways, both backed by `@lunora/scheduler` (re-exported
9
- from `@lunora/server`):
8
+ Lunora schedules work in two ways, both implemented in `@lunora/scheduler`:
10
9
 
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`.
10
+ - **Deferred dispatch**: `ctx.scheduler.runAfter` / `runAt` run a function (or
11
+ start a workflow) later. Jobs are stored in the `SchedulerDO` Durable Object
12
+ (binding `SCHEDULER`), which owns the alarm, the retries and the dead-letter list.
13
+ - **Recurring jobs (crons)**: declared in `lunora/crons.ts` with `cronJobs()`.
14
+ Codegen compiles them into Cloudflare Cron Triggers, and the Worker's
15
+ `scheduled()` handler dispatches them directly. They don't go through the
16
+ `SchedulerDO`.
15
17
 
16
- Both run jobs through the `SchedulerDO` Durable Object (binding `SCHEDULER`),
17
- which owns the alarm and durable storage.
18
+ ## When Not to Use
18
19
 
19
- ## When to Use
20
+ - The project has no Lunora backend yet: use `lunora-quickstart` first.
21
+ - You only need to react to data changes: use a reactive `query` /
22
+ subscription (`lunora-realtime`).
23
+ - Multi-step orchestration with sleeps and waits belongs in a durable workflow.
24
+ A scheduler or cron can start one, but don't build step logic on top of jobs.
20
25
 
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).
26
+ ## Deferred dispatch: `runAfter` / `runAt`
25
27
 
26
- ## When Not to Use
28
+ ### Wire it once
27
29
 
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.
30
+ ```bash
31
+ pnpm add @lunora/scheduler
32
+ lunora codegen
33
+ ```
31
34
 
32
- ## Deferred dispatch — `runAfter` / `runAt`
35
+ Codegen needs `@lunora/scheduler` installed as soon as `ctx.scheduler` is used,
36
+ because the generated `_generated/app.ts` imports `createScheduler`. Once the
37
+ dependency is present, codegen emits `_generated/scheduler.ts` (re-exporting
38
+ `SchedulerDO`) and a `.scheduler()` method on the app builder.
33
39
 
34
- Available on `ctx.scheduler` in any function. Target functions are passed by
35
- reference from the generated `api` / `internal` proxy:
40
+ - **Vite-first apps** (generated worker entry): the plugin forwards
41
+ `SchedulerDO` and calls `.scheduler()` for you.
42
+ - **Hand-written worker entry** (`src/server.ts` with `defineApp`): wire it yourself.
43
+ `wrangler.jsonc`'s `SCHEDULER` binding is reconciled from this export:
36
44
 
37
45
  ```ts
38
- import { mutation, v } from "@lunora/server";
46
+ const app = defineApp<Env>()
47
+ .shard((env) => env.SHARD)
48
+ .scheduler({ namespace: (env) => env.SCHEDULER })
49
+ .build();
50
+
51
+ export const ShardDO = app.ShardDO;
52
+ export { SchedulerDO } from "../lunora/_generated/scheduler.js";
53
+ ```
54
+
55
+ The `SchedulerDO` calls back into the Worker at `LUNORA_ORIGIN_URL`. It reads
56
+ that value from its own env and never from the request, so a caller can't point
57
+ dispatch at an arbitrary URL. Without it, every `runAfter`/`runAt` fails with
58
+ `ORIGIN_NOT_CONFIGURED`, and nothing provisions it for you:
39
59
 
40
- import { internal } from "./_generated/api";
60
+ - dev: `LUNORA_ORIGIN_URL="http://localhost:5173"` in `.dev.vars` (your dev
61
+ server's URL)
62
+ - prod: `vars.LUNORA_ORIGIN_URL` in `wrangler.jsonc`, or
63
+ `wrangler secret put LUNORA_ORIGIN_URL` (per `--env`)
41
64
 
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 });
65
+ `lunora doctor` warns when the scheduler binding exists and the variable is unset.
45
66
 
46
- return { jobId: id };
67
+ ### Schedule from a mutation or action
68
+
69
+ ```ts
70
+ import { LunoraError } from "lunorash/server";
71
+
72
+ import { internal } from "#lunora/_generated/internal.js";
73
+ import { mutation } from "#lunora/_generated/server.js";
74
+
75
+ export const startTrial = mutation.mutation(async ({ ctx }) => {
76
+ // the caller's own id, never one the client passes in
77
+ const { userId } = ctx.auth;
78
+ if (!userId) {
79
+ throw new LunoraError("UNAUTHORIZED", "not signed in");
80
+ }
81
+ // run an internal action 14 days from now; the id is a plain string
82
+ const jobId = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
83
+
84
+ return { jobId };
47
85
  });
48
86
  ```
49
87
 
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
88
+ - The full `ctx.scheduler` surface is `runAfter(delayMs, target, args?)`,
89
+ `runAt(timestampMs, target, args?)` (epoch ms), `cancel(id)`, `get(id)`, and `list()`.
90
+ It is not available in a `query`, because queries are deterministic and re-run.
91
+ - `target` is a generated `internal.*` / `api.*` mutation or action reference, a
92
+ `"file:fn"` string, or a `workflows.<name>` / `agents.<name>` reference.
93
+ A workflow or agent reference starts a fresh instance when the job fires, with
94
+ `args` as its params. Scheduled jobs run with no end-user identity, so target
95
+ `internal.*` functions.
96
+ - **Inside a mutation, scheduling is transactional.** The call is buffered and
97
+ dispatched only after the mutation commits. If the mutation rolls back (OCC
98
+ conflict, validation error), the job is dropped too. The returned id is final
99
+ immediately, so you can store it on the row you're writing and `cancel` it later.
100
+ - Retries use the DO defaults: 5 retries, exponential backoff from 30 s. A job
101
+ that exhausts them is dead-lettered (visible in the studio), not dropped. To set
102
+ a per-job `retry` policy (`{ maxAttempts, backoff: "exponential" | "linear",
103
+ baseMs, maxMs }`) or a `shardKey`, build your own client with
104
+ `createScheduler({ namespace })` from `@lunora/scheduler`. Its `runAfter`/`runAt`
105
+ take a fourth `RunOptions` argument and add `dead()` / `deadRetry(id)`.
106
+
107
+ ## Recurring jobs: crons
65
108
 
66
109
  ### Step 1: Add the starter
67
110
 
68
111
  ```bash
69
112
  lunora registry add crons
113
+ pnpm install
70
114
  ```
71
115
 
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.
116
+ This adds `@lunora/server` + `@lunora/scheduler` to `package.json` and copies two
117
+ files you own: `lunora/crons.ts` (a `cronJobs()` registry) and
118
+ `lunora/crons/jobs.ts` (an example `internalMutation`, `internal.crons.jobs.run`).
76
119
 
77
120
  ### Step 2: Declare jobs
78
121
 
79
122
  ```ts
80
123
  import { cronJobs } from "@lunora/server";
81
124
 
82
- import { internal } from "./_generated/api";
125
+ import { internal } from "#lunora/_generated/internal.js";
83
126
 
84
127
  const crons = cronJobs();
85
128
 
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
129
+ crons.interval("purge sessions", { minutes: 5 }, internal.sessions.purgeExpired, {});
130
+ crons.hourly("rollup", { minuteUTC: 17 }, internal.stats.rollup);
131
+ crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest);
132
+ crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly);
133
+ crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice);
134
+ crons.cron("custom", "0 */6 * * *", internal.foo.bar); // raw cron escape hatch
91
135
 
92
136
  export default crons;
93
137
  ```
94
138
 
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).
139
+ - Names must be non-empty string literals, unique across the project.
140
+ - Targets must be static property accesses (`internal.<…path>.<fn>`, one segment
141
+ per folder and file, e.g. `internal.crons.jobs.run`) or a `workflows.<name>` /
142
+ `agents.<name>` reference. Codegen resolves them from the AST, so a computed
143
+ reference fails codegen. `args` is optional and must be a literal.
144
+ - Schedules are UTC and validated when the job is defined. `interval` takes exactly one of
145
+ `{ minutes }` (must divide 60) or `{ hours }` (must divide 24). `{ seconds }`
146
+ is rejected because Cron Triggers have a one-minute floor. For sub-minute work,
147
+ use `runAfter`.
148
+ - Use `hourly` with an offset minute to spread jobs away from `:00`.
101
149
 
102
- ### Step 3: Regenerate types and the schedule
150
+ ### Step 3: Regenerate
103
151
 
104
152
  ```bash
105
153
  lunora codegen
106
154
  ```
107
155
 
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.
156
+ Codegen emits `lunora/_generated/crons.ts` (`LUNORA_CRON_TRIGGERS` plus the
157
+ `LUNORA_CRONS` dispatcher map, which `defineApp` wires into `scheduled()`) and
158
+ syncs `triggers.crons` in `wrangler.jsonc`. Don't hand-edit that array.
159
+
160
+ **Verify:** the studio's scheduled-jobs view lists each cron and has a "Run now"
161
+ action, which calls the same dispatch path as a real tick.
112
162
 
113
- ## Bounded concurrency — workpool (optional)
163
+ ## Bounded concurrency: workpool (optional)
114
164
 
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):
165
+ A workpool is a named pool inside the same `SchedulerDO`. It needs no extra
166
+ binding. Build it in Worker code that has the raw `env`:
117
167
 
118
168
  ```ts
119
169
  import { createWorkpool } from "@lunora/scheduler";
120
170
 
121
- const pool = createWorkpool({
122
- namespace: env.SCHEDULER,
123
- originUrl: "https://my-app.example.com",
124
- name: "imports",
125
- maxConcurrency: 3,
126
- });
171
+ const pool = createWorkpool({ namespace: env.SCHEDULER, name: "imports", maxConcurrency: 3 });
127
172
 
128
- await pool.enqueue(internal.imports.processRow, { rowId });
173
+ await pool.enqueue(internal.imports.processRow, { rowId }, { retry: { maxAttempts: 3 } });
174
+ // pool.cancel(id), pool.status() → { inFlight, queued, maxConcurrency }
129
175
  ```
130
176
 
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.
177
+ The DO dispatches at most `maxConcurrency` jobs at once and queues the rest
178
+ durably. One alarm drain keeps at most six dispatches in flight (the DO connection limit),
179
+ so a long-running job holds a slot for its whole duration. For individually long jobs
180
+ (LLM calls, exports), prefer `createQueueWorkpool`, which is backed by Cloudflare Queues.
181
+ You give up the hard cap, per-job cancel and per-job status in exchange.
135
182
 
136
183
  ## Common Pitfalls
137
184
 
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.
185
+ 1. **`ORIGIN_NOT_CONFIGURED`**: `LUNORA_ORIGIN_URL` is unset for the `SchedulerDO`
186
+ (see above).
187
+ 2. **`SchedulerDO` not exported** from a hand-written worker entry: wrangler
188
+ only binds classes the entry exports, so `SCHEDULER` is never provisioned.
189
+ 3. **Dynamic `name` or target in `cronJobs()`**: codegen cannot discover it.
190
+ 4. **Editing crons without re-running `lunora codegen`**: `triggers.crons` and the
191
+ dispatcher map are generated output and go stale.
192
+ 5. **Non-idempotent handlers**: a failed job is retried, and a slow cron tick
193
+ can overlap the next one.