@lunora/cli 0.0.0 → 1.0.0-alpha.10

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 (75) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +109 -9
  3. package/__assets__/package-og.svg +14 -0
  4. package/dist/bin.mjs +11 -0
  5. package/dist/index.d.mts +956 -0
  6. package/dist/index.d.ts +956 -0
  7. package/dist/index.mjs +19 -0
  8. package/dist/packem_chunks/handler.mjs +150 -0
  9. package/dist/packem_chunks/handler10.mjs +22 -0
  10. package/dist/packem_chunks/handler11.mjs +192 -0
  11. package/dist/packem_chunks/handler12.mjs +131 -0
  12. package/dist/packem_chunks/handler13.mjs +65 -0
  13. package/dist/packem_chunks/handler14.mjs +58 -0
  14. package/dist/packem_chunks/handler15.mjs +79 -0
  15. package/dist/packem_chunks/handler16.mjs +43 -0
  16. package/dist/packem_chunks/handler17.mjs +105 -0
  17. package/dist/packem_chunks/handler18.mjs +170 -0
  18. package/dist/packem_chunks/handler19.mjs +89 -0
  19. package/dist/packem_chunks/handler2.mjs +114 -0
  20. package/dist/packem_chunks/handler20.mjs +94 -0
  21. package/dist/packem_chunks/handler21.mjs +311 -0
  22. package/dist/packem_chunks/handler3.mjs +204 -0
  23. package/dist/packem_chunks/handler4.mjs +33 -0
  24. package/dist/packem_chunks/handler5.mjs +49 -0
  25. package/dist/packem_chunks/handler6.mjs +91 -0
  26. package/dist/packem_chunks/handler7.mjs +42 -0
  27. package/dist/packem_chunks/handler8.mjs +174 -0
  28. package/dist/packem_chunks/handler9.mjs +16 -0
  29. package/dist/packem_chunks/planDevCommand.mjs +500 -0
  30. package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
  31. package/dist/packem_chunks/runDeployCommand.mjs +504 -0
  32. package/dist/packem_chunks/runInitCommand.mjs +1498 -0
  33. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
  34. package/dist/packem_chunks/runResetCommand.mjs +41 -0
  35. package/dist/packem_chunks/runRpcCommand.mjs +68 -0
  36. package/dist/packem_shared/COMMANDS-D3h9Iwvl.mjs +944 -0
  37. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
  38. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
  39. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
  40. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
  41. package/dist/packem_shared/command-BC30oSBW.mjs +14 -0
  42. package/dist/packem_shared/commands-DPKWlqqX.mjs +812 -0
  43. package/dist/packem_shared/createLogger-B40gPzQo.mjs +78 -0
  44. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +43 -0
  45. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +61 -0
  46. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +161 -0
  47. package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
  48. package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
  49. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
  50. package/dist/packem_shared/output-format-wUvAN6AL.mjs +17 -0
  51. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
  52. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
  53. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
  54. package/dist/packem_shared/runAddCommand-CTRA_JlL.mjs +4 -0
  55. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
  56. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +43 -0
  57. package/dist/packem_shared/storage-2RJBhUC4.mjs +84 -0
  58. package/dist/packem_shared/tui-prompts-DEiPCKV-.mjs +661 -0
  59. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
  60. package/package.json +62 -18
  61. package/skills/README.md +29 -0
  62. package/skills/lunora/SKILL.md +83 -0
  63. package/skills/lunora-create-package/SKILL.md +129 -0
  64. package/skills/lunora-deploy/SKILL.md +150 -0
  65. package/skills/lunora-functions/SKILL.md +182 -0
  66. package/skills/lunora-migration-helper/SKILL.md +194 -0
  67. package/skills/lunora-performance-audit/SKILL.md +143 -0
  68. package/skills/lunora-quickstart/SKILL.md +240 -0
  69. package/skills/lunora-realtime/SKILL.md +177 -0
  70. package/skills/lunora-setup-auth/SKILL.md +170 -0
  71. package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
  72. package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
  73. package/skills/lunora-setup-mail/SKILL.md +151 -0
  74. package/skills/lunora-setup-scheduler/SKILL.md +157 -0
  75. package/skills/lunora-setup-storage/SKILL.md +158 -0
@@ -0,0 +1,177 @@
1
+ ---
2
+ name: lunora-realtime
3
+ description: Wires Lunora's live data into a client. Use for `LunoraClient`/`LunoraProvider`,
4
+ reactive `useQuery`/`useSubscription`, `useMutation` with optimistic updates,
5
+ pagination, connection status, the React/Vue/Solid/Svelte adapters, and the
6
+ `@lunora/db` TanStack binding.
7
+ ---
8
+
9
+ # Lunora Realtime
10
+
11
+ Consume Lunora functions reactively from the client. Queries are live
12
+ subscriptions over WebSocket — a `useQuery` re-renders the instant a mutation
13
+ changes the rows it reads — and mutations can paint optimistically with
14
+ automatic rollback.
15
+
16
+ ## When to Use
17
+
18
+ - Wiring a frontend to a Lunora backend (React, Vue, Solid, Svelte).
19
+ - Adding optimistic updates, pagination, or presence to the UI.
20
+ - Choosing between live hooks and the `@lunora/db` collection layer.
21
+
22
+ ## When Not to Use
23
+
24
+ - Writing the server functions themselves — that's `lunora-functions`.
25
+ - Initial project/provider scaffolding — that's `lunora-quickstart`.
26
+
27
+ ## Provider Setup
28
+
29
+ Create the `LunoraClient` once at module scope and wrap the app. The client owns
30
+ the WebSocket, the optimistic cache, and the offline queue.
31
+
32
+ ```tsx
33
+ import { LunoraClient } from "@lunora/client";
34
+ import { LunoraProvider } from "@lunora/react";
35
+
36
+ const url = (import.meta.env.VITE_LUNORA_URL as string | undefined) ?? globalThis.location.origin;
37
+ const client = new LunoraClient({ url });
38
+
39
+ // <LunoraProvider client={client}>…</LunoraProvider>
40
+ ```
41
+
42
+ Vue / Solid / Svelte have matching providers in `@lunora/vue`, `@lunora/solid`,
43
+ `@lunora/svelte`; the hook names and semantics below mirror across them.
44
+
45
+ ## Live Queries
46
+
47
+ `useQuery(reference, args)` opens a subscription and returns the value, or
48
+ `undefined` while it loads. The reference comes from codegen
49
+ (`api.<file>.<name>`).
50
+
51
+ ```tsx
52
+ import { useQuery } from "@lunora/react";
53
+
54
+ import { api } from "../../lunora/_generated/api";
55
+ import type { Doc } from "../../lunora/_generated/dataModel";
56
+
57
+ const todos = useQuery(api.todos.list, {}) as Doc<"todos">[] | undefined;
58
+ ```
59
+
60
+ - `undefined` means "loading" — render a skeleton/spinner for it.
61
+ - The subscription tears down on unmount and re-runs only when `args` change or
62
+ the underlying rows change. Keep `args` narrow so a write elsewhere doesn't
63
+ re-push unrelated data (see `lunora-performance-audit`).
64
+ - `useSubscription` is the lower-level primitive for streaming subscriptions;
65
+ `usePreloadedQuery` + `lunoraQueryOptions` support SSR/preload handoff via
66
+ `@lunora/react/server`.
67
+
68
+ ## Authorization & Live Queries
69
+
70
+ Subscriptions re-run the query handler **server-side under anonymous
71
+ identity**. The one-shot `fetch` RPC behind the initial load carries the
72
+ caller's identity, but the live WebSocket channel (the subscription seed and
73
+ every write-driven refresh) does not — it evaluates as anonymous.
74
+
75
+ This matters for any query that authorizes or filters on the authenticated
76
+ user:
77
+
78
+ - A query guarded by `.use(rls(...))` or one that reads `ctx.auth.userId`
79
+ directly returns the user's rows on the **initial** HTTP fetch, but its
80
+ **live** updates evaluate anonymously and may resolve to an empty/denied
81
+ set.
82
+ - This **fails closed** — the live channel shows _less_ data, never another
83
+ user's data, so there is no leak. But it is a correctness caveat: the
84
+ initial render and the live updates can disagree.
85
+
86
+ The supported pattern today is to scope per-user data **outside** of
87
+ `ctx.auth` inside a subscribed query:
88
+
89
+ - Partition the data by shard with `.shardBy(userId)` (or tenant/room), so the
90
+ subscription is already scoped to the right state, or
91
+ - Pass the identifier as an **explicit query arg**
92
+ (`useQuery(api.todos.list, { userId })`) and filter on the arg rather than on
93
+ `ctx.auth`.
94
+
95
+ Reserve `rls()` / `ctx.auth`-based filtering for non-subscribed reads (one-shot
96
+ actions/queries) where identity is always present.
97
+
98
+ ## Mutations + Optimistic Updates
99
+
100
+ `useMutation(reference)` returns `{ mutate, pending }`. Pass an `optimistic`
101
+ callback to paint the next state immediately; if the server rejects the call the
102
+ runtime rolls the cache back automatically.
103
+
104
+ ```tsx
105
+ import { useMutation } from "@lunora/react";
106
+
107
+ import type { Doc, Id } from "../../lunora/_generated/dataModel";
108
+
109
+ const { mutate: add, pending } = useMutation(api.todos.add);
110
+
111
+ await add(
112
+ { text },
113
+ {
114
+ optimistic: (current) => {
115
+ const list = (current as Doc<"todos">[] | undefined) ?? [];
116
+ const provisional: Doc<"todos"> = {
117
+ _id: `optimistic_${Date.now()}` as Id<"todos">,
118
+ _creationTime: Date.now(),
119
+ text,
120
+ done: false,
121
+ createdAt: Date.now(),
122
+ };
123
+ return [provisional, ...list];
124
+ },
125
+ },
126
+ );
127
+ ```
128
+
129
+ - The `optimistic` callback receives the current cached value and returns the
130
+ provisional one. When the server delta arrives it replaces the optimistic
131
+ entry; on failure the cache reverts.
132
+ - `pending` is `true` while the call is in flight — disable the submit button
133
+ with it.
134
+ - **Offline queue:** mutations made while disconnected are queued by
135
+ `LunoraClient` and replayed on reconnect (client-id-keyed, so they aren't
136
+ double-applied).
137
+
138
+ ## Pagination, Connection, Presence
139
+
140
+ - `usePaginatedQuery` / `useInfiniteQuery` — cursor pagination over a query that
141
+ ends in `.paginate(...)` on the server.
142
+ - `useConnectionStatus` — live socket state for an offline/reconnecting banner.
143
+ - `usePresence` — who's-here + heartbeat (pairs with the `presence` registry
144
+ item).
145
+ - `useAuth` + the `Authenticated` / `Unauthenticated` / `AuthLoading` gates —
146
+ see `lunora-setup-auth`.
147
+
148
+ ## `@lunora/db` — TanStack DB Collections
149
+
150
+ For richer client state (indexed local collections, cross-query joins, a durable
151
+ offline-transactions outbox), use `@lunora/db` instead of raw hooks. Scaffold
152
+ with `vis generate lunora-collections` (wires `defineCollections` from your
153
+ schema + functions into live TanStack DB collections). Reach for it when the app
154
+ needs client-side indexes/joins or a persistent optimistic outbox; raw `useQuery`
155
+ /`useMutation` are enough for straightforward live lists.
156
+
157
+ ## Common Pitfalls
158
+
159
+ 1. **Creating `LunoraClient` inside a component.** It re-opens the socket every
160
+ render — create it once at module scope.
161
+ 2. **Treating `undefined` as empty.** `undefined` is "loading", `[]` is "loaded,
162
+ empty" — branch on both.
163
+ 3. **Broad query args.** A subscription keyed too broadly re-renders on
164
+ unrelated writes; scope `args` to what the component shows.
165
+ 4. **Optimistic shape drift.** The provisional value must match the query's
166
+ element shape (including `_id`/`_creationTime`) or the UI flickers when the
167
+ real delta lands.
168
+
169
+ ## Checklist
170
+
171
+ - [ ] `LunoraClient` created once at module scope; app wrapped in the provider.
172
+ - [ ] Live reads use `useQuery`; `undefined` handled as loading.
173
+ - [ ] Writes use `useMutation`; `pending` disables submit; `optimistic` matches
174
+ the row shape.
175
+ - [ ] Query `args` scoped narrowly to avoid over-broad re-renders.
176
+ - [ ] Pagination via `usePaginatedQuery`/`useInfiniteQuery` over `.paginate`.
177
+ - [ ] Considered `@lunora/db` if the app needs local indexes/joins or an outbox.
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: lunora-setup-auth
3
+ description: Adds authentication to a Lunora app. Use for sign-up/sign-in, email/password,
4
+ OAuth (Clerk, Auth0), magic link, or email OTP via `lunora registry add auth`,
5
+ wiring the auth handler into the Worker, and gating functions on the session.
6
+ ---
7
+
8
+ # Lunora Setup Auth
9
+
10
+ Wire authentication into a Lunora app using the `auth` registry item, which is
11
+ built on `@lunora/auth` (a thin wrapper over
12
+ [better-auth](https://www.better-auth.com)) with sessions persisted in
13
+ `SessionDO` and identity tables in D1.
14
+
15
+ ## When to Use
16
+
17
+ - Adding sign-up / sign-in to a Lunora app.
18
+ - Adding an OAuth/OIDC provider (Clerk, Auth0), magic link, or email OTP.
19
+ - Gating queries/mutations on the signed-in user.
20
+
21
+ ## When Not to Use
22
+
23
+ - The project has no Lunora backend yet — use `lunora-quickstart` first.
24
+ - You only need to read `ctx.auth.userId` in a function and auth is already
25
+ installed — just use it.
26
+
27
+ ## Workflow
28
+
29
+ 1. Add the base `auth` item.
30
+ 2. Mount the auth request handler in the Worker entry.
31
+ 3. Configure env vars and the D1 database.
32
+ 4. (Optional) Layer a provider item (Clerk / Auth0 / magic link / OTP) on top.
33
+ 5. Gate functions on `ctx.auth.userId`; gate UI with the auth gates/hooks.
34
+
35
+ ## Step 1: Add the base item
36
+
37
+ ```bash
38
+ lunora registry add auth
39
+ ```
40
+
41
+ This:
42
+
43
+ 1. Adds `@lunora/auth`, `@lunora/mail`, and `@lunora/server` to `package.json`
44
+ (run `pnpm install` afterwards).
45
+ 2. Copies `lunora/auth/index.ts` — the auth instance (`buildAuth` / `getAuth`)
46
+ and the `/api/auth/*` request handler (`mountAuth`) — into your project. It is
47
+ **yours** to edit.
48
+ 3. Adds a D1 `DB` binding to `wrangler.jsonc` (better-auth persists
49
+ users/sessions there).
50
+ 4. Scaffolds `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and `MAIL_FROM` into
51
+ `.dev.vars`.
52
+
53
+ ## Step 2: Mount the handler
54
+
55
+ In your Worker entry, route `/api/auth/*` to the scaffolded handler (see the
56
+ generated `lunora/auth/index.ts` README block). `createWorker` handles the rest
57
+ of the RPC surface; the auth handler owns the better-auth endpoints.
58
+
59
+ ## Step 3: Env vars and the D1 database
60
+
61
+ | Var | Secret | Notes |
62
+ | -------------------- | ------ | ------------------------------------------------------------------- |
63
+ | `BETTER_AUTH_SECRET` | yes | Encryption secret, min 32 chars. `openssl rand -base64 32`. |
64
+ | `BETTER_AUTH_URL` | no | Public base URL, e.g. `http://localhost:8787` in dev, your domain. |
65
+ | `MAIL_FROM` | no | Sender for verification / reset mail. Captured in the dev Mail tab. |
66
+
67
+ Create the D1 database and paste its id into the `DB` binding in
68
+ `wrangler.jsonc`:
69
+
70
+ ```bash
71
+ wrangler d1 create my-app-db
72
+ ```
73
+
74
+ The better-auth schema (user/session/account/verification tables) is **not**
75
+ declared in `lunora/schema.ts` — it is managed by better-auth in D1. In dev,
76
+ `ensureMigrated(auth)` auto-applies it; in production prefer
77
+ `compileMigrationsSql(auth.options)` piped to `wrangler d1 execute`. Run
78
+ `lunora doctor` to confirm the `DB` binding has a real `database_id` (not a
79
+ placeholder).
80
+
81
+ Verification and password-reset emails are **captured into the Lunora Studio
82
+ Mail tab** in dev with zero email setup. For real delivery, `lunora registry add
83
+ mail` (adds the `SEND_EMAIL` binding) or set `RESEND_API_KEY`.
84
+
85
+ ## Step 4: Add a provider (optional)
86
+
87
+ Each provider item builds on the base `auth` item (`requires: ["auth"]`):
88
+
89
+ ```bash
90
+ lunora registry add auth-clerk # Clerk via better-auth genericOAuth
91
+ lunora registry add auth-auth0 # Auth0 via better-auth genericOAuth
92
+ lunora registry add auth-magic-link # passwordless magic-link (mail)
93
+ lunora registry add auth-otp # passwordless email one-time-password
94
+ ```
95
+
96
+ Add the base `auth` item first (or let the registry resolve the `requires`
97
+ dependency). OAuth items need the provider's client id/secret added to
98
+ `.dev.vars`.
99
+
100
+ ## Step 5: Use the session
101
+
102
+ ### In functions
103
+
104
+ The runtime resolves the session and exposes the user on every context:
105
+
106
+ ```ts
107
+ import { LunoraError, mutation, v } from "@lunora/server";
108
+
109
+ export const createDocument = mutation.input({ title: v.string() }).mutation(async ({ ctx, args: { title } }) => {
110
+ if (!ctx.auth.userId) {
111
+ throw new LunoraError("UNAUTHORIZED", "not signed in");
112
+ }
113
+ return ctx.db.insert("documents", { ownerId: ctx.auth.userId, title, createdAt: Date.now() });
114
+ });
115
+ ```
116
+
117
+ For richer checks (org membership, roles), compose `withAuthPlugins(auth)` and
118
+ call the better-auth server API — see the scaffolded `lunora/auth/index.ts`.
119
+
120
+ ### In the UI (React)
121
+
122
+ ```tsx
123
+ import { Authenticated, Unauthenticated, useAuth } from "@lunora/react";
124
+
125
+ function Account() {
126
+ const { user, signIn, signOut } = useAuth();
127
+
128
+ return (
129
+ <>
130
+ <Authenticated>
131
+ <span>Signed in as {user?.email}</span>
132
+ <button type="button" onClick={() => signOut()}>
133
+ Sign out
134
+ </button>
135
+ </Authenticated>
136
+ <Unauthenticated>
137
+ <button type="button" onClick={() => signIn()}>
138
+ Sign in
139
+ </button>
140
+ </Unauthenticated>
141
+ </>
142
+ );
143
+ }
144
+ ```
145
+
146
+ `@lunora/react` also exports `AuthLoading` and `useAuthState` for the loading
147
+ window before the session resolves.
148
+
149
+ ## Common Pitfalls
150
+
151
+ 1. **Declaring better-auth tables in `lunora/schema.ts`.** They live in D1 and
152
+ are managed by better-auth — do not add them to `defineSchema`.
153
+ 2. **Placeholder `database_id`.** The `DB` binding ships with a placeholder;
154
+ `wrangler d1 create` + paste the id, then `lunora doctor` to confirm.
155
+ 3. **Missing/short `BETTER_AUTH_SECRET`.** better-auth needs ≥32 chars;
156
+ `@lunora/auth` surfaces a clear error when it is absent.
157
+ 4. **Expecting prod email to "just work".** Dev captures mail into the Studio;
158
+ production needs `mail` (the `SEND_EMAIL` binding) or `RESEND_API_KEY` and a
159
+ verified sender domain.
160
+
161
+ ## Checklist
162
+
163
+ - [ ] `lunora registry add auth` run, `pnpm install` done.
164
+ - [ ] Auth handler mounted in the Worker entry.
165
+ - [ ] `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` / `MAIL_FROM` set in `.dev.vars`.
166
+ - [ ] D1 database created and its id pasted into the `DB` binding (`lunora
167
+ doctor` clean).
168
+ - [ ] Provider item added if needed (Clerk / Auth0 / magic link / OTP).
169
+ - [ ] Functions gate on `ctx.auth.userId`; UI uses the auth gates/`useAuth`.
170
+ - [ ] Verified sign-in → session → an authenticated query round-trip.
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: lunora-setup-hyperdrive
3
+ description: Connects an existing Postgres/MySQL database to a Lunora app from an action via Cloudflare Hyperdrive. Use for `@lunora/hyperdrive`, `ctx.sql`, `createHyperdrive` + `fromPostgresJs`/`fromNodePg`/`fromMysql2`, the `HYPERDRIVE` binding, `wrangler hyperdrive create`, and the action-only / non-reactive guardrails (live queries don't track external writes).
4
+ ---
5
+
6
+ # Lunora Setup Hyperdrive
7
+
8
+ Wire an **existing** Postgres/MySQL database into a Lunora app using
9
+ `@lunora/hyperdrive`, which surfaces a [Cloudflare
10
+ Hyperdrive](https://developers.cloudflare.com/hyperdrive/) binding's connection
11
+ string and a driver-agnostic `ctx.sql` client — usable **only inside an
12
+ `action`**.
13
+
14
+ > **Integrate, don't replace.** Hyperdrive talks to a database Lunora has no
15
+ > visibility into. Queries through `ctx.sql` are **non-deterministic** (forbidden
16
+ > in `query`/`mutation`, enforced by the `hyperdrive_outside_action` advisor
17
+ > lint) and external writes are **invisible to Lunora live queries** —
18
+ > subscriptions will NOT re-run when external rows change. Use it to read/write a
19
+ > legacy DB from an action; if you want that data reactive, write a projection
20
+ > into a `defineSchema` DO/D1 table.
21
+
22
+ ## When to Use
23
+
24
+ - Reading/writing an existing Postgres or MySQL database from a Lunora action.
25
+ - Incrementally migrating off a legacy DB while standing up Lunora alongside it.
26
+ - Backfilling/syncing external rows into reactive Lunora tables (projection).
27
+
28
+ ## When Not to Use
29
+
30
+ - You want the external data to be **reactive** out of the box — it can't be;
31
+ Hyperdrive writes don't hit the change-feed. Project into a `defineSchema`
32
+ table instead.
33
+ - You're reaching for `ctx.sql` inside a `query` or `mutation` — forbidden by
34
+ design (the advisor lint flags it). Move the access into an `action`.
35
+ - The app has no Lunora backend yet — use `lunora-quickstart` first.
36
+ - You'd be replacing `defineSchema` entirely — then you lose realtime, OCC,
37
+ optimistic updates, the offline queue, and the advisors. Use Lunora's own data
38
+ layer for app state.
39
+
40
+ ## Workflow
41
+
42
+ 1. Install `@lunora/hyperdrive` and one SQL driver.
43
+ 2. Create the Hyperdrive config (`wrangler hyperdrive create`) and add the
44
+ `HYPERDRIVE` binding to `wrangler.jsonc`.
45
+ 3. Regenerate types with `lunora codegen` so `ctx.sql` appears on `ActionCtx`.
46
+ 4. Read/write the external DB from an action via `ctx.sql`.
47
+ 5. (Optional) Project external rows into a `defineSchema` table to make them
48
+ reactive.
49
+
50
+ ## Step 1: Install the package + a driver
51
+
52
+ ```bash
53
+ pnpm add @lunora/hyperdrive
54
+ # choose ONE driver (optional peer deps — none is bundled):
55
+ pnpm add postgres # postgres.js → fromPostgresJs ($1, $2 placeholders)
56
+ # or
57
+ pnpm add pg # node-postgres → fromNodePg ($1, $2 placeholders)
58
+ # or
59
+ pnpm add mysql2 # mysql2 → fromMysql2 (? placeholders)
60
+ ```
61
+
62
+ ## Step 2: Create the binding
63
+
64
+ ```bash
65
+ wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host:5432/db" # gitleaks:allow -- placeholder, not a real secret
66
+ ```
67
+
68
+ This prints an `id`. Add the binding to `wrangler.jsonc`, using
69
+ `localConnectionString` so `lunora dev` connects to your DB directly (no edge
70
+ proxy locally):
71
+
72
+ ```jsonc
73
+ {
74
+ "hyperdrive": [
75
+ {
76
+ "binding": "HYPERDRIVE",
77
+ "id": "<the id from the command above>",
78
+ "localConnectionString": "postgres://user:pass@localhost:5432/db", // gitleaks:allow -- placeholder, not a real secret
79
+ },
80
+ ],
81
+ }
82
+ ```
83
+
84
+ Lunora validates this binding (errors if `binding` is missing; warns if `id` is
85
+ empty/placeholder — it can't connect), but it does **not** auto-write the `id`:
86
+ that's a remote resource only `wrangler hyperdrive create` can mint. Importing
87
+ `@lunora/hyperdrive` surfaces a hint reminding you to add it.
88
+
89
+ ## Step 3: Regenerate types
90
+
91
+ ```bash
92
+ lunora codegen
93
+ ```
94
+
95
+ When codegen sees `ctx.sql` used, it adds `sql: SqlClient` to **`ActionCtx`
96
+ only** — never `QueryCtx`/`MutationCtx` — with a JSDoc restating the
97
+ determinism/realtime caveat.
98
+
99
+ ## Step 4: Use `ctx.sql` from an action
100
+
101
+ ```ts
102
+ import { createHyperdrive, fromPostgresJs } from "@lunora/hyperdrive";
103
+ import postgres from "postgres";
104
+
105
+ import { action, v } from "@lunora/server";
106
+
107
+ export const listLegacyOrders = action.input({ orgId: v.string() }).action(async ({ ctx, args: { orgId } }) => {
108
+ const { connectionString } = createHyperdrive(ctx.env.HYPERDRIVE);
109
+ ctx.sql = fromPostgresJs(postgres(connectionString));
110
+
111
+ return ctx.sql.query<{ id: string; total: number }>("select id, total from orders where org = $1", [orgId]);
112
+ });
113
+ ```
114
+
115
+ The package never rewrites SQL — use your driver's native placeholders (`$1` for
116
+ Postgres, `?` for MySQL).
117
+
118
+ ## Step 5 (optional): Make it reactive via a projection
119
+
120
+ External writes don't hit the change-feed, so a subscription won't re-fire on a
121
+ Postgres change. To make external data reactive, write a projection into a
122
+ `defineSchema` table from the same action — that write _is_ tracked:
123
+
124
+ ```ts
125
+ const [row] = await ctx.sql.query<{ id: string; total: number }>("select id, total from orders where id = $1", [id]);
126
+
127
+ // This write re-runs live queries reading `orders`:
128
+ await ctx.runMutation("orders:upsert", { id: row.id, total: row.total });
129
+ ```
130
+
131
+ ## Common Pitfalls
132
+
133
+ 1. **`ctx.sql` in a query/mutation.** It's action-only by design; the
134
+ `hyperdrive_outside_action` advisor lint flags it. Move the SQL into an
135
+ action.
136
+ 2. **Expecting external writes to be reactive.** They aren't — Lunora can't see
137
+ them. Project into a `defineSchema` table if you need reactivity.
138
+ 3. **Placeholder / empty `id`.** A Hyperdrive binding with no real `id` can't
139
+ connect (Lunora warns). Run `wrangler hyperdrive create` and paste the id.
140
+ 4. **Bundling a driver as a hard dependency.** Drivers are optional peer deps —
141
+ install the one you use; don't assume one ships with the package.
142
+ 5. **Wrong placeholder syntax.** `$1, $2` for Postgres drivers, `?` for mysql2.
143
+ The package does not rewrite SQL.
144
+
145
+ ## Checklist
146
+
147
+ - [ ] `@lunora/hyperdrive` + one driver (`postgres`/`pg`/`mysql2`) installed.
148
+ - [ ] `wrangler hyperdrive create` run; `HYPERDRIVE` binding added to
149
+ `wrangler.jsonc` with a real `id` and a `localConnectionString` for dev.
150
+ - [ ] `lunora codegen` run so `ctx.sql` is typed on `ActionCtx`.
151
+ - [ ] All `ctx.sql` access lives in actions (advisor clean — no
152
+ `hyperdrive_outside_action`).
153
+ - [ ] If reactivity is needed, external rows are projected into a `defineSchema`
154
+ table.
@@ -0,0 +1,171 @@
1
+ ---
2
+ name: lunora-setup-hyperdrive-global
3
+ description: Use PlanetScale (Postgres/MySQL via Cloudflare Hyperdrive) as a first-class, REACTIVE `.global()` storage backend for a Lunora app — and migrate an existing D1 `.global()` dataset to it. Use for `@lunora/hyperdrive/global`, `.global({ backend: "hyperdrive" })`, `createHyperdriveGlobalCtxDb`, the app-builder `.hyperdriveGlobal()` declaration, the `HYPERDRIVE` binding pointing at PlanetScale, and `lunora migrate d1-to-hyperdrive`. NOT the same as `@lunora/hyperdrive` (which is an action-only, non-reactive `ctx.sql`).
4
+ ---
5
+
6
+ # Lunora Setup: Hyperdrive Global Backend (Postgres/MySQL)
7
+
8
+ Store Lunora `.global()` tables in a **Postgres or MySQL database reached
9
+ through [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/)**
10
+ — **PlanetScale**, Neon, RDS, or any Hyperdrive-reachable database — instead of
11
+ D1, as a **drop-in, fully reactive** backend. (PlanetScale on Cloudflare is the
12
+ headline use case; nothing here is PlanetScale-specific.)
13
+
14
+ > **`@lunora/hyperdrive/global` vs. `@lunora/hyperdrive` (`ctx.sql`).** The
15
+ > `ctx.sql` surface is an action-only, **non-reactive** escape hatch for
16
+ > integrating a legacy database. `@lunora/hyperdrive/global` is different: Lunora
17
+ > **owns** the schema (column-per-field, like D1) and routes every write through
18
+ > its own store core, so live queries stay reactive — the writer is injected as
19
+ > `globalDb` and the shard DO's broadcast hook re-runs subscriptions exactly as
20
+ > with D1.
21
+
22
+ ## When to Use
23
+
24
+ - You want `.global()` (cross-tenant) tables to live in PlanetScale rather than
25
+ D1 — e.g. for larger datasets, existing SQL tooling, or unified Cloudflare
26
+ billing.
27
+ - You're migrating an existing D1 `.global()` dataset to PlanetScale.
28
+
29
+ ## When Not to Use
30
+
31
+ - You only need to read/write a **legacy** external DB from an action — use
32
+ `@lunora/hyperdrive` (`ctx.sql`) instead; it's lighter and action-scoped.
33
+ - Your global data is small and D1 is fine — bare `.global()` (D1) needs no
34
+ extra binding or driver.
35
+
36
+ ## How it works
37
+
38
+ PlanetScale reuses Lunora's dialect-parameterized store core (the same one D1
39
+ uses): a `SqlDialect` (Postgres or MySQL) shapes the SQL, and a Hyperdrive-backed
40
+ `SqlExec` runs it from inside the Durable Object that hosts the global writer.
41
+ Values are stored SQLite-shaped (boolean → 1/0, JSON → text/json, bigint →
42
+ decimal), so the value codec is shared with D1.
43
+
44
+ ## Step 1: Install the package + a driver
45
+
46
+ ```bash
47
+ pnpm add @lunora/hyperdrive/global
48
+ # choose the driver matching your engine (optional peer deps — none is bundled):
49
+ pnpm add postgres # PlanetScale Postgres → fromPostgresJs
50
+ # or
51
+ pnpm add mysql2 # PlanetScale MySQL → mysql2/promise
52
+ ```
53
+
54
+ ## Step 2: Create the Hyperdrive binding pointing at PlanetScale
55
+
56
+ Create a PlanetScale database from the Cloudflare dashboard (unified billing),
57
+ then a Hyperdrive config over its connection string:
58
+
59
+ ```bash
60
+ wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host/db" # gitleaks:allow -- placeholder
61
+ ```
62
+
63
+ Add the binding to `wrangler.jsonc` (use `localConnectionString` for `lunora dev`):
64
+
65
+ ```jsonc
66
+ {
67
+ "hyperdrive": [
68
+ { "binding": "HYPERDRIVE", "id": "<id from the command>", "localConnectionString": "postgres://user:pass@localhost:5432/db" }, // gitleaks:allow -- placeholder
69
+ ],
70
+ }
71
+ ```
72
+
73
+ > **Read-your-writes:** point the Hyperdrive config at the **primary** (or pin
74
+ > writes to it) so a write then immediate read isn't served by a stale replica.
75
+
76
+ ## Step 3: Mark tables `.global({ backend: "hyperdrive" })`
77
+
78
+ ```ts
79
+ // lunora/schema.ts
80
+ import { defineSchema, defineTable, v } from "@lunora/server";
81
+
82
+ export default defineSchema({
83
+ settings: defineTable({ key: v.string(), value: v.string() }).global({ backend: "hyperdrive" }),
84
+ });
85
+ ```
86
+
87
+ A bare `.global()` stays on D1. **One backend per app** — mixing D1- and
88
+ PlanetScale-backed global tables in the same app isn't supported yet (codegen
89
+ errors clearly).
90
+
91
+ ## Step 4: Wire `.hyperdriveGlobal()` in the app composition
92
+
93
+ Codegen emits a `.hyperdriveGlobal()` builder method. Supply the engine and an `exec`
94
+ built from the Hyperdrive binding (cache the driver on the DO instance; rebuild
95
+ lazily after hibernation):
96
+
97
+ ```ts
98
+ import { buildPgExec } from "@lunora/hyperdrive/global";
99
+ import { fromPostgresJs } from "@lunora/hyperdrive";
100
+ import postgres from "postgres";
101
+
102
+ export default defineApp<Env>()
103
+ .shard((env) => env.SHARD)
104
+ .hyperdriveGlobal({
105
+ engine: "postgres",
106
+ exec: (env) => buildPgExec(fromPostgresJs(postgres(env.HYPERDRIVE.connectionString))),
107
+ });
108
+ ```
109
+
110
+ For MySQL, create the pool with the **`FOUND_ROWS`** flag (the optimistic-concurrency
111
+ guard needs matched-row counts, or idempotent writes raise spurious conflicts):
112
+
113
+ ```ts
114
+ import { buildMysqlExec } from "@lunora/hyperdrive/global";
115
+ import mysql from "mysql2/promise";
116
+
117
+ .hyperdriveGlobal({
118
+ engine: "mysql",
119
+ exec: (env) => buildMysqlExec(mysql.createPool({ uri: env.HYPERDRIVE.connectionString, flags: ["FOUND_ROWS"] })),
120
+ });
121
+ ```
122
+
123
+ ## Step 5: Regenerate + run
124
+
125
+ ```bash
126
+ lunora codegen # wires config.hyperdriveGlobal and the .hyperdriveGlobal() method
127
+ lunora dev
128
+ ```
129
+
130
+ Tables auto-provision on first use (the runtime runs the PlanetScale DDL through
131
+ the dialect — no manual migration needed), and subscriptions are reactive.
132
+
133
+ ## Migrating an existing D1 dataset → PlanetScale
134
+
135
+ Blue-green: deploy the new PlanetScale-backed worker alongside the old D1 one,
136
+ then copy the `.global()` data:
137
+
138
+ ```bash
139
+ lunora migrate d1-to-hyperdrive \
140
+ --from-url https://old-d1.example.com --from-token "$D1_ADMIN_TOKEN" \
141
+ --to-url https://new-ps.example.com --to-token "$PS_ADMIN_TOKEN" \
142
+ --tables settings,orders # omit to move every global table
143
+ # --out dump.ndjson # keep the intermediate dump to inspect
144
+ ```
145
+
146
+ It streams the source's global rows to NDJSON, imports them into the target
147
+ (whose `.global({ backend: "hyperdrive" })` tables route the writes to
148
+ PlanetScale), and verifies the row counts match. Rows whose `_id` already exists
149
+ are reported as conflicts, not duplicated.
150
+
151
+ > **Direct external writes** to PlanetScale (bypassing Lunora) are invisible to
152
+ > live queries — same as D1. Let Lunora own the writes.
153
+
154
+ ## Common Pitfalls
155
+
156
+ 1. **Mixing backends.** One global backend per app (codegen errors on a mix).
157
+ 2. **Missing driver.** `postgres`/`mysql2` are optional peer deps — install the
158
+ one matching your engine.
159
+ 3. **Stale-replica reads.** Point Hyperdrive at the primary for read-your-writes.
160
+ 4. **Expecting external writes to be reactive.** They aren't — route writes
161
+ through Lunora (`ctx.db`).
162
+
163
+ ## Checklist
164
+
165
+ - [ ] `@lunora/hyperdrive/global` + the engine's driver installed.
166
+ - [ ] Hyperdrive binding (`HYPERDRIVE`) created over the PlanetScale connection
167
+ string, with a `localConnectionString` for dev.
168
+ - [ ] Target tables marked `.global({ backend: "hyperdrive" })` (one backend per app).
169
+ - [ ] `.hyperdriveGlobal({ engine, exec })` wired in the app composition.
170
+ - [ ] `lunora codegen` run; `lunora dev` serves the tables reactively.
171
+ - [ ] (Migrating) `lunora migrate d1-to-hyperdrive` run; row counts verified.