@lunora/cli 1.0.0-alpha.41 → 1.0.0-alpha.410

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 (136) hide show
  1. package/LICENSE.md +33 -0
  2. package/README.md +3 -1
  3. package/dist/bin.mjs +2 -10
  4. package/dist/index.d.mts +1045 -374
  5. package/dist/index.d.ts +1045 -374
  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 +1 -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 +3 -131
  13. package/dist/packem_chunks/handler14.mjs +1 -65
  14. package/dist/packem_chunks/handler15.mjs +1 -58
  15. package/dist/packem_chunks/handler16.mjs +1 -80
  16. package/dist/packem_chunks/handler17.mjs +7 -43
  17. package/dist/packem_chunks/handler18.mjs +1 -105
  18. package/dist/packem_chunks/handler19.mjs +1 -170
  19. package/dist/packem_chunks/handler2.mjs +1 -114
  20. package/dist/packem_chunks/handler20.mjs +3 -94
  21. package/dist/packem_chunks/handler21.mjs +3 -94
  22. package/dist/packem_chunks/handler22.mjs +1 -0
  23. package/dist/packem_chunks/handler23.mjs +2 -0
  24. package/dist/packem_chunks/handler24.mjs +99 -0
  25. package/dist/packem_chunks/handler25.mjs +2 -0
  26. package/dist/packem_chunks/handler26.mjs +9 -0
  27. package/dist/packem_chunks/handler27.mjs +1 -0
  28. package/dist/packem_chunks/handler3.mjs +1 -204
  29. package/dist/packem_chunks/handler4.mjs +3 -33
  30. package/dist/packem_chunks/handler5.mjs +1 -49
  31. package/dist/packem_chunks/handler6.mjs +1 -91
  32. package/dist/packem_chunks/handler7.mjs +1 -42
  33. package/dist/packem_chunks/handler8.mjs +2 -174
  34. package/dist/packem_chunks/handler9.mjs +1 -315
  35. package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
  36. package/dist/packem_chunks/runDeployCommand.mjs +23 -592
  37. package/dist/packem_chunks/runDevCommand.mjs +2 -0
  38. package/dist/packem_chunks/runInitCommand.mjs +165 -1432
  39. package/dist/packem_chunks/runResetCommand.mjs +1 -41
  40. package/dist/packem_chunks/runRpcCommand.mjs +1 -68
  41. package/dist/packem_shared/COMMANDS-BpfWKiiZ.mjs +1 -0
  42. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-CyIsI3S1.mjs +1 -0
  43. package/dist/packem_shared/EXIT_CODE-D_XCixv8.mjs +1 -0
  44. package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
  45. package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
  46. package/dist/packem_shared/api-spec-lYyq4vL6.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/cap-error-body-YBKO32BF.mjs +1 -0
  50. package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
  51. package/dist/packem_shared/cli-CPcfZdt0.mjs +2 -0
  52. package/dist/packem_shared/cli-manifest-DoQ_3bzs.mjs +1 -0
  53. package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
  54. package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
  55. package/dist/packem_shared/command-CB4JM-Jl.mjs +1 -0
  56. package/dist/packem_shared/commands-DvSfLvyt.mjs +13 -0
  57. package/dist/packem_shared/createLogger-4TujevyB.mjs +2 -0
  58. package/dist/packem_shared/createRecordingSpawner-Ch7zuQQE.mjs +1 -0
  59. package/dist/packem_shared/deploy-target-resolve-BTCW1Iv4.mjs +1 -0
  60. package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
  61. package/dist/packem_shared/docker-AqLAkOyY.mjs +1 -0
  62. package/dist/packem_shared/free-port-DjbFhaaT.mjs +1 -0
  63. package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
  64. package/dist/packem_shared/import-BRM1hs7j.mjs +12 -0
  65. package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
  66. package/dist/packem_shared/insertSchemaExtension-DuiV6cba.mjs +8 -0
  67. package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
  68. package/dist/packem_shared/mcp-config-file-CcDTeXIq.mjs +2 -0
  69. package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
  70. package/dist/packem_shared/output-format-DWe_tkTR.mjs +2 -0
  71. package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
  72. package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
  73. package/dist/packem_shared/plan-DFY3CqHD.mjs +5 -0
  74. package/dist/packem_shared/planDevCommand-BDoJUgQ4.mjs +1 -0
  75. package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
  76. package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
  77. package/dist/packem_shared/render-lunora-error-B6893vbU.mjs +2 -0
  78. package/dist/packem_shared/resolve-Bdc9KEUv.mjs +5 -0
  79. package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
  80. package/dist/packem_shared/runAddCommand-BChdJQ5L.mjs +1 -0
  81. package/dist/packem_shared/runExportCommand-slAq5BCF.mjs +5 -0
  82. package/dist/packem_shared/runMigrateGenerateCommand-BwemPIOz.mjs +11 -0
  83. package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
  84. package/dist/packem_shared/secret-key-8DPOH98z.mjs +1 -0
  85. package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
  86. package/dist/packem_shared/storage-DNzYhXR-.mjs +1 -0
  87. package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
  88. package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
  89. package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
  90. package/dist/packem_shared/wrangler-secrets-CTQPpalP.mjs +1 -0
  91. package/package.json +39 -19
  92. package/skills/README.md +36 -17
  93. package/skills/lunora/SKILL.md +158 -65
  94. package/skills/lunora-cloudflare-audit/SKILL.md +163 -0
  95. package/skills/lunora-create-package/SKILL.md +97 -84
  96. package/skills/lunora-deploy/SKILL.md +175 -105
  97. package/skills/lunora-functions/SKILL.md +363 -56
  98. package/skills/lunora-migration-helper/SKILL.md +162 -137
  99. package/skills/lunora-performance-audit/SKILL.md +137 -95
  100. package/skills/lunora-quickstart/SKILL.md +137 -136
  101. package/skills/lunora-realtime/SKILL.md +217 -97
  102. package/skills/lunora-setup-auth/SKILL.md +162 -102
  103. package/skills/lunora-setup-hyperdrive/SKILL.md +130 -109
  104. package/skills/lunora-setup-hyperdrive-global/SKILL.md +93 -90
  105. package/skills/lunora-setup-mail/SKILL.md +85 -113
  106. package/skills/lunora-setup-scheduler/SKILL.md +132 -96
  107. package/skills/lunora-setup-storage/SKILL.md +189 -95
  108. package/dist/packem_chunks/planDevCommand.mjs +0 -541
  109. package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
  110. package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
  111. package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
  112. package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
  113. package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
  114. package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
  115. package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
  116. package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
  117. package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
  118. package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
  119. package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
  120. package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
  121. package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
  122. package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
  123. package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
  124. package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
  125. package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
  126. package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
  127. package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
  128. package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
  129. package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
  130. package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
  131. package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
  132. package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
  133. package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
  134. package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
  135. package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
  136. 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,301 @@ 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.live` (action-side only; see `lunora-realtime`) | `@lunora/server` |
289
+ | `ctx.sql` (action-only) | `@lunora/hyperdrive` |
290
+ | `ctx.kv` / `ctx.images` / `ctx.analytics` | `@lunora/bindings` subpaths |
291
+ | `ctx.pipelines` / `ctx.vectors` / `ctx.r2sql` | `@lunora/bindings` subpaths |
292
+
293
+ One exception to the usage scan, and one extra requirement:
294
+
295
+ - **`ctx.flags` gates on a declaration file**, not on usage — codegen wires it
296
+ only when `lunora/flags.ts` exists. `ctx.notify` / `ctx.push` work the same
297
+ way via `lunora/notify.ts`.
298
+ - **`ctx.sql` also needs the real resource.** Codegen types the field, but the
299
+ connection needs a `HYPERDRIVE` binding (`wrangler hyperdrive create`) and an
300
+ explicit `createHyperdrive(ctx.env.HYPERDRIVE)` + driver adapter in the
301
+ action — see `lunora-setup-hyperdrive`. Bindings codegen can provision on its
302
+ own (e.g. `BROWSER` for `ctx.browser`) need no manual wrangler step.
303
+
304
+ `ctx.browser` and `ctx.sql` are **action-only** by design. They are external,
305
+ non-deterministic I/O: a query is re-run on every subscription re-evaluation, so
306
+ a non-deterministic read makes reactivity wrong, and a mutation's writes are
307
+ transactional — a rollback cannot un-send a network call.
308
+
309
+ ### Topics: one event, many consumers
310
+
311
+ A queue has one consumer. To fan one event out to several independent
312
+ consumers, declare a topic and subscribe to it — in `lunora/queues.ts`, next to
313
+ your queues. Each subscription is its own queue with its own retries and
314
+ dead-letter queue.
315
+
316
+ ```ts
317
+ // lunora/queues.ts
318
+ import { defineSubscription, defineTopic } from "@lunora/queue";
319
+
320
+ import { internal } from "./_generated/internal.js";
321
+
322
+ export const signups = defineTopic<{ userId: string }>();
323
+
324
+ export const welcomeEmail = defineSubscription(signups, {
325
+ handler: async (_ctx, batch) => {
326
+ for (const message of batch.messages) {
327
+ await message.run(internal.email.welcome, { userId: message.body.userId });
328
+ message.ack();
329
+ }
330
+ },
331
+ maxRetries: 5,
332
+ deadLetterQueue: "welcome-email-dlq",
333
+ });
334
+
335
+ // in a mutation or action
336
+ await ctx.topics.signups.publish({ userId });
337
+ ```
338
+
339
+ - Delivery is **at-least-once per subscription** and unordered — make handlers
340
+ idempotent. A failed send rejects `publish`, and a retry re-delivers to the
341
+ subscriptions that already got it.
342
+ - A subscription is published to only through its topic, never `ctx.queues`.
343
+ The topic passed to `defineSubscription` must be a `defineTopic` export of
344
+ `lunora/queues.ts` or a module's `queues.ts` (import it from there).
345
+ - Rate-limit public procedures that publish: one publish is one send per
346
+ subscription (`privileged_fanout_from_public_procedure` flags it).
347
+
348
+ ## Modules: grouping `lunora/` folders
349
+
350
+ A folder whose `module.ts` default-exports `defineModule(...)` is a module.
351
+ Modules are metadata — no `api.*` path or runtime change, still one Worker —
352
+ that drive the Studio **Architecture** view, OpenAPI tags, and table ownership:
353
+
354
+ ```ts
355
+ // lunora/billing/module.ts
356
+ import { defineModule } from "lunorash/server";
357
+
358
+ export default defineModule({ description: "Invoices and payments", tables: ["invoices"] });
359
+ ```
360
+
361
+ - Write `description` and `tables` inline; codegen reads them without running
362
+ the file. Modules do not nest.
363
+ - A module can declare its own queues, topics and subscriptions in
364
+ `lunora/<module>/queues.ts`. Export names stay app-wide (`ctx.queues.<name>`),
365
+ so two queues files exporting the same name is a codegen error.
366
+ - `cross_module_table_write` warns when a function outside the owning module
367
+ writes an owned table (insert, `patch`/`replace`/`delete`, batch or facade),
368
+ including through a same-file helper. Move the write into the owning module:
369
+ an exported helper there taking the caller's `ctx` (`openInvoice(ctx, …)`), or
370
+ a registered owner mutation via `ctx.runMutation(internal.<owner>.<fn>, …)`.
371
+ - Installed components (`.extend(...)` schema extensions) count as modules that
372
+ own their prefixed tables: write them through the component's functions, not
373
+ `ctx.db` directly.
374
+ - Keep ids typed `Id<"table">` — a write through an untyped `string` id cannot
375
+ be attributed to a table.
376
+
377
+ ## Services: calling sibling Workers
378
+
379
+ A Worker the app calls (a parser, an LLM gateway) is declared in
380
+ `lunora.config.ts`, not discovered. Each one becomes a typed `ctx.services.<key>`
381
+ on **actions** (not queries/mutations: a cross-Worker call cannot be replayed or
382
+ rolled back; HTTP actions reach it through `ctx.runAction`):
383
+
384
+ ```ts
385
+ // lunora.config.ts
386
+ export default {
387
+ services: {
388
+ documentParser: { dir: "services/document-parser" }, // fetch service
389
+ llmGateway: { dir: "services/llm-gateway", entrypoint: "Gateway" }, // RPC
390
+ },
391
+ };
392
+
393
+ // in an action
394
+ const parsed = await ctx.services.documentParser.fetch("https://parser/parse", { body, method: "POST" });
395
+ const text = await ctx.services.llmGateway.complete(prompt); // typed from the Gateway class
396
+ ```
397
+
398
+ - The Worker name and entry come from `<dir>/wrangler.jsonc`; Lunora writes the
399
+ `services[]` binding (`SERVICE_<KEY>`), runs it in the same `lunora dev` /
400
+ `vite dev` session, and deploys it before the app.
401
+ - A bound service needs no URL, no `*_URL` var and no HMAC: set
402
+ `"workers_dev": false` on it when it has no public route (`lunora doctor` warns
403
+ otherwise). Keep its HMAC check if it has a custom domain or route.
156
404
 
157
405
  ## HTTP endpoints
158
406
 
159
407
  For webhooks or non-RPC HTTP, use `httpRouter` / `httpRoute` + `httpAction`:
160
408
 
409
+ `httpRouter()` takes **no arguments** — it returns a [Hono](https://hono.dev)
410
+ app you mount routes on, and you export the app. Passing it a routes object is a
411
+ type error (`Expected 0 arguments`), and from untyped code the routes simply
412
+ never mount.
413
+
161
414
  ```ts
