@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,16 +1,26 @@
1
1
  ---
2
2
  name: lunora-functions
3
- description: Authoring rules for Lunora schema and functions. Use when writing or reviewing
4
- `lunora/` code — `defineSchema`/`defineTable`, `v.*` validators, query vs
5
- mutation vs action (and `internal*`), indexes & `withIndex`, the `ctx.db` API,
6
- pagination, scheduling, and `httpAction`.
3
+ description: Authoring rules for Lunora schema and server functions under `lunora/` —
4
+ `defineSchema`/`defineTable`, `v.*` validators, the generated
5
+ `query`/`mutation`/`action` builders (and `internal*`), indexes and
6
+ `withIndex`, the `ctx.db` API, pagination, `ctx.db.related`, opt-in `ctx.*`
7
+ capabilities, topics, modules, services, and `httpAction`. Use when writing or
8
+ reviewing files in `lunora/` (`schema.ts`, function files, `http.ts`,
9
+ `queues.ts`), when asked to "add a query/mutation/action", "add an index",
10
+ "call one function from another", or "add a webhook endpoint", or when
11
+ `lunora codegen` reports an error in a function.
7
12
  ---
8
13
 
9
14
  # Lunora Functions
10
15
 
11
- The core authoring rules for Lunora backend code. Read this before writing or
12
- changing anything under `lunora/`. After every edit, run `lunora codegen` — it
13
- regenerates `lunora/_generated/` and typechecks your schema + functions.
16
+ The core authoring rules for Lunora backend code. The verify loop after every
17
+ edit:
18
+
19
+ 1. `lunora codegen` — regenerates `lunora/_generated/` and prints advisor
20
+ findings (it does not run `tsc`).
21
+ 2. `lunora verify` — codegen dry-run plus `tsc --noEmit`, no files written. The
22
+ type-check needs a `tsconfig.json` (without one it warns and skips) and is
23
+ off under `--no-typecheck`.
14
24
 
15
25
  ## When to Use
16
26
 
@@ -30,7 +40,7 @@ regenerates `lunora/_generated/` and typechecks your schema + functions.
30
40
  a `v.*` validator. Declare an index for every access pattern you query by.
31
41
 
32
42
  ```ts
33
- import { defineSchema, defineTable, v } from "@lunora/server";
43
+ import { defineSchema, defineTable, v } from "lunorash/server";
34
44
 
35
45
  export default defineSchema({
36
46
  messages: defineTable({
@@ -46,8 +56,9 @@ export default defineSchema({
46
56
  });
47
57
  ```
48
58
 
49
- - Lunora injects `_id` and `_creationTime` on every row — do **not** declare
50
- them.
59
+ - Lunora injects `_id` and `_creationTime` on every row, so don't declare them.
60
+ - `lunorash/*` is the umbrella package templates install; `@lunora/server` is
61
+ the same module if the project depends on it directly.
51
62
  - `.index("name", ["a", "b"])` — columns are ordered; put equality columns
52
63
  first, then the range/sort column.
53
64
  - `.shardBy("ownerId")` partitions the table across Durable Objects by key;
