@lunora/cli 1.0.0-alpha.361 → 1.0.0-alpha.363

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.
@@ -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,15 +71,26 @@ 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, …).
77
+
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)`.
66
84
 
67
85
  ## Functions: query / mutation / action
68
86
 
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>`.
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`.
72
94
 
73
95
  | Kind | Reads `ctx.db` | Writes `ctx.db` | Side effects / `fetch` | Reactive |
74
96
  | ---------- | -------------- | ------------------- | ---------------------- | -------- |
@@ -76,26 +98,24 @@ consts from `lunora/*.ts`; codegen surfaces them as `api.<file>.<name>`.
76
98
  | `mutation` | yes | yes, transactional | no | — |
77
99
  | `action` | yes | yes, autocommitting | yes | — |
78
100
 
79
- The builders are **generated**, not imported from `@lunora/server`: codegen binds
80
- them to your schema, so `ctx.db`, `v.id("channels")`, and index names are all
81
- typed. `@lunora/server` exports the schema/HTTP/validator surface and
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
82
104
  `LunoraError`, never `query` / `mutation` / `action`.
83
105
 
84
106
  ```ts
107
+ // lunora/messages.ts
85
108
  import { LunoraError } from "lunorash/server";
86
109
 
87
- import type { Id } from "#lunora/_generated/server.js";
88
- import { action, mutation, query, v } from "#lunora/_generated/server.js";
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";
89
113
 
90
- // `api` / `internal` come from codegen:
91
- // import { api } from "./_generated/api";
92
- // import { internal } from "./_generated/internal";
93
-
94
- 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">[]> =>
95
115
  ctx.db
96
116
  .query("messages")
97
117
  .withIndex("by_channel", (q) => q.eq("channelId", channelId))
98
- .collect(),
118
+ .take(100),
99
119
  );
100
120
 
101
121
  export const send = mutation
@@ -112,25 +132,37 @@ export const send = mutation
112
132
  });
113
133
  });
114
134
 
115
- export const notifySlack = action.input({ messageId: v.id("messages") }).action(async ({ ctx, args: { messageId } }) => {
116
- const message = await ctx.runQuery(api.messages.getById, { messageId });
117
- 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
+ }
118
146
  });
119
147
  ```
120
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
+
121
152
  - **Pick the right kind.** Reactive read → `query`. Transactional write →
122
153
  `mutation`. External I/O (`fetch`, third-party SDKs, calling other functions)
123
154
  → `action`. An action has a `ctx.db`, but its own writes are not
124
155
  transactional: each autocommits as it runs, so a later throw rolls nothing
125
156
  back. Reach data through `ctx.runQuery` / `ctx.runMutation` — a mutation
126
157
  called that way runs in the same all-or-nothing transaction a top-level one
127
- gets, so put every write that has to land together in ONE mutation rather
158
+ gets, so put every write that has to land together in one mutation rather
128
159
  than sequencing several from the action.
129
160
  - **`internal*` variants** (`internalQuery`, `internalMutation`,
130
161
  `internalAction`) are not exposed to clients — use them for server-only logic
131
162
  called from actions, crons, or other functions.
132
- - **Throw `LunoraError`** (`import { LunoraError } from "@lunora/server"`) with a
133
- 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.
134
166
 
135
167
  ## The `ctx.db` API
136
168
 
@@ -139,15 +171,16 @@ Reads:
139
171
  ```ts
140
172
  await ctx.db.get(id); // one row by id (or null)
141
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
142
176
  .collect(); // all matching rows
143
- .first(); // first row or null
144
- .unique(); // exactly one (throws if 0 or >1)
145
177
  .take(n); // first n rows
146
- .order("asc" | "desc") // sort by the index range
147
- .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 }
148
181
  ```
149
182
 
150
- Writes (mutations only):
183
+ Writes (mutations, and autocommitting in actions):
151
184
 
152
185
  ```ts
153
186
  await ctx.db.insert("t", { ...fields }); // returns the new Id
@@ -156,9 +189,32 @@ await ctx.db.replace(id, { ...allFields }); // full overwrite
156
189
  await ctx.db.delete(id);
157
190
  ```
158
191
 
159
- **Prefer `withIndex` over `.filter`.** A `.filter(...)` with no covering index
160
- scans the whole table — `@lunora/advisor` flags it as `filter-without-index`.
161
- 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
+ ```
162
218
 
163
219
  ### Following foreign keys: `ctx.db.related`
164
220
 
@@ -183,14 +239,14 @@ for (const node of nodes) {
183
239
  ```
184
240
 
185
241
  - **Edge names are `"<table>.<column>"`.** `edges` restricts the walk to the
186
- named ones; a name the schema does not declare is **refused**, not ignored.
242
+ named ones; a name the schema does not declare is refused, not ignored.
187
243
  - **`direction`** — `"out"` follows the ids this row holds, `"in"` the rows that
188
244
  point at it, `"both"` (the default) does both.
189
245
  - **`depth`** defaults to `1`, max `4`. **`limit`** defaults to `50`, max `200`.
190
- Both caps **refuse rather than clamp**, so do not probe for the ceiling.
246
+ Both caps refuse rather than clamp, so a probe for the ceiling just errors.
191
247
  - **Only a column is an edge**: a bare `v.id(...)`, `v.optional(v.id(...))` or
192
248
  `v.array(v.id(...))`. An id nested in a `v.object` / `v.union` / `v.record` is
193
- not. An array FK is followed **outward only**.
249
+ not. An array FK is followed outward only.
194
250
  - **It is an ordinary read** — RLS, column masks, soft delete, `.global()`
195
251
  routing and reactivity all apply, because every hop goes back through
196
252
  `ctx.db`. Under a `.rls("required")` schema each hop gets exactly the verdict a
@@ -204,35 +260,40 @@ for (const node of nodes) {
204
260
 
205
261
  Always available:
206
262
 
207
- - `ctx.auth` — the resolved session (`ctx.auth.userId`).
208
- - `ctx.scheduler` — `runAfter` / `runAt` for deferred work.
263
+ - `ctx.auth` — the resolved session (`ctx.auth.userId`, `null` when anonymous).
209
264
  - `ctx.secrets` — Cloudflare Secrets Store.
210
- - `ctx.span` / `ctx.trace` — the current span and a scoped tracing helper for
211
- wide events.
212
-
213
- Added by their package when wired. **A dependency in `package.json` is not
214
- enough** — codegen scans the `lunora/` source set and flips a capability on only
215
- when a file there imports the `@lunora/*` package or reads its `ctx.*` helper.
216
- So write the call first, then run `lunora codegen` to surface the typed context:
217
-
218
- | `ctx.*` | Package |
219
- | ----------------------------------------------------------------------------------------- | ----------------------------- |
220
- | `ctx.storage` | `@lunora/storage` (R2) |
221
- | `ctx.ai` | `@lunora/ai` (Workers AI) |
222
- | `ctx.flags` | `@lunora/flags` (OpenFeature) |
223
- | `ctx.queues.<name>` | `@lunora/queue` |
224
- | `ctx.topics.<name>` (pub/sub, see below) | `@lunora/queue` |
225
- | `ctx.workflows` / `ctx.runStep` | `@lunora/workflow` |
226
- | `ctx.containers` | `@lunora/container` |
227
- | `ctx.browser` (action-only) | `@lunora/browser` |
228
- | `ctx.sql` (action-only) | `@lunora/hyperdrive` |
229
- | `ctx.kv` / `ctx.images` / `ctx.analytics` / `ctx.pipelines` / `ctx.vectors` / `ctx.r2sql` | `@lunora/bindings` subpaths |
230
-
231
- Two exceptions to the usage scan, and one extra requirement:
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:
232
293
 
233
294
  - **`ctx.flags` gates on a declaration file**, not on usage — codegen wires it
234
- only when `lunora/flags.ts` exists (`vis generate lunora-flags` creates it).
235
- `ctx.notify` / `ctx.push` work the same way via `lunora/notify.ts`.
295
+ only when `lunora/flags.ts` exists. `ctx.notify` / `ctx.push` work the same
296
+ way via `lunora/notify.ts`.
236
297
  - **`ctx.sql` also needs the real resource.** Codegen types the field, but the
237
298
  connection needs a `HYPERDRIVE` binding (`wrangler hyperdrive create`) and an
238
299
  explicit `createHyperdrive(ctx.env.HYPERDRIVE)` + driver adapter in the
@@ -255,7 +316,7 @@ dead-letter queue.
255
316
  // lunora/queues.ts
256
317
  import { defineSubscription, defineTopic } from "@lunora/queue";
257
318
 
258
- import { internal } from "./_generated/internal";
319
+ import { internal } from "./_generated/internal.js";
259
320
 
260
321
  export const signups = defineTopic<{ userId: string }>();
261
322
 
@@ -291,7 +352,7 @@ that drive the Studio **Architecture** view, OpenAPI tags, and table ownership:
291
352
 
292
353
  ```ts
293
354
  // lunora/billing/module.ts
294
- import { defineModule } from "@lunora/server";
355
+ import { defineModule } from "lunorash/server";
295
356
 
296
357
  export default defineModule({ description: "Invoices and payments", tables: ["invoices"] });
297
358
  ```
@@ -350,37 +411,77 @@ never mount.
350
411
 
351
412
  ```ts
352
413
  // lunora/http.ts
353
- import { httpAction, httpRouter } from "@lunora/server";
414
+ import { httpRouter } from "lunorash/server";
354
415
 
355
- import { internal } from "./_generated/internal";
416
+ import { internal } from "./_generated/internal.js";
356
417
 
357
418
  const app = httpRouter();
419
+ const encoder = new TextEncoder();
358
420
 
359
- app.post(
360
- "/webhooks/stripe",
361
- httpAction(async (ctx, request) => {
362
- const event = await request.json();
421
+ // Stripe's scheme: `stripe-signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
422
+ // The Stripe SDK's `stripe.webhooks.constructEventAsync` does the same check.
423
+ const verifyStripe = async (secret: string, header: string | undefined, payload: string): Promise<boolean> => {
424
+ const fields = header?.split(",").map((part) => part.split("=", 2)) ?? [];
425
+ const t = fields.find(([k]) => k === "t")?.[1];
426
+ // While a secret is being rolled Stripe sends one `v1` per secret; accept any.
427
+ const v1s = fields.filter(([k, value]) => k === "v1" && /^[\da-f]{64}$/.test(value ?? "")).map(([, value]) => value ?? "");
363
428
 
364
- await ctx.runMutation(internal.billing.record, { event });
429
+ if (!t || v1s.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
430
+ return false; // missing, malformed, or outside the 5-minute replay window
431
+ }
365
432
 
366
- return new Response("ok");
367
- }),
368
- );
433
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
434
+ const signed = encoder.encode(`${t}.${payload}`);
435
+
436
+ for (const v1 of v1s) {
437
+ const signature = Uint8Array.from(v1.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16));
438
+
439
+ if (await crypto.subtle.verify("HMAC", key, signature, signed)) {
440
+ return true; // constant-time compare
441
+ }
442
+ }
443
+
444
+ return false;
445
+ };
446
+
447
+ // A plain Hono handler, not `httpAction`: the action ctx (`c.var.lunora`) has
448
+ // no env, while `c.env` is the Worker env.
449
+ app.post("/webhooks/stripe", async (c) => {
450
+ const secret = c.env.STRIPE_WEBHOOK_SECRET;
451
+
452
+ if (typeof secret !== "string") {
453
+ throw new Error("STRIPE_WEBHOOK_SECRET is not set");
454
+ }
455
+
456
+ const payload = await c.req.text(); // the raw bytes the signature covers
457
+
458
+ if (!(await verifyStripe(secret, c.req.header("stripe-signature"), payload))) {
459
+ return c.text("invalid signature", 401);
460
+ }
461
+
462
+ await c.var.lunora.runMutation(internal.billing.record, { event: JSON.parse(payload) as unknown });
463
+
464
+ return c.text("ok");
465
+ });
369
466
 
370
467
  export default app;
371
468
  ```
372
469
 
470
+ A webhook route is public: verify the provider's signature over the raw body
471
+ before any write, or anyone can forge events into `internal.*` functions.
472
+
373
473
  ## Checklist
374
474
 
375
475
  - [ ] Schema columns are `v.*` validators; `_id`/`_creationTime` not declared.
376
476
  - [ ] An index exists for every access pattern; queries use `withIndex`, not
377
- `.filter`.
477
+ `.filter`, and are bounded (`.take(n)` / `.paginate`).
478
+ - [ ] Client-supplied strings and arrays carry a `.max(...)`.
378
479
  - [ ] Right function kind: `query` (reactive read) / `mutation` (write) /
379
480
  `action` (side effects via `runQuery`/`runMutation`).
380
481
  - [ ] Server-only logic uses `internal*`; expected failures throw `LunoraError`.
381
- - [ ] `ctx.db` writes only inside mutations; ids typed with `Id<"table">`.
482
+ - [ ] Writes that must land together sit in one mutation; ids typed `Id<"table">`.
382
483
  - [ ] Any `ctx.db.related` walk is narrowed with `edges` / `direction`, and its
383
484
  foreign keys are indexed.
384
485
  - [ ] Writes to a module- or component-owned table go through the owner's
385
486
  functions (`cross_module_table_write` is clean).
386
- - [ ] Ran `lunora codegen`; typecheck is clean.
487
+ - [ ] `lunora codegen` shows no new advisor findings; `lunora verify` is clean.