UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

490 lines (399 loc) • 23.1 kB
--- name: lunora-functions description: Authoring rules for Lunora schema and server functions under `lunora/` — `defineSchema`/`defineTable`, `v.*` validators, the generated `query`/`mutation`/`action` builders (and `internal*`), indexes and `withIndex`, the `ctx.db` API, pagination, `ctx.db.related`, opt-in `ctx.*` capabilities, topics, modules, services, and `httpAction`. Use when writing or reviewing files in `lunora/` (`schema.ts`, function files, `http.ts`, `queues.ts`), when asked to "add a query/mutation/action", "add an index", "call one function from another", or "add a webhook endpoint", or when `lunora codegen` reports an error in a function. --- # Lunora Functions The core authoring rules for Lunora backend code. The verify loop after every edit: 1. `lunora codegen` — regenerates `lunora/_generated/` and prints advisor findings (it does not run `tsc`). 2. `lunora verify` — codegen dry-run plus `tsc --noEmit`, no files written. The type-check needs a `tsconfig.json` (without one it warns and skips) and is off under `--no-typecheck`. ## When to Use - Writing or editing schema, queries, mutations, or actions. - Reviewing `lunora/` code for correctness and idiom. - Deciding query vs mutation vs action, or public vs internal. ## When Not to Use - Setting up a new project (`lunora-quickstart`) or auth (`lunora-setup-auth`). - Diagnosing a slow query or write conflict (`lunora-performance-audit`). - Changing an existing schema with data at rest (`lunora-migration-helper`). ## Schema: `defineSchema` + `defineTable` `lunora/schema.ts` exports `defineSchema` as the default export. Every column is a `v.*` validator. Declare an index for every access pattern you query by. ```ts import { defineSchema, defineTable, v } from "lunorash/server"; export default defineSchema({ messages: defineTable({ channelId: v.id("channels"), authorId: v.id("users"), body: v.string(), createdAt: v.number(), }).index("by_channel", ["channelId", "createdAt"]), channels: defineTable({ name: v.string(), }), }); ``` - Lunora injects `_id` and `_creationTime` on every row, so don't declare them. - `lunorash/*` is the umbrella package templates install; `@lunora/server` is the same module if the project depends on it directly. - `.index("name", ["a", "b"])` — columns are ordered; put equality columns first, then the range/sort column. - `.shardBy("ownerId")` partitions the table across Durable Objects by key; `.global()` replicates it to D1 for cross-region reads. Default (neither) is a single root-scoped ShardDO. They are not combined on one table — for choosing between them, see the side-by-side comparison in the `lunora-performance-audit` skill. ### Validators (`v.*`) `string`, `number`, `boolean`, `id("table")`, `null`, `any`, `bigint`, `bytes`, `literal(value)`, `array(item)`, `object({...})`, `record(key, value)`, `union(a, b, …)`, `optional(inner)`, `partial({...})`, `geoPoint()`, the convenience types `date`, `timestamp`, and `storage` (an R2 object key), and `v.from(schema)` to adopt any Standard Schema v1 validator (Zod, Valibot, …). - Required is the default. `v.optional(x)` means the field may be absent; `x.nullable()` means it may be `null`. They are different. - Refinements chain on the validator and are enforced at the trust boundary: `v.string().min(1).max(4096)`, `.email()`, `.url()`, `.pattern(re)`; `v.number().int().min(0)`, `.positive()`; `v.array(x).max(n)`. Bound every client-supplied string and array — templates cap text at `.max(4096)`. ## Functions: query / mutation / action Each function declares its inputs with `.input(...)` (a `v.*` map), optionally adds `.use(middleware)` (rate limiting, RLS, masking) and `.output(validator)`, and ends with a terminal `.query` / `.mutation` / `.action` taking a `({ ctx, args }) => …` handler. Export them as named consts from `lunora/*.ts`. Codegen nests references by path: `lunora/messages.ts` → `api.messages.<fn>`, `lunora/billing/invoices.ts` → `api.billing.invoices.<fn>`. Public functions land in `_generated/api.ts`, `internal*` ones in `_generated/internal.ts`. | Kind | Reads `ctx.db` | Writes `ctx.db` | Side effects / `fetch` | Reactive | | ---------- | -------------- | ------------------- | ---------------------- | -------- | | `query` | yes | no | no | yes | | `mutation` | yes | yes, transactional | no | — | | `action` | yes | yes, autocommitting | yes | — | Import the builders from `_generated/server`, not from `lunorash/server`: codegen binds them to your schema, which is what types `ctx.db`, `v.id("channels")`, and index names. `lunorash/server` exports the schema/HTTP/validator surface and `LunoraError`, never `query` / `mutation` / `action`. ```ts // lunora/messages.ts import { LunoraError } from "lunorash/server"; import { api } from "./_generated/api.js"; import type { Doc, Id } from "./_generated/server.js"; import { action, mutation, query, v } from "./_generated/server.js"; export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }): Promise<Doc<"messages">[]> => ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", channelId)) .take(100), ); export const send = mutation .input({ channelId: v.id("channels"), body: v.string() }) .mutation(async ({ ctx, args: { channelId, body } }): Promise<Id<"messages">> => { if (!ctx.auth.userId) { throw new LunoraError("UNAUTHORIZED", "not signed in"); } return ctx.db.insert("messages", { channelId, authorId: ctx.auth.userId as Id<"users">, body, createdAt: Date.now(), }); }); export const notifySlack = action.input({ channelId: v.id("channels") }).action(async ({ ctx, args: { channelId } }) => { // A public action is callable by anyone: gate it like a mutation (and check // channel access too, if your app has per-channel membership). if (!ctx.auth.userId) { throw new LunoraError("UNAUTHORIZED", "not signed in"); } const messages = await ctx.runQuery(api.messages.listByChannel, { channelId }); const response = await fetch(String(ctx.env?.SLACK_WEBHOOK_URL), { method: "POST", body: JSON.stringify(messages) }); if (!response.ok) { throw new Error(`Slack webhook failed: ${String(response.status)}`); // fetch resolves on 4xx/5xx } }); ``` Templates also map `#lunora/*` to `./lunora/*` in `package.json` `imports`, so `#lunora/_generated/server.js` works from anywhere in the project. - **Pick the right kind.** Reactive read → `query`. Transactional write → `mutation`. External I/O (`fetch`, third-party SDKs, calling other functions) → `action`. An action has a `ctx.db`, but its own writes are not transactional: each autocommits as it runs, so a later throw rolls nothing back. Reach data through `ctx.runQuery` / `ctx.runMutation` — a mutation called that way runs in the same all-or-nothing transaction a top-level one gets, so put every write that has to land together in one mutation rather than sequencing several from the action. - **`internal*` variants** (`internalQuery`, `internalMutation`, `internalAction`) are not exposed to clients — use them for server-only logic called from actions, crons, or other functions. - **Throw `LunoraError(code, message?, options?)`** from `lunorash/server` for expected failures (`"UNAUTHORIZED"`, `"NOT_FOUND"`, …); it serializes with its code to the client, where `isUnauthorizedError` and friends branch on it. ## The `ctx.db` API Reads: ```ts await ctx.db.get("table", id); // one row by id (or null) ctx.db.query("t").withIndex("by_x", (q) => q.eq("x", v)); // indexed query .order("asc" | "desc") // by the active index (or _creationTime) .filter((doc) => …) // JS predicate, applied after the index range .collect(); // all matching rows .take(n); // first n rows .first(); // first row or null .unique(); // the one match, null if none; throws if more than one .paginate({ numItems, cursor }); // { page, isDone, continueCursor } ``` Writes (mutations, and autocommitting in actions): ```ts await ctx.db.insert("t", { ...fields }); // returns the new Id await ctx.db.patch(table, id, { field: next }); // shallow-merge update await ctx.db.replace(table, id, { ...allFields }); // full overwrite await ctx.db.delete(table, id); ``` Prefer `withIndex` over `.filter`: a `.filter(...)` without a covering index scans the whole table (advisor: `filter_without_index`), and a bare `.collect()` grows with the table and with every subscriber's payload (`unbounded_collect`). Constrain with `.withIndex`, cap with `.take(n)`, or paginate. ### Pagination A paginated query takes a `paginationOpts` arg and returns `.paginate(...)` as-is; the client hook (`usePaginatedQuery`, see `lunora-realtime`) supplies it: ```ts // `v.any()` would type the arg `unknown`, which `.paginate()` rejects. The hook // also sends `endCursor`; an undeclared key is stripped, so declare it. Bound // `numItems` too: it is caller input and sets the read limit. const cursor = v.optional(v.union(v.string(), v.null())); export const page = query .input({ channelId: v.id("channels"), paginationOpts: v.object({ cursor, endCursor: cursor, numItems: v.number().int().min(1).max(100) }) }) .query(async ({ ctx, args: { channelId, paginationOpts } }) => ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", channelId)) .order("desc") .paginate(paginationOpts), ); ``` ### Following foreign keys: `ctx.db.related` Every `v.id("table")` column is an edge in a graph your schema already declares. `ctx.db.related` walks it, so "this customer's tickets, and those tickets' messages" is one call instead of a hand-written chain of `withIndex` lookups. ```ts const { continueCursor, isDone, nodes } = await ctx.db.related( { table: "customers", id: customerId }, // or a row you already loaded { depth: 2, direction: "in", edges: ["tickets.customerId", "messages.ticketId"], limit: 50 }, ); for (const node of nodes) { node.table; // "messages" node.document; // the row itself node.depth; // 2 node.score; // 0.5 — 1 at depth 1, halving per hop node.path; // ["tickets.customerId", "messages.ticketId"] node.pathIds; // ids along the way, start included } ``` - **Edge names are `"<table>.<column>"`.** `edges` restricts the walk to the named ones; a name the schema does not declare is refused, not ignored. - **`direction`** — `"out"` follows the ids this row holds, `"in"` the rows that point at it, `"both"` (the default) does both. - **`depth`** defaults to `1`, max `4`. **`limit`** defaults to `50`, max `200`. Both caps refuse rather than clamp, so a probe for the ceiling just errors. - **Only a column is an edge**: a bare `v.id(...)`, `v.optional(v.id(...))` or `v.array(v.id(...))`. An id nested in a `v.object` / `v.union` / `v.record` is not. An array FK is followed outward only. - **It is an ordinary read** — RLS, column masks, soft delete, `.global()` routing and reactivity all apply, because every hop goes back through `ctx.db`. Under a `.rls("required")` schema each hop gets exactly the verdict a direct read of that table would, so declare a read policy for every table the walk can reach — or narrow it with `edges`. - Index the foreign keys. Each inward hop is a `WHERE fk IN (…)` read, and unindexed it scans — see the `lunora-performance-audit` skill for the cost model and the traversal caps. ## Other `ctx` capabilities Always available: - `ctx.auth` — the resolved session (`ctx.auth.userId`, `null` when anonymous). - `ctx.secrets` — Cloudflare Secrets Store. - `ctx.ip` — the caller's `CF-Connecting-IP`; `undefined` off Cloudflare. - `ctx.runQuery` (all kinds), `ctx.runMutation` (mutations, actions), `ctx.runAction` (actions) — call another function by its `api.*` / `internal.*` reference. - `ctx.log`, `ctx.metrics`, `ctx.span` / `ctx.trace` — logging, metrics, the current wide-event span and a scoped tracing helper. Wired by codegen on use. A dependency in `package.json` is not enough: codegen scans the `lunora/` source set and turns a capability on only when a file there imports its `@lunora/*` package or reads its `ctx.*` helper. Write the call first, then run `lunora codegen` to wire the binding and typed context: | `ctx.*` | Package | | ------------------------------------------------------------------- | ----------------------------- | | `ctx.scheduler` (mutations + actions; see `lunora-setup-scheduler`) | `@lunora/scheduler` | | `ctx.storage` (R2; see `lunora-setup-storage`) | `@lunora/storage` | | `ctx.ai` | `@lunora/ai` (Workers AI) | | `ctx.flags` | `@lunora/flags` (OpenFeature) | | `ctx.queues.<name>` | `@lunora/queue` | | `ctx.topics.<name>` (pub/sub, see below) | `@lunora/queue` | | `ctx.workflows` / `ctx.runStep` | `@lunora/workflow` | | `ctx.containers` | `@lunora/container` | | `ctx.browser` (action-only) | `@lunora/browser` | | `ctx.live` (action-side only; see `lunora-realtime`) | `@lunora/server` | | `ctx.sql` (action-only) | `@lunora/hyperdrive` | | `ctx.kv` / `ctx.images` / `ctx.analytics` | `@lunora/bindings` subpaths | | `ctx.pipelines` / `ctx.vectors` / `ctx.r2sql` | `@lunora/bindings` subpaths | One exception to the usage scan, and one extra requirement: - **`ctx.flags` gates on a declaration file**, not on usage — codegen wires it only when `lunora/flags.ts` exists. `ctx.notify` / `ctx.push` work the same way via `lunora/notify.ts`. - **`ctx.sql` also needs the real resource.** Codegen types the field, but the connection needs a `HYPERDRIVE` binding (`wrangler hyperdrive create`) and an explicit `createHyperdrive(ctx.env.HYPERDRIVE)` + driver adapter in the action — see `lunora-setup-hyperdrive`. Bindings codegen can provision on its own (e.g. `BROWSER` for `ctx.browser`) need no manual wrangler step. `ctx.browser` and `ctx.sql` are **action-only** by design. They are external, non-deterministic I/O: a query is re-run on every subscription re-evaluation, so a non-deterministic read makes reactivity wrong, and a mutation's writes are transactional — a rollback cannot un-send a network call. ### Topics: one event, many consumers A queue has one consumer. To fan one event out to several independent consumers, declare a topic and subscribe to it — in `lunora/queues.ts`, next to your queues. Each subscription is its own queue with its own retries and dead-letter queue. ```ts // lunora/queues.ts import { defineSubscription, defineTopic } from "@lunora/queue"; import { internal } from "./_generated/internal.js"; export const signups = defineTopic<{ userId: string }>(); export const welcomeEmail = defineSubscription(signups, { handler: async (_ctx, batch) => { for (const message of batch.messages) { await message.run(internal.email.welcome, { userId: message.body.userId }); message.ack(); } }, maxRetries: 5, deadLetterQueue: "welcome-email-dlq", }); // in a mutation or action await ctx.topics.signups.publish({ userId }); ``` - Delivery is **at-least-once per subscription** and unordered — make handlers idempotent. A failed send rejects `publish`, and a retry re-delivers to the subscriptions that already got it. - A subscription is published to only through its topic, never `ctx.queues`. The topic passed to `defineSubscription` must be a `defineTopic` export of `lunora/queues.ts` or a module's `queues.ts` (import it from there). - Rate-limit public procedures that publish: one publish is one send per subscription (`privileged_fanout_from_public_procedure` flags it). ## Modules: grouping `lunora/` folders A folder whose `module.ts` default-exports `defineModule(...)` is a module. Modules are metadata — no `api.*` path or runtime change, still one Worker — that drive the Studio **Architecture** view, OpenAPI tags, and table ownership: ```ts // lunora/billing/module.ts import { defineModule } from "lunorash/server"; export default defineModule({ description: "Invoices and payments", tables: ["invoices"] }); ``` - Write `description` and `tables` inline; codegen reads them without running the file. Modules do not nest. - A module can declare its own queues, topics and subscriptions in `lunora/<module>/queues.ts`. Export names stay app-wide (`ctx.queues.<name>`), so two queues files exporting the same name is a codegen error. - `cross_module_table_write` warns when a function outside the owning module writes an owned table (insert, `patch`/`replace`/`delete`, batch or facade), including through a same-file helper. Move the write into the owning module: an exported helper there taking the caller's `ctx` (`openInvoice(ctx, …)`), or a registered owner mutation via `ctx.runMutation(internal.<owner>.<fn>, …)`. - Installed components (`.extend(...)` schema extensions) count as modules that own their prefixed tables: write them through the component's functions, not `ctx.db` directly. - Keep ids typed `Id<"table">` — a write through an untyped `string` id cannot be attributed to a table. ## Services: calling sibling Workers A Worker the app calls (a parser, an LLM gateway) is declared in `lunora.config.ts`, not discovered. Each one becomes a typed `ctx.services.<key>` on **actions** (not queries/mutations: a cross-Worker call cannot be replayed or rolled back; HTTP actions reach it through `ctx.runAction`): ```ts // lunora.config.ts export default { services: { documentParser: { dir: "services/document-parser" }, // fetch service llmGateway: { dir: "services/llm-gateway", entrypoint: "Gateway" }, // RPC }, }; // in an action const parsed = await ctx.services.documentParser.fetch("https://parser/parse", { body, method: "POST" }); const text = await ctx.services.llmGateway.complete(prompt); // typed from the Gateway class ``` - The Worker name and entry come from `<dir>/wrangler.jsonc`; Lunora writes the `services[]` binding (`SERVICE_<KEY>`), runs it in the same `lunora dev` / `vite dev` session, and deploys it before the app. - A bound service needs no URL, no `*_URL` var and no HMAC: set `"workers_dev": false` on it when it has no public route (`lunora doctor` warns otherwise). Keep its HMAC check if it has a custom domain or route. ## HTTP endpoints For webhooks or non-RPC HTTP, use `httpRouter` / `httpRoute` + `httpAction`: `httpRouter()` takes **no arguments** — it returns a [Hono](https://hono.dev) app you mount routes on, and you export the app. Passing it a routes object is a type error (`Expected 0 arguments`), and from untyped code the routes simply never mount. ```ts // lunora/http.ts import { httpRouter } from "lunorash/server"; import { internal } from "./_generated/internal.js"; const app = httpRouter(); const encoder = new TextEncoder(); // Stripe's scheme: `stripe-signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`. // The Stripe SDK's `stripe.webhooks.constructEventAsync` does the same check. const verifyStripe = async (secret: string, header: string | undefined, payload: string): Promise<boolean> => { const fields = header?.split(",").map((part) => part.split("=", 2)) ?? []; const t = fields.find(([k]) => k === "t")?.[1]; // While a secret is being rolled Stripe sends one `v1` per secret; accept any. const v1s = fields.filter(([k, value]) => k === "v1" && /^[\da-f]{64}$/.test(value ?? "")).map(([, value]) => value ?? ""); if (!t || v1s.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) { return false; // missing, malformed, or outside the 5-minute replay window } const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); const signed = encoder.encode(`${t}.${payload}`); for (const v1 of v1s) { const signature = Uint8Array.from(v1.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16)); if (await crypto.subtle.verify("HMAC", key, signature, signed)) { return true; // constant-time compare } } return false; }; // A plain Hono handler, not `httpAction`: the action ctx (`c.var.lunora`) has // no env, while `c.env` is the Worker env. app.post("/webhooks/stripe", async (c) => { const secret = c.env.STRIPE_WEBHOOK_SECRET; if (typeof secret !== "string") { throw new Error("STRIPE_WEBHOOK_SECRET is not set"); } const payload = await c.req.text(); // the raw bytes the signature covers if (!(await verifyStripe(secret, c.req.header("stripe-signature"), payload))) { return c.text("invalid signature", 401); } await c.var.lunora.runMutation(internal.billing.record, { event: JSON.parse(payload) as unknown }); return c.text("ok"); }); export default app; ``` A webhook route is public: verify the provider's signature over the raw body before any write, or anyone can forge events into `internal.*` functions. ## Checklist - [ ] Schema columns are `v.*` validators; `_id`/`_creationTime` not declared. - [ ] An index exists for every access pattern; queries use `withIndex`, not `.filter`, and are bounded (`.take(n)` / `.paginate`). - [ ] Client-supplied strings and arrays carry a `.max(...)`. - [ ] Right function kind: `query` (reactive read) / `mutation` (write) / `action` (side effects via `runQuery`/`runMutation`). - [ ] Server-only logic uses `internal*`; expected failures throw `LunoraError`. - [ ] Writes that must land together sit in one mutation; ids typed `Id<"table">`. - [ ] Any `ctx.db.related` walk is narrowed with `edges` / `direction`, and its foreign keys are indexed. - [ ] Writes to a module- or component-owned table go through the owner's functions (`cross_module_table_write` is clean). - [ ] `lunora codegen` shows no new advisor findings; `lunora verify` is clean.