| name | lunora-functions |
| description | Authoring rules for Lunora schema and functions. Use when writing or reviewing `lunora/` code — `defineSchema`/`defineTable`, `v.*` validators, query vs mutation vs action (and `internal*`), indexes & `withIndex`, the `ctx.db` API, pagination, scheduling, and `httpAction`. |
Lunora Functions
The core authoring rules for Lunora backend code. Read this before writing or
changing anything under lunora/. After every edit, run lunora codegen — it
regenerates lunora/_generated/ and typechecks your schema + functions.
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.
import { defineSchema, defineTable, v } from "@lunora/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 — do not declare
them.
.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), plus the convenience types date,
timestamp, and storage (an R2 object key). Use v.optional(...) for nullable
fields — required is the default.
Functions: query / mutation / action
Each function declares its inputs with .input(...) (a v.* map) and ends with a
terminal .query / .mutation / .action handler. Export them as named
consts from lunora/*.ts; codegen surfaces them as api.<file>.<name>.
| Kind | Reads ctx.db | Writes ctx.db | Side effects / fetch | Reactive |
|---|
query | yes | no | no | yes |
mutation | yes | yes | no | — |
action | no (use runQuery/runMutation) | no | yes | — |
import type { Id } from "@lunora/server";
import { action, LunoraError, mutation, query, v } from "@lunora/server";
export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }) =>
ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect(),
);
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({ messageId: v.id("messages") }).action(async ({ ctx, args: { messageId } }) => {
const message = await ctx.runQuery(api.messages.getById, { messageId });
await fetch(SLACK_WEBHOOK, { method: "POST", body: JSON.stringify(message) });
});
- Pick the right kind. Reactive read →
query. Transactional write →
mutation. External I/O (fetch, third-party SDKs, calling other functions)
→ action. An action has no ctx.db; it reaches data via ctx.runQuery /
ctx.runMutation.
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 (import { LunoraError } from "@lunora/server") with a
code + message for expected failures; it serializes cleanly to the client.
The ctx.db API
Reads:
await ctx.db.get(id);
ctx.db.query("t").withIndex("by_x", (q) => q.eq("x", v));
.collect();
.first();
.unique();
.take(n);
.order("asc" | "desc")
.paginate(opts);
Writes (mutations only):
await ctx.db.insert("t", { ...fields });
await ctx.db.patch(id, { field: next });
await ctx.db.replace(id, { ...allFields });
await ctx.db.delete(id);
Prefer withIndex over .filter. A .filter(...) with no covering index
scans the whole table — @lunora/advisor flags it as filter-without-index.
Declare the index and constrain with .withIndex.
Other ctx capabilities
ctx.auth (the resolved session: ctx.auth.userId), ctx.scheduler
(runAfter / runAt for deferred work), ctx.storage (R2), ctx.vectors
(Vectorize), and — when their packages are wired — ctx.ai and ctx.containers.
HTTP endpoints
For webhooks or non-RPC HTTP, use httpRouter / httpRoute + httpAction:
import { httpAction, httpRouter } from "@lunora/server";
export default httpRouter({
"/webhooks/stripe": httpAction(async (ctx, request) => {
const event = await request.json();
await ctx.runMutation(internal.billing.record, { event });
return new Response("ok");
}),
});
Checklist