162
- import { httpAction, httpRouter } from "@lunora/server";
415
+ // lunora/http.ts
416
+ import { httpRouter } from "lunorash/server";
163
417
 
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
- }),
418
+ import { internal } from "./_generated/internal.js";
419
+
420
+ const app = httpRouter();
421
+ const encoder = new TextEncoder();
422
+
423
+ // Stripe's scheme: `stripe-signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
424
+ // The Stripe SDK's `stripe.webhooks.constructEventAsync` does the same check.
425
+ const verifyStripe = async (secret: string, header: string | undefined, payload: string): Promise<boolean> => {
426
+ const fields = header?.split(",").map((part) => part.split("=", 2)) ?? [];
427
+ const t = fields.find(([k]) => k === "t")?.[1];
428
+ // While a secret is being rolled Stripe sends one `v1` per secret; accept any.
429
+ const v1s = fields.filter(([k, value]) => k === "v1" && /^[\da-f]{64}$/.test(value ?? "")).map(([, value]) => value ?? "");
430
+
431
+ if (!t || v1s.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
432
+ return false; // missing, malformed, or outside the 5-minute replay window
433
+ }
434
+
435
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
436
+ const signed = encoder.encode(`${t}.${payload}`);
437
+
438
+ for (const v1 of v1s) {
439
+ const signature = Uint8Array.from(v1.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16));
440
+
441
+ if (await crypto.subtle.verify("HMAC", key, signature, signed)) {
442
+ return true; // constant-time compare
443
+ }
444
+ }
445
+
446
+ return false;
447
+ };
448
+
449
+ // A plain Hono handler, not `httpAction`: the action ctx (`c.var.lunora`) has
450
+ // no env, while `c.env` is the Worker env.
451
+ app.post("/webhooks/stripe", async (c) => {
452
+ const secret = c.env.STRIPE_WEBHOOK_SECRET;
453
+
454
+ if (typeof secret !== "string") {
455
+ throw new Error("STRIPE_WEBHOOK_SECRET is not set");
456
+ }
457
+
458
+ const payload = await c.req.text(); // the raw bytes the signature covers
459
+
460
+ if (!(await verifyStripe(secret, c.req.header("stripe-signature"), payload))) {
461
+ return c.text("invalid signature", 401);
462
+ }
463
+
464
+ await c.var.lunora.runMutation(internal.billing.record, { event: JSON.parse(payload) as unknown });
465
+
466
+ return c.text("ok");
170
467
  });
468
+
469
+ export default app;
171
470
  ```
172
471
 
472
+ A webhook route is public: verify the provider's signature over the raw body
473
+ before any write, or anyone can forge events into `internal.*` functions.
474
+
173
475
  ## Checklist
174
476
 
175
477
  - [ ] Schema columns are `v.*` validators; `_id`/`_creationTime` not declared.
176
478
  - [ ] An index exists for every access pattern; queries use `withIndex`, not
177
- `.filter`.
479
+ `.filter`, and are bounded (`.take(n)` / `.paginate`).
480
+ - [ ] Client-supplied strings and arrays carry a `.max(...)`.
178
481
  - [ ] Right function kind: `query` (reactive read) / `mutation` (write) /
179
482
  `action` (side effects via `runQuery`/`runMutation`).
180
483
  - [ ] 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.
484
+ - [ ] Writes that must land together sit in one mutation; ids typed `Id<"table">`.
485
+ - [ ] Any `ctx.db.related` walk is narrowed with `edges` / `direction`, and its
486
+ foreign keys are indexed.
487
+ - [ ] Writes to a module- or component-owned table go through the owner's
488
+ functions (`cross_module_table_write` is clean).
489
+ - [ ] `lunora codegen` shows no new advisor findings; `lunora verify` is clean.