@lunora/cli 1.0.0-alpha.37 → 1.0.0-alpha.370

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 (132) hide show
  1. package/LICENSE.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/bin.mjs +2 -10
  4. package/dist/index.d.mts +1007 -372
  5. package/dist/index.d.ts +1007 -372
  6. package/dist/index.mjs +1 -19
  7. package/dist/packem_chunks/dispatch.mjs +1 -0
  8. package/dist/packem_chunks/handler.mjs +1 -160
  9. package/dist/packem_chunks/handler10.mjs +2 -16
  10. package/dist/packem_chunks/handler11.mjs +1 -22
  11. package/dist/packem_chunks/handler12.mjs +1 -192
  12. package/dist/packem_chunks/handler13.mjs +1 -131
  13. package/dist/packem_chunks/handler14.mjs +1 -65
  14. package/dist/packem_chunks/handler15.mjs +3 -58
  15. package/dist/packem_chunks/handler16.mjs +1 -80
  16. package/dist/packem_chunks/handler17.mjs +1 -43
  17. package/dist/packem_chunks/handler18.mjs +1 -105
  18. package/dist/packem_chunks/handler19.mjs +7 -170
  19. package/dist/packem_chunks/handler2.mjs +1 -114
  20. package/dist/packem_chunks/handler20.mjs +1 -94
  21. package/dist/packem_chunks/handler21.mjs +1 -94
  22. package/dist/packem_chunks/handler22.mjs +3 -0
  23. package/dist/packem_chunks/handler23.mjs +3 -0
  24. package/dist/packem_chunks/handler24.mjs +1 -0
  25. package/dist/packem_chunks/handler25.mjs +2 -0
  26. package/dist/packem_chunks/handler26.mjs +1 -0
  27. package/dist/packem_chunks/handler27.mjs +99 -0
  28. package/dist/packem_chunks/handler28.mjs +10 -0
  29. package/dist/packem_chunks/handler3.mjs +1 -204
  30. package/dist/packem_chunks/handler4.mjs +1 -33
  31. package/dist/packem_chunks/handler5.mjs +1 -49
  32. package/dist/packem_chunks/handler6.mjs +3 -91
  33. package/dist/packem_chunks/handler7.mjs +1 -42
  34. package/dist/packem_chunks/handler8.mjs +1 -174
  35. package/dist/packem_chunks/handler9.mjs +1 -315
  36. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  37. package/dist/packem_chunks/runDeployCommand.mjs +11 -594
  38. package/dist/packem_chunks/runDevCommand.mjs +2 -0
  39. package/dist/packem_chunks/runInitCommand.mjs +164 -1432
  40. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  41. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  42. package/dist/packem_shared/COMMANDS-CWnIJmxM.mjs +1 -0
  43. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
  44. package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
  45. package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
  46. package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
  47. package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
  48. package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
  49. package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
  50. package/dist/packem_shared/cli-6BRlq6nw.mjs +2 -0
  51. package/dist/packem_shared/cli-manifest-CRet0egA.mjs +1 -0
  52. package/dist/packem_shared/cloudflare-credentials-lC5JtPLe.mjs +1 -0
  53. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  54. package/dist/packem_shared/command-CAXl2A1S.mjs +1 -0
  55. package/dist/packem_shared/commands-DgUDxkA8.mjs +13 -0
  56. package/dist/packem_shared/createLogger-Cl18I8AX.mjs +2 -0
  57. package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
  58. package/dist/packem_shared/deploy-target-DCWwiuAe.mjs +1 -0
  59. package/dist/packem_shared/diffSnapshots-WWwx-KvZ.mjs +5 -0
  60. package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
  61. package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
  62. package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
  63. package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
  64. package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
  65. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  66. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  67. package/dist/packem_shared/output-format-B4eQHi5y.mjs +2 -0
  68. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  69. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  70. package/dist/packem_shared/plan-DJeIwzFu.mjs +5 -0
  71. package/dist/packem_shared/planDevCommand-DkaC-fNF.mjs +1 -0
  72. package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
  73. package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
  74. package/dist/packem_shared/render-lunora-error-C3OJY5wk.mjs +2 -0
  75. package/dist/packem_shared/resolve-BrsV4Qyc.mjs +5 -0
  76. package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
  77. package/dist/packem_shared/runAddCommand-DTvfp4h2.mjs +1 -0
  78. package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
  79. package/dist/packem_shared/runMigrateGenerateCommand-Ii_WMZEA.mjs +11 -0
  80. package/dist/packem_shared/schemaIrToSnapshot-BjK-0IRo.mjs +1 -0
  81. package/dist/packem_shared/secret-key-BCrqNJ68.mjs +1 -0
  82. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  83. package/dist/packem_shared/storage-DWhTX8eS.mjs +1 -0
  84. package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
  85. package/dist/packem_shared/variables-acxcvVQa.mjs +1 -0
  86. package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
  87. package/dist/packem_shared/wrangler-secrets-3chIVv3H.mjs +1 -0
  88. package/package.json +39 -19
  89. package/skills/README.md +35 -17
  90. package/skills/lunora/SKILL.md +154 -64
  91. package/skills/lunora-create-package/SKILL.md +97 -84
  92. package/skills/lunora-deploy/SKILL.md +175 -105
  93. package/skills/lunora-functions/SKILL.md +362 -56
  94. package/skills/lunora-migration-helper/SKILL.md +162 -137
  95. package/skills/lunora-performance-audit/SKILL.md +137 -95
  96. package/skills/lunora-quickstart/SKILL.md +137 -136
  97. package/skills/lunora-realtime/SKILL.md +140 -99
  98. package/skills/lunora-setup-auth/SKILL.md +162 -102
  99. package/skills/lunora-setup-hyperdrive/SKILL.md +114 -97
  100. package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
  101. package/skills/lunora-setup-mail/SKILL.md +85 -113
  102. package/skills/lunora-setup-scheduler/SKILL.md +132 -96
  103. package/skills/lunora-setup-storage/SKILL.md +189 -95
  104. package/dist/packem_chunks/planDevCommand.mjs +0 -541
  105. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
  106. package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
  107. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  108. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  109. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  110. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  111. package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
  112. package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
  113. package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
  114. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  115. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  116. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  117. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  118. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  119. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  120. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  121. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  122. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  123. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  124. package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
  125. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  126. package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
  127. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  128. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  129. package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
  130. package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
  131. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
  132. package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
@@ -1,67 +1,55 @@
1
1
  ---
2
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`).
3
+ description: Stores a Lunora app's own `.global()` tables in Postgres or MySQL (PlanetScale, Neon, RDS, or any Hyperdrive-reachable database) through Cloudflare Hyperdrive instead of D1. Lunora owns the schema, and live queries stay fully reactive. Also migrates an existing D1 `.global()` dataset with `lunora migrate d1-to-hyperdrive`. Covers `.global({ backend: "hyperdrive" })`, `@lunora/hyperdrive/global` (`buildPgExec` / `buildMysqlExec`, `createHyperdriveGlobalCtxDb`), the app builder's `.hyperdriveGlobal({ engine, exec })`, and the `HYPERDRIVE` binding. Use when the user wants to "move global tables to PlanetScale/Postgres", "use Postgres instead of D1", or "migrate off D1", or edits `.global({ backend: "hyperdrive" })`. For querying an existing, externally owned database from an action (non-reactive `ctx.sql`), use `lunora-setup-hyperdrive` instead.
4
4
  ---
5
5
 
6
6
  # Lunora Setup: Hyperdrive Global Backend (Postgres/MySQL)
7
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.)
8
+ Store `.global()` (cross-tenant) tables in a Postgres or MySQL database reached
9
+ through Cloudflare Hyperdrive instead of D1. PlanetScale on Cloudflare is the
10
+ typical case, but nothing here is PlanetScale-specific.
13
11
 
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.
12
+ > **This is not `ctx.sql`.** `@lunora/hyperdrive`'s `ctx.sql`
13
+ > (`lunora-setup-hyperdrive`) is an action-only, non-reactive client for a
14
+ > database Lunora doesn't own. `.global({ backend: "hyperdrive" })` is the opposite:
15
+ > Lunora owns the tables (one column per field, like D1), and every write goes
16
+ > through Lunora's store core. Subscriptions re-run exactly as they do with D1.
17
+ > The live-query guarantee covers only writes made through Lunora (`ctx.db`).
18
+ > Rows written to the database directly are invisible to live queries.
28
19
 
29
20
  ## When Not to Use
30
21
 
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.
22
+ - You only need to read or write a legacy database from an action: use
23
+ `lunora-setup-hyperdrive`.
24
+ - Your global data is small and D1 is fine: bare `.global()` needs no extra
25
+ binding or driver.
35
26
 
36
27
  ## How it works
37
28
 
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.
29
+ The same dialect-parameterized store core that backs D1 runs here with a
30
+ Postgres or MySQL `SqlDialect`. A Hyperdrive-backed `SqlExec` executes it from
31
+ inside the Durable Object that hosts the global writer. Values are stored in the
32
+ same shapes D1 uses (boolean → 1/0, JSON → text/json, bigint → decimal).
43
33
 
44
- ## Step 1: Install the package + a driver
34
+ ## Step 1: Install
45
35
 
46
36
  ```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
37
+ pnpm add @lunora/hyperdrive @lunora/sql-store
38
+ pnpm add postgres # Postgres engine → buildPgExec(fromPostgresJs(...))
39
+ # or: pnpm add mysql2 → buildMysqlExec(mysql2/promise pool), MySQL 8.0+
52
40
  ```
53
41
 
54
- ## Step 2: Create the Hyperdrive binding pointing at PlanetScale
42
+ `@lunora/hyperdrive/global` is a subpath export of `@lunora/hyperdrive`, not a
43
+ separate package. Codegen requires `@lunora/sql-store` too, because the generated
44
+ `_generated/app.ts` imports its `SqlExec` types. Install the driver that matches
45
+ your engine. Drivers are optional peer dependencies.
55
46
 
56
- Create a PlanetScale database from the Cloudflare dashboard (unified billing),
57
- then a Hyperdrive config over its connection string:
47
+ ## Step 2: Create the Hyperdrive binding
58
48
 
59
49
  ```bash
60
50
  wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host/db" # gitleaks:allow -- placeholder
61
51
  ```
62
52
 
63
- Add the binding to `wrangler.jsonc` (use `localConnectionString` for `lunora dev`):
64
-
65
53
  ```jsonc
66
54
  {
67
55
  "hyperdrive": [
@@ -70,10 +58,14 @@ Add the binding to `wrangler.jsonc` (use `localConnectionString` for `lunora dev
70
58
  }
71
59
  ```
72
60
 
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.
61
+ A binding is required. Without any `hyperdrive` entry, config validation errors
62
+ and `lunora dev` / `lunora deploy` won't start. You choose the name: the
63
+ validator only checks that at least one binding exists, and your `exec` selector picks it.
64
+
65
+ Point Hyperdrive at the primary (or pin writes to it) so you can read your own
66
+ writes. A replica can serve a stale read right after a write.
75
67
 
76
- ## Step 3: Mark tables `.global({ backend: "hyperdrive" })`
68
+ ## Step 3: Mark tables
77
69
 
78
70
  ```ts
79
71
  // lunora/schema.ts
@@ -84,88 +76,99 @@ export default defineSchema({
84
76
  });
85
77
  ```
86
78
 
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).
79
+ Bare `.global()` stays on D1. An app can use only one global backend: codegen fails
80
+ if the schema mixes D1 and Hyperdrive global tables.
90
81
 
91
- ## Step 4: Wire `.hyperdriveGlobal()` in the app composition
82
+ ## Step 4: Wire `.hyperdriveGlobal()`
92
83
 
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):
84
+ Codegen emits a `.hyperdriveGlobal({ engine, exec, origin? })` builder method.
85
+ `exec` builds a `SqlExec` from `env`:
96
86
 
97
87
  ```ts
98
- import { buildPgExec } from "@lunora/hyperdrive/global";
99
88
  import { fromPostgresJs } from "@lunora/hyperdrive";
89
+ import { buildPgExec } from "@lunora/hyperdrive/global";
100
90
  import postgres from "postgres";
101
91
 
102
- export default defineApp<Env>()
92
+ import { defineApp } from "../lunora/_generated/app.js";
93
+
94
+ const app = defineApp<Env>()
103
95
  .shard((env) => env.SHARD)
104
96
  .hyperdriveGlobal({
105
97
  engine: "postgres",
106
98
  exec: (env) => buildPgExec(fromPostgresJs(postgres(env.HYPERDRIVE.connectionString))),
107
- });
99
+ })
100
+ .build();
101
+
102
+ export const ShardDO = app.ShardDO;
108
103
  ```
109
104
 
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):
105
+ For MySQL, create the pool with the `FOUND_ROWS` flag. The optimistic-concurrency
106
+ guard needs matched-row counts. Without the flag, an idempotent write reports
107
+ zero affected rows and raises a spurious conflict. `buildMysqlExec` throws at
108
+ construction when the flag is missing. If it can't read the flag, it warns once instead.
112
109
 
113
110
  ```ts
114
111
  import { buildMysqlExec } from "@lunora/hyperdrive/global";
115
112
  import mysql from "mysql2/promise";
116
113
 
114
+ // inside the builder chain:
117
115
  .hyperdriveGlobal({
118
116
  engine: "mysql",
119
117
  exec: (env) => buildMysqlExec(mysql.createPool({ uri: env.HYPERDRIVE.connectionString, flags: ["FOUND_ROWS"] })),
120
- });
118
+ })
121
119
  ```
122
120
 
123
- ## Step 5: Regenerate + run
121
+ Outside the app builder, for example in tests or a custom host,
122
+ `createHyperdriveGlobalCtxDb({ engine, exec, ... })` builds the same reactive writer
123
+ directly.
124
+
125
+ ## Step 5: Regenerate and run
124
126
 
125
127
  ```bash
126
- lunora codegen # wires config.hyperdriveGlobal and the .hyperdriveGlobal() method
128
+ lunora codegen
127
129
  lunora dev
128
130
  ```
129
131
 
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
+ Tables provision automatically on first use, because the runtime runs the DDL
133
+ through the dialect. You don't write or commit SQL migrations. `CREATE TABLE IF NOT EXISTS` never
134
+ reshapes an existing table, though. When a column's validator starts accepting
135
+ null, the store reports the `ALTER` statement to run. MySQL tables created before
136
+ the `utf8mb4_0900_bin` column collation need a one-off
137
+ `ALTER TABLE … CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin`.
138
+ See `lunora-migration-helper` for schema changes.
132
139
 
133
- ## Migrating an existing D1 dataset → PlanetScale
140
+ **Verify:** subscribe to a query over a hyperdrive-backed table, write through a
141
+ mutation, and confirm that the subscription updates.
134
142
 
135
- Blue-green: deploy the new PlanetScale-backed worker alongside the old D1 one,
136
- then copy the `.global()` data:
143
+ Postgres deployments can also serve `ctx.vectors` from pgvector instead of a Vectorize
144
+ binding with `createPgVectorIndex` (from `@lunora/hyperdrive/global`). It supports
145
+ equality-only metadata filters.
146
+
147
+ ## Migrating an existing D1 dataset
148
+
149
+ The flow is blue-green: deploy the Hyperdrive-backed worker next to the D1 one,
150
+ then copy the data:
137
151
 
138
152
  ```bash
139
153
  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
154
+ --from-url https://old-d1.example.com --from-token "$D1_ADMIN_TOKEN" \
155
+ --to-url https://new-hd.example.com --to-token "$HD_ADMIN_TOKEN" \
156
+ --tables settings,orders # omit to move every global table; --out dump.ndjson keeps the dump
144
157
  ```
145
158
 
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.
159
+ The command exports the source's global rows to NDJSON, imports them into the
160
+ target, and compares row counts. It refuses to run when the source and target
161
+ resolve to the same URL, and non-localhost URLs must use https. Rows whose `_id`
162
+ already exists in the target are reported as conflicts and not duplicated.
163
+ A count mismatch is a warning: inspect it with `--out`, resolve it, and re-run.
164
+ Tokens default to `--token` / `LUNORA_ADMIN_TOKEN`.
153
165
 
154
166
  ## Common Pitfalls
155
167
 
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.
168
+ 1. **Installing `@lunora/hyperdrive/global` as a package name.** It's a subpath.
169
+ Install `@lunora/hyperdrive` and `@lunora/sql-store`.
170
+ 2. **Mixing D1 and Hyperdrive global tables.** Codegen rejects this.
171
+ 3. **Missing `FOUND_ROWS` on MySQL.** It causes spurious OCC conflicts on idempotent writes.
172
+ 4. **Stale-replica reads.** Point Hyperdrive at the primary.
173
+ 5. **Writing to the database directly.** Live queries won't see those writes. Route writes
174
+ through `ctx.db`.
@@ -1,151 +1,123 @@
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. With a queue bound in dev, the message still goes through the
110
+ queue and the consumer's mailer captures it into the inbox. Queues deliver at least once: with
111
+ per-message acks a later throw retries only the unacknowledged messages, while
112
+ without them a single throw resends the whole batch. The full wiring is in the "Queueing"
113
+ section of the item README.
114
+
115
+ ## Testing and inbound
116
+
117
+ - **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.
118
+ - **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(...)`.
119
+
120
+ ## Verify
121
+
122
+ 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.
123
+ 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.