@@ -60,34 +71,51 @@ export default defineSchema({
60
71
 
61
72
  `string`, `number`, `boolean`, `id("table")`, `null`, `any`, `bigint`, `bytes`,
62
73
  `literal(value)`, `array(item)`, `object({...})`, `record(key, value)`,
63
- `union(a, b, …)`, `optional(inner)`, plus the convenience types `date`,
64
- `timestamp`, and `storage` (an R2 object key). Use `v.optional(...)` for nullable
65
- fields — required is the default.
74
+ `union(a, b, …)`, `optional(inner)`, `partial({...})`, `geoPoint()`, the
75
+ convenience types `date`, `timestamp`, and `storage` (an R2 object key), and
76
+ `v.from(schema)` to adopt any Standard Schema v1 validator (Zod, Valibot, …).
66
77
 
67
- ## Functions: query / mutation / action
78
+ - Required is the default. `v.optional(x)` means the field may be absent;
79
+ `x.nullable()` means it may be `null`. They are different.
80
+ - Refinements chain on the validator and are enforced at the trust boundary:
81
+ `v.string().min(1).max(4096)`, `.email()`, `.url()`, `.pattern(re)`;
82
+ `v.number().int().min(0)`, `.positive()`; `v.array(x).max(n)`. Bound every
83
+ client-supplied string and array — templates cap text at `.max(4096)`.
68
84
 
69
- Each function declares its inputs with `.input(...)` (a `v.*` map) and ends with a
70
- terminal `.query` / `.mutation` / `.action` handler. Export them as named
71
- consts from `lunora/*.ts`; codegen surfaces them as `api.<file>.<name>`.
85
+ ## Functions: query / mutation / action
72
86
 
73
- | Kind | Reads `ctx.db` | Writes `ctx.db` | Side effects / `fetch` | Reactive |
74
- | ---------- | --------------------------------- | --------------- | ---------------------- | -------- |
75
- | `query` | yes | no | no | yes |
76
- | `mutation` | yes | yes | no | — |
77
- | `action` | no (use `runQuery`/`runMutation`) | no | yes | — |
87
+ Each function declares its inputs with `.input(...)` (a `v.*` map), optionally
88
+ adds `.use(middleware)` (rate limiting, RLS, masking) and `.output(validator)`,
89
+ and ends with a terminal `.query` / `.mutation` / `.action` taking a
90
+ `({ ctx, args }) => …` handler. Export them as named consts from `lunora/*.ts`.
91
+ Codegen nests references by path: `lunora/messages.ts` → `api.messages.<fn>`,
92
+ `lunora/billing/invoices.ts` → `api.billing.invoices.<fn>`. Public functions
93
+ land in `_generated/api.ts`, `internal*` ones in `_generated/internal.ts`.
94
+
95
+ | Kind | Reads `ctx.db` | Writes `ctx.db` | Side effects / `fetch` | Reactive |
96
+ | ---------- | -------------- | ------------------- | ---------------------- | -------- |
97
+ | `query` | yes | no | no | yes |
98
+ | `mutation` | yes | yes, transactional | no | — |
99
+ | `action` | yes | yes, autocommitting | yes | — |
100
+
101
+ Import the builders from `_generated/server`, not from `lunorash/server`: codegen
102
+ binds them to your schema, which is what types `ctx.db`, `v.id("channels")`, and
103
+ index names. `lunorash/server` exports the schema/HTTP/validator surface and
104
+ `LunoraError`, never `query` / `mutation` / `action`.
78
105
 
79
106
  ```ts
80
- import type { Id } from "@lunora/server";
81
- import { action, LunoraError, mutation, query, v } from "@lunora/server";
107
+ // lunora/messages.ts
108
+ import { LunoraError } from "lunorash/server";
82
109
 
83
- // `api` / `internal` come from codegen:
84
- // import { api, internal } from "./_generated/api";
110
+ import { api } from "./_generated/api.js";
111
+ import type { Doc, Id } from "./_generated/server.js";
112
+ import { action, mutation, query, v } from "./_generated/server.js";
85
113
 
86
- export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }) =>
114
+ export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }): Promise<Doc<"messages">[]> =>
87
115
  ctx.db
88
116
  .query("messages")
89
117
  .withIndex("by_channel", (q) => q.eq("channelId", channelId))
90
- .collect(),
118
+ .take(100),
91
119
  );
92
120
 
93
121
  export const send = mutation
@@ -104,21 +132,37 @@ export const send = mutation
104
132
  });
105
133
  });
106
134
 
107
- export const notifySlack = action.input({ messageId: v.id("messages") }).action(async ({ ctx, args: { messageId } }) => {
108
- const message = await ctx.runQuery(api.messages.getById, { messageId });
109
- await fetch(SLACK_WEBHOOK, { method: "POST", body: JSON.stringify(message) });
135
+ export const notifySlack = action.input({ channelId: v.id("channels") }).action(async ({ ctx, args: { channelId } }) => {
136
+ // A public action is callable by anyone: gate it like a mutation (and check
137
+ // channel access too, if your app has per-channel membership).
138
+ if (!ctx.auth.userId) {
139
+ throw new LunoraError("UNAUTHORIZED", "not signed in");
140
+ }
141
+ const messages = await ctx.runQuery(api.messages.listByChannel, { channelId });
142
+ const response = await fetch(String(ctx.env?.SLACK_WEBHOOK_URL), { method: "POST", body: JSON.stringify(messages) });
143
+ if (!response.ok) {
144
+ throw new Error(`Slack webhook failed: ${String(response.status)}`); // fetch resolves on 4xx/5xx
145
+ }
110
146
  });
111
147
  ```
112
148
 
149
+ Templates also map `#lunora/*` to `./lunora/*` in `package.json` `imports`, so
150
+ `#lunora/_generated/server.js` works from anywhere in the project.
151
+
113
152
  - **Pick the right kind.** Reactive read → `query`. Transactional write →
114
153
  `mutation`. External I/O (`fetch`, third-party SDKs, calling other functions)
115
- → `action`. An action has no `ctx.db`; it reaches data via `ctx.runQuery` /
116
- `ctx.runMutation`.
154
+ → `action`. An action has a `ctx.db`, but its own writes are not
155
+ transactional: each autocommits as it runs, so a later throw rolls nothing
156
+ back. Reach data through `ctx.runQuery` / `ctx.runMutation` — a mutation
157
+ called that way runs in the same all-or-nothing transaction a top-level one
158
+ gets, so put every write that has to land together in one mutation rather
159
+ than sequencing several from the action.
117
160
  - **`internal*` variants** (`internalQuery`, `internalMutation`,
118
161
  `internalAction`) are not exposed to clients — use them for server-only logic
119
162
  called from actions, crons, or other functions.
120
- - **Throw `LunoraError`** (`import { LunoraError } from "@lunora/server"`) with a
121
- code + message for expected failures; it serializes cleanly to the client.
163
+ - **Throw `LunoraError(code, message?, options?)`** from `lunorash/server` for
164
+ expected failures (`"UNAUTHORIZED"`, `"NOT_FOUND"`, …); it serializes with its
165
+ code to the client, where `isUnauthorizedError` and friends branch on it.
122
166
 
123
167
  ## The `ctx.db` API
124
168
 
@@ -127,15 +171,16 @@ Reads:
127
171
  ```ts
128
172
  await ctx.db.get(id); // one row by id (or null)
129
173
  ctx.db.query("t").withIndex("by_x", (q) => q.eq("x", v)); // indexed query
174
+ .order("asc" | "desc") // by the active index (or _creationTime)
175
+ .filter((doc) => …) // JS predicate, applied after the index range
130
176
  .collect(); // all matching rows
131
- .first(); // first row or null
132
- .unique(); // exactly one (throws if 0 or >1)
133
177
  .take(n); // first n rows
134
- .order("asc" | "desc") // sort by the index range
135
- .paginate(opts); // cursor page (pair with usePaginatedQuery)
178
+ .first(); // first row or null
179
+ .unique(); // the one match, null if none; throws if more than one
180
+ .paginate({ numItems, cursor }); // { page, isDone, continueCursor }
136
181
  ```
137
182
 
138
- Writes (mutations only):
183
+ Writes (mutations, and autocommitting in actions):
139
184
 
140
185
  ```ts
141
186
  await ctx.db.insert("t", { ...fields }); // returns the new Id
@@ -144,39 +189,300 @@ await ctx.db.replace(id, { ...allFields }); // full overwrite
144
189
  await ctx.db.delete(id);
145
190
  ```
146
191
 
147
- **Prefer `withIndex` over `.filter`.** A `.filter(...)` with no covering index
148
- scans the whole table — `@lunora/advisor` flags it as `filter-without-index`.
149
- Declare the index and constrain with `.withIndex`.
192
+ Prefer `withIndex` over `.filter`: a `.filter(...)` without a covering index
193
+ scans the whole table (advisor: `filter_without_index`), and a bare `.collect()`
194
+ grows with the table and with every subscriber's payload (`unbounded_collect`).
195
+ Constrain with `.withIndex`, cap with `.take(n)`, or paginate.
196
+
197
+ ### Pagination
198
+
199
+ A paginated query takes a `paginationOpts` arg and returns `.paginate(...)`
200
+ as-is; the client hook (`usePaginatedQuery`, see `lunora-realtime`) supplies it:
201
+
202
+ ```ts
203
+ // `v.any()` would type the arg `unknown`, which `.paginate()` rejects. The hook
204
+ // also sends `endCursor`; an undeclared key is stripped, so declare it. Bound
205
+ // `numItems` too: it is caller input and sets the read limit.
206
+ const cursor = v.optional(v.union(v.string(), v.null()));
207
+
208
+ export const page = query
209
+ .input({ channelId: v.id("channels"), paginationOpts: v.object({ cursor, endCursor: cursor, numItems: v.number().int().min(1).max(100) }) })
210
+ .query(async ({ ctx, args: { channelId, paginationOpts } }) =>
211
+ ctx.db
212
+ .query("messages")
213
+ .withIndex("by_channel", (q) => q.eq("channelId", channelId))
214
+ .order("desc")
215
+ .paginate(paginationOpts),
216
+ );
217
+ ```
218
+
219
+ ### Following foreign keys: `ctx.db.related`
220
+
221
+ Every `v.id("table")` column is an edge in a graph your schema already declares.
222
+ `ctx.db.related` walks it, so "this customer's tickets, and those tickets'
223
+ messages" is one call instead of a hand-written chain of `withIndex` lookups.
224
+
225
+ ```ts
226
+ const { continueCursor, isDone, nodes } = await ctx.db.related(
227
+ { table: "customers", id: customerId }, // or a row you already loaded
228
+ { depth: 2, direction: "in", edges: ["tickets.customerId", "messages.ticketId"], limit: 50 },
229
+ );
230
+
231
+ for (const node of nodes) {
232
+ node.table; // "messages"
233
+ node.document; // the row itself
234
+ node.depth; // 2
235
+ node.score; // 0.5 — 1 at depth 1, halving per hop
236
+ node.path; // ["tickets.customerId", "messages.ticketId"]
237
+ node.pathIds; // ids along the way, start included
238
+ }
239
+ ```
240
+
241
+ - **Edge names are `"<table>.<column>"`.** `edges` restricts the walk to the
242
+ named ones; a name the schema does not declare is refused, not ignored.
243
+ - **`direction`** — `"out"` follows the ids this row holds, `"in"` the rows that
244
+ point at it, `"both"` (the default) does both.
245
+ - **`depth`** defaults to `1`, max `4`. **`limit`** defaults to `50`, max `200`.
246
+ Both caps refuse rather than clamp, so a probe for the ceiling just errors.
247
+ - **Only a column is an edge**: a bare `v.id(...)`, `v.optional(v.id(...))` or
248
+ `v.array(v.id(...))`. An id nested in a `v.object` / `v.union` / `v.record` is
249
+ not. An array FK is followed outward only.
250
+ - **It is an ordinary read** — RLS, column masks, soft delete, `.global()`
251
+ routing and reactivity all apply, because every hop goes back through
252
+ `ctx.db`. Under a `.rls("required")` schema each hop gets exactly the verdict a
253
+ direct read of that table would, so declare a read policy for every table the
254
+ walk can reach — or narrow it with `edges`.
255
+ - Index the foreign keys. Each inward hop is a `WHERE fk IN (…)` read, and
256
+ unindexed it scans — see the `lunora-performance-audit` skill for the cost
257
+ model and the traversal caps.
150
258
 
151
259
  ## Other `ctx` capabilities
152
260
 
153
- `ctx.auth` (the resolved session: `ctx.auth.userId`), `ctx.scheduler`
154
- (`runAfter` / `runAt` for deferred work), `ctx.storage` (R2), `ctx.vectors`
155
- (Vectorize), and — when their packages are wired — `ctx.ai` and `ctx.containers`.
261
+ Always available:
262
+
263
+ - `ctx.auth` — the resolved session (`ctx.auth.userId`, `null` when anonymous).
264
+ - `ctx.secrets` — Cloudflare Secrets Store.
265
+ - `ctx.ip` — the caller's `CF-Connecting-IP`; `undefined` off Cloudflare.
266
+ - `ctx.runQuery` (all kinds), `ctx.runMutation` (mutations, actions),
267
+ `ctx.runAction` (actions) — call another function by its `api.*` /
268
+ `internal.*` reference.
269
+ - `ctx.log`, `ctx.metrics`, `ctx.span` / `ctx.trace` — logging, metrics, the
270
+ current wide-event span and a scoped tracing helper.
271
+
272
+ Wired by codegen on use. A dependency in `package.json` is not enough: codegen
273
+ scans the `lunora/` source set and turns a capability on only when a file there
274
+ imports its `@lunora/*` package or reads its `ctx.*` helper. Write the call
275
+ first, then run `lunora codegen` to wire the binding and typed context:
276
+
277
+ | `ctx.*` | Package |
278
+ | ------------------------------------------------------------------- | ----------------------------- |
279
+ | `ctx.scheduler` (mutations + actions; see `lunora-setup-scheduler`) | `@lunora/scheduler` |
280
+ | `ctx.storage` (R2; see `lunora-setup-storage`) | `@lunora/storage` |
281
+ | `ctx.ai` | `@lunora/ai` (Workers AI) |
282
+ | `ctx.flags` | `@lunora/flags` (OpenFeature) |
283
+ | `ctx.queues.<name>` | `@lunora/queue` |
284
+ | `ctx.topics.<name>` (pub/sub, see below) | `@lunora/queue` |
285
+ | `ctx.workflows` / `ctx.runStep` | `@lunora/workflow` |
286
+ | `ctx.containers` | `@lunora/container` |
287
+ | `ctx.browser` (action-only) | `@lunora/browser` |
288
+ | `ctx.sql` (action-only) | `@lunora/hyperdrive` |
289
+ | `ctx.kv` / `ctx.images` / `ctx.analytics` | `@lunora/bindings` subpaths |
290
+ | `ctx.pipelines` / `ctx.vectors` / `ctx.r2sql` | `@lunora/bindings` subpaths |
291
+
292
+ One exception to the usage scan, and one extra requirement:
293
+
294
+ - **`ctx.flags` gates on a declaration file**, not on usage — codegen wires it
295
+ only when `lunora/flags.ts` exists. `ctx.notify` / `ctx.push` work the same
296
+ way via `lunora/notify.ts`.
297
+ - **`ctx.sql` also needs the real resource.** Codegen types the field, but the
298
+ connection needs a `HYPERDRIVE` binding (`wrangler hyperdrive create`) and an
299
+ explicit `createHyperdrive(ctx.env.HYPERDRIVE)` + driver adapter in the
300
+ action — see `lunora-setup-hyperdrive`. Bindings codegen can provision on its
301
+ own (e.g. `BROWSER` for `ctx.browser`) need no manual wrangler step.
302
+
303
+ `ctx.browser` and `ctx.sql` are **action-only** by design. They are external,
304
+ non-deterministic I/O: a query is re-run on every subscription re-evaluation, so
305
+ a non-deterministic read makes reactivity wrong, and a mutation's writes are
306
+ transactional — a rollback cannot un-send a network call.
307
+
308
+ ### Topics: one event, many consumers
309
+
310
+ A queue has one consumer. To fan one event out to several independent
311
+ consumers, declare a topic and subscribe to it — in `lunora/queues.ts`, next to
312
+ your queues. Each subscription is its own queue with its own retries and
313
+ dead-letter queue.
314
+
315
+ ```ts
316
+ // lunora/queues.ts
317
+ import { defineSubscription, defineTopic } from "@lunora/queue";
318
+
319
+ import { internal } from "./_generated/internal.js";
320
+
321
+ export const signups = defineTopic<{ userId: string }>();
322
+
323
+ export const welcomeEmail = defineSubscription(signups, {
324
+ handler: async (_ctx, batch) => {
325
+ for (const message of batch.messages) {
326
+ await message.run(internal.email.welcome, { userId: message.body.userId });
327
+ message.ack();
328
+ }
329
+ },
330
+ maxRetries: 5,
331
+ deadLetterQueue: "welcome-email-dlq",
332
+ });
333
+
334
+ // in a mutation or action
335
+ await ctx.topics.signups.publish({ userId });
336
+ ```
337
+
338
+ - Delivery is **at-least-once per subscription** and unordered — make handlers
339
+ idempotent. A failed send rejects `publish`, and a retry re-delivers to the
340
+ subscriptions that already got it.
341
+ - A subscription is published to only through its topic, never `ctx.queues`.
342
+ The topic passed to `defineSubscription` must be a `defineTopic` export of
343
+ `lunora/queues.ts` or a module's `queues.ts` (import it from there).
344
+ - Rate-limit public procedures that publish: one publish is one send per
345
+ subscription (`privileged_fanout_from_public_procedure` flags it).
346
+
347
+ ## Modules: grouping `lunora/` folders
348
+
349
+ A folder whose `module.ts` default-exports `defineModule(...)` is a module.
350
+ Modules are metadata — no `api.*` path or runtime change, still one Worker —
351
+ that drive the Studio **Architecture** view, OpenAPI tags, and table ownership:
352
+
353
+ ```ts
354
+ // lunora/billing/module.ts
355
+ import { defineModule } from "lunorash/server";
356
+
357
+ export default defineModule({ description: "Invoices and payments", tables: ["invoices"] });
358
+ ```
359
+
360
+ - Write `description` and `tables` inline; codegen reads them without running
361
+ the file. Modules do not nest.
362
+ - A module can declare its own queues, topics and subscriptions in
363
+ `lunora/<module>/queues.ts`. Export names stay app-wide (`ctx.queues.<name>`),
364
+ so two queues files exporting the same name is a codegen error.
365
+ - `cross_module_table_write` warns when a function outside the owning module
366
+ writes an owned table (insert, `patch`/`replace`/`delete`, batch or facade),
367
+ including through a same-file helper. Move the write into the owning module:
368
+ an exported helper there taking the caller's `ctx` (`openInvoice(ctx, …)`), or
369
+ a registered owner mutation via `ctx.runMutation(internal.<owner>.<fn>, …)`.
370
+ - Installed components (`.extend(...)` schema extensions) count as modules that
371
+ own their prefixed tables: write them through the component's functions, not
372
+ `ctx.db` directly.
373
+ - Keep ids typed `Id<"table">` — a write through an untyped `string` id cannot
374
+ be attributed to a table.
375
+
376
+ ## Services: calling sibling Workers
377
+
378
+ A Worker the app calls (a parser, an LLM gateway) is declared in
379
+ `lunora.config.ts`, not discovered. Each one becomes a typed `ctx.services.<key>`
380
+ on **actions** (not queries/mutations: a cross-Worker call cannot be replayed or
381
+ rolled back; HTTP actions reach it through `ctx.runAction`):
382
+
383
+ ```ts
384
+ // lunora.config.ts
385
+ export default {
386
+ services: {
387
+ documentParser: { dir: "services/document-parser" }, // fetch service
388
+ llmGateway: { dir: "services/llm-gateway", entrypoint: "Gateway" }, // RPC
389
+ },
390
+ };
391
+
392
+ // in an action
393
+ const parsed = await ctx.services.documentParser.fetch("https://parser/parse", { body, method: "POST" });
394
+ const text = await ctx.services.llmGateway.complete(prompt); // typed from the Gateway class
395
+ ```
396
+
397
+ - The Worker name and entry come from `<dir>/wrangler.jsonc`; Lunora writes the
398
+ `services[]` binding (`SERVICE_<KEY>`), runs it in the same `lunora dev` /
399
+ `vite dev` session, and deploys it before the app.
400
+ - A bound service needs no URL, no `*_URL` var and no HMAC: set
401
+ `"workers_dev": false` on it when it has no public route (`lunora doctor` warns
402
+ otherwise). Keep its HMAC check if it has a custom domain or route.
156
403
 
157
404
  ## HTTP endpoints
158
405
 
159
406
  For webhooks or non-RPC HTTP, use `httpRouter` / `httpRoute` + `httpAction`:
160
407
 
408
+ `httpRouter()` takes **no arguments** — it returns a [Hono](https://hono.dev)
409
+ app you mount routes on, and you export the app. Passing it a routes object is a
410
+ type error (`Expected 0 arguments`), and from untyped code the routes simply
411
+ never mount.
412
+
161
413
  ```ts
162
- import { httpAction, httpRouter } from "@lunora/server";
414
+ // lunora/http.ts
415
+ import { httpRouter } from "lunorash/server";
163
416
 
164
- export default httpRouter({
165
- "/webhooks/stripe": httpAction(async (ctx, request) => {
166
- const event = await request.json();
167
- await ctx.runMutation(internal.billing.record, { event });
168
- return new Response("ok");
169
- }),
417
+ import { internal } from "./_generated/internal.js";
418
+
419
+ const app = httpRouter();
420
+ const encoder = new TextEncoder();
421
+
422
+ // Stripe's scheme: `stripe-signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
423
+ // The Stripe SDK's `stripe.webhooks.constructEventAsync` does the same check.
424
+ const verifyStripe = async (secret: string, header: string | undefined, payload: string): Promise<boolean> => {
425
+ const fields = header?.split(",").map((part) => part.split("=", 2)) ?? [];
426
+ const t = fields.find(([k]) => k === "t")?.[1];
427
+ // While a secret is being rolled Stripe sends one `v1` per secret; accept any.
428
+ const v1s = fields.filter(([k, value]) => k === "v1" && /^[\da-f]{64}$/.test(value ?? "")).map(([, value]) => value ?? "");
429
+
430
+ if (!t || v1s.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
431
+ return false; // missing, malformed, or outside the 5-minute replay window
432
+ }
433
+
434
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
435
+ const signed = encoder.encode(`${t}.${payload}`);
436
+
437
+ for (const v1 of v1s) {
438
+ const signature = Uint8Array.from(v1.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16));
439
+
440
+ if (await crypto.subtle.verify("HMAC", key, signature, signed)) {
441
+ return true; // constant-time compare
442
+ }
443
+ }
444
+
445
+ return false;
446
+ };
447
+
448
+ // A plain Hono handler, not `httpAction`: the action ctx (`c.var.lunora`) has
449
+ // no env, while `c.env` is the Worker env.
450
+ app.post("/webhooks/stripe", async (c) => {
451
+ const secret = c.env.STRIPE_WEBHOOK_SECRET;
452
+
453
+ if (typeof secret !== "string") {
454
+ throw new Error("STRIPE_WEBHOOK_SECRET is not set");
455
+ }
456
+
457
+ const payload = await c.req.text(); // the raw bytes the signature covers
458
+
459
+ if (!(await verifyStripe(secret, c.req.header("stripe-signature"), payload))) {
460
+ return c.text("invalid signature", 401);
461
+ }
462
+
463
+ await c.var.lunora.runMutation(internal.billing.record, { event: JSON.parse(payload) as unknown });
464
+
465
+ return c.text("ok");
170
466
  });
467
+
468
+ export default app;
171
469
  ```
172
470
 
471
+ A webhook route is public: verify the provider's signature over the raw body
472
+ before any write, or anyone can forge events into `internal.*` functions.
473
+
173
474
  ## Checklist
174
475
 
175
476
  - [ ] Schema columns are `v.*` validators; `_id`/`_creationTime` not declared.
176
477
  - [ ] An index exists for every access pattern; queries use `withIndex`, not
177
- `.filter`.
478
+ `.filter`, and are bounded (`.take(n)` / `.paginate`).
479
+ - [ ] Client-supplied strings and arrays carry a `.max(...)`.
178
480
  - [ ] Right function kind: `query` (reactive read) / `mutation` (write) /
179
481
  `action` (side effects via `runQuery`/`runMutation`).
180
482
  - [ ] Server-only logic uses `internal*`; expected failures throw `LunoraError`.
181
- - [ ] `ctx.db` writes only inside mutations; ids typed with `Id<"table">`.
182
- - [ ] Ran `lunora codegen`; typecheck is clean.
483
+ - [ ] Writes that must land together sit in one mutation; ids typed `Id<"table">`.
484
+ - [ ] Any `ctx.db.related` walk is narrowed with `edges` / `direction`, and its
485
+ foreign keys are indexed.
486
+ - [ ] Writes to a module- or component-owned table go through the owner's
487
+ functions (`cross_module_table_write` is clean).
488
+ - [ ] `lunora codegen` shows no new advisor findings; `lunora verify` is clean.