@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
Markdown
---
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.