@lunora/cli 1.0.0-alpha.39 → 1.0.0-alpha.390
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +33 -0
- package/README.md +1 -1
- package/dist/bin.mjs +2 -10
- package/dist/index.d.mts +1019 -374
- package/dist/index.d.ts +1019 -374
- package/dist/index.mjs +1 -19
- package/dist/packem_chunks/dispatch.mjs +1 -0
- package/dist/packem_chunks/handler.mjs +1 -160
- package/dist/packem_chunks/handler10.mjs +1 -16
- package/dist/packem_chunks/handler11.mjs +1 -22
- package/dist/packem_chunks/handler12.mjs +1 -192
- package/dist/packem_chunks/handler13.mjs +3 -131
- package/dist/packem_chunks/handler14.mjs +1 -65
- package/dist/packem_chunks/handler15.mjs +1 -58
- package/dist/packem_chunks/handler16.mjs +1 -80
- package/dist/packem_chunks/handler17.mjs +7 -43
- package/dist/packem_chunks/handler18.mjs +1 -105
- package/dist/packem_chunks/handler19.mjs +1 -170
- package/dist/packem_chunks/handler2.mjs +1 -114
- package/dist/packem_chunks/handler20.mjs +3 -94
- package/dist/packem_chunks/handler21.mjs +3 -94
- package/dist/packem_chunks/handler22.mjs +1 -0
- package/dist/packem_chunks/handler23.mjs +2 -0
- package/dist/packem_chunks/handler24.mjs +2 -0
- package/dist/packem_chunks/handler25.mjs +99 -0
- package/dist/packem_chunks/handler26.mjs +9 -0
- package/dist/packem_chunks/handler27.mjs +1 -0
- package/dist/packem_chunks/handler3.mjs +1 -204
- package/dist/packem_chunks/handler4.mjs +3 -33
- package/dist/packem_chunks/handler5.mjs +1 -49
- package/dist/packem_chunks/handler6.mjs +1 -91
- package/dist/packem_chunks/handler7.mjs +1 -42
- package/dist/packem_chunks/handler8.mjs +2 -174
- package/dist/packem_chunks/handler9.mjs +1 -315
- package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
- package/dist/packem_chunks/runDeployCommand.mjs +11 -594
- package/dist/packem_chunks/runDevCommand.mjs +2 -0
- package/dist/packem_chunks/runInitCommand.mjs +164 -1432
- package/dist/packem_chunks/runResetCommand.mjs +1 -41
- package/dist/packem_chunks/runRpcCommand.mjs +1 -68
- package/dist/packem_shared/COMMANDS-JClcR9JT.mjs +1 -0
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
- package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
- package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
- package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
- package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
- package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
- package/dist/packem_shared/cap-error-body-YBKO32BF.mjs +1 -0
- package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
- package/dist/packem_shared/cli-DwEpuZm9.mjs +2 -0
- package/dist/packem_shared/cli-manifest-CRet0egA.mjs +1 -0
- package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
- package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
- package/dist/packem_shared/command-CAXl2A1S.mjs +1 -0
- package/dist/packem_shared/commands-DgUDxkA8.mjs +13 -0
- package/dist/packem_shared/createLogger-Cl18I8AX.mjs +2 -0
- package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
- package/dist/packem_shared/deploy-target-DCWwiuAe.mjs +1 -0
- package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
- package/dist/packem_shared/docker-AqLAkOyY.mjs +1 -0
- package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
- package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
- package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
- package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
- package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
- package/dist/packem_shared/mcp-config-file-CcDTeXIq.mjs +2 -0
- package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
- package/dist/packem_shared/output-format-B4eQHi5y.mjs +2 -0
- package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
- package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
- package/dist/packem_shared/plan-Dl57T11P.mjs +5 -0
- package/dist/packem_shared/planDevCommand-D8sm56hO.mjs +1 -0
- package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
- package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
- package/dist/packem_shared/render-lunora-error-C3OJY5wk.mjs +2 -0
- package/dist/packem_shared/resolve-BrsV4Qyc.mjs +5 -0
- package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
- package/dist/packem_shared/runAddCommand-DTvfp4h2.mjs +1 -0
- package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
- package/dist/packem_shared/runMigrateGenerateCommand-BwemPIOz.mjs +11 -0
- package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
- package/dist/packem_shared/secret-key-6bCftsxj.mjs +1 -0
- package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
- package/dist/packem_shared/storage-DWhTX8eS.mjs +1 -0
- package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
- package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
- package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
- package/dist/packem_shared/wrangler-secrets-3chIVv3H.mjs +1 -0
- package/package.json +39 -19
- package/skills/README.md +35 -17
- package/skills/lunora/SKILL.md +154 -64
- package/skills/lunora-create-package/SKILL.md +97 -84
- package/skills/lunora-deploy/SKILL.md +175 -105
- package/skills/lunora-functions/SKILL.md +362 -56
- package/skills/lunora-migration-helper/SKILL.md +162 -137
- package/skills/lunora-performance-audit/SKILL.md +137 -95
- package/skills/lunora-quickstart/SKILL.md +137 -136
- package/skills/lunora-realtime/SKILL.md +140 -99
- package/skills/lunora-setup-auth/SKILL.md +162 -102
- package/skills/lunora-setup-hyperdrive/SKILL.md +114 -97
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
- package/skills/lunora-setup-mail/SKILL.md +85 -113
- package/skills/lunora-setup-scheduler/SKILL.md +132 -96
- package/skills/lunora-setup-storage/SKILL.md +189 -95
- package/dist/packem_chunks/planDevCommand.mjs +0 -541
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
- package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
- package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
- package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
- package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
- package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
- package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
- package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
- package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
- package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
- package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
- package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
- package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
- package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
- package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
- package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
- 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:
|
|
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
|
|
9
|
-
through
|
|
10
|
-
|
|
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
|
-
>
|
|
15
|
-
> `
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
>
|
|
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
|
|
32
|
-
|
|
33
|
-
- Your global data is small and D1 is fine
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
34
|
+
## Step 1: Install
|
|
45
35
|
|
|
46
36
|
```bash
|
|
47
|
-
pnpm add @lunora/hyperdrive/
|
|
48
|
-
|
|
49
|
-
pnpm add
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
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()`
|
|
82
|
+
## Step 4: Wire `.hyperdriveGlobal()`
|
|
92
83
|
|
|
93
|
-
Codegen emits a `.hyperdriveGlobal()` builder method.
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
guard needs matched-row counts,
|
|
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
|
-
|
|
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
|
|
128
|
+
lunora codegen
|
|
127
129
|
lunora dev
|
|
128
130
|
```
|
|
129
131
|
|
|
130
|
-
Tables
|
|
131
|
-
the dialect
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
141
|
-
--to-url https://new-
|
|
142
|
-
--tables settings,orders
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
are reported as conflicts
|
|
150
|
-
|
|
151
|
-
|
|
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. **
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
3. **
|
|
160
|
-
4. **
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Studio Mail tab
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
52
|
+
`MAIL_FROM` is required on every path. It accepts `Name <addr@host>` or a bare
|
|
53
|
+
address.
|
|
78
54
|
|
|
79
|
-
|
|
55
|
+
## Step 3: Send from a server function
|
|
80
56
|
|
|
81
|
-
`sendEmail` is an
|
|
82
|
-
mutation, schedule it
|
|
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 {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
await ctx.scheduler.runAfter(0,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
90
|
+
```tsx
|
|
91
|
+
import { renderEmail } from "@lunora/mail"; // needs `react` installed
|
|
121
92
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|