Last updated:
Every Lunora function receives a ctx as the first argument of its handler.
ctx is the only way a function reaches anything outside itself: the database,
auth, storage, the scheduler, and any add-on surfaces. The function kind decides
which ctx you get, and the kind constrains what ctx can do. A query cannot
write; an action cannot transact against the database.
import { mutation, query } from "@/lunora/_generated/server";
export const list = query.query(async ({ ctx }) => {
return ctx.db.query("messages").take(50); // read-only
});
export const send = mutation.mutation(async ({ ctx, args }) => {
if (!ctx.auth.userId) throw new Error("must be signed in");
await ctx.db.insert("messages", args); // transactional write
});The three context types
| Context | Function kind | ctx.db | Side effects |
|---|---|---|---|
QueryCtx | query | DatabaseReader | none (pure reads) |
MutationCtx | mutation | DatabaseWriter | transactional writes against one shard |
ActionCtx | action | DatabaseWriter | unrestricted: fetch, external APIs, composition |
QueryCtx
Read-only. ctx.db is a DatabaseReader (get, query, normalizeId, plus
the ctx.db.system reader for _scheduled_functions / _storage). Storage
and vectors are present but read-only (ReadOnlyStorage, VectorSearchReader).
A query may compose other queries with ctx.runQuery, observing the same
transaction snapshot. There is no runMutation/runAction, because a query is
not allowed to write — and the dispatch enforces it at runtime, so casting past
the type gets a RUN_KIND_FORBIDDEN error rather than a write smuggled into a
subscription re-run.
MutationCtx
Reads plus transactional writes. ctx.db is a DatabaseWriter (insert,
patch, replace, delete on top of the reader surface), running inside one
shard's transactional scope. Adds ctx.scheduler (enqueue deferred work) and
ctx.workflows (start/inspect durable workflows), and upgrades ctx.vectors
to the mutating VectorSearch. ctx.storage stays read-only; R2 writes are a
side effect reserved for actions. A mutation can compose other mutations and
queries (ctx.runMutation, ctx.runQuery), both reusing the same db writer and
riding the same transaction — a composed mutation does not open a second one.
ctx.scheduler is transactional too: a runAfter/runAt issued inside a
mutation is held until the mutation commits, and discarded if it rolls back. You
still get the job id back synchronously, so you can store it on the row you just
wrote and cancel it later.
ActionCtx
The escape hatch. An action can do anything a Worker can: it gets ctx.fetch
(the standard fetch) for calling external APIs, the full read/write
ctx.storage (store, delete, generateUploadUrl), ctx.scheduler,
ctx.workflows, and the mutating ctx.vectors. It composes other functions of
every kind: ctx.runQuery, ctx.runMutation, ctx.runAction. An action's
ctx.db writes are not part of a single mutation transaction. Do
transactional reads and writes inside a mutation and call it from the action:
that composed mutation runs in its own all-or-nothing span, so a throw halfway
through it rolls back every write it made — the action sees the error with
nothing half-applied behind it.
Surfaces on every ctx
These are present regardless of kind:
ctx.auth: the authenticated identity.ctx.auth.userIdis the verified user id ornull;ctx.auth.getIdentity()resolves the full claim set. Populated by@lunora/authwhen a session is present.ctx.log: a structured, function-attributed logger (log,info,warn,error,debug). A drop-in forconsole, except each line is tagged with the function path and routed to your observability sink or the dev terminal.ctx.ip: the caller's trustedCF-Connecting-IP, orundefinedfor a live-subscription re-run, a server-initiated dispatch, or non-Cloudflare hosting. Useful as a rate-limit key for anonymous traffic:@lunora/ratelimitbuckets an IPv6 address by its /64, so a client cannot rotate through its own prefix for a fresh bucket.ctx.ipitself stays the full address.ctx.now: the wall-clock instant (epoch ms) the function began, captured once so the whole handler sees a single stable value. Read time from here in aquery/mutationinstead ofDate.now(): those handlers must be deterministic (a query is re-run on every subscription re-evaluation), and thenondeterministic_query_mutationadvisor flagsDate.now(). Actions may useDate.now()freely but getctx.nowtoo for parity.
ctx.origin is on mutations and actions only: the origin
(scheme://host[:port]) the request behind the dispatch reached the worker on,
read off the request URL and never off a client header. Build absolute URLs back
to your app against it, such as a signed storage URL, instead of shipping a
per-environment base URL. It is undefined for a scheduled or workflow run and
any other dispatch no request is behind. Queries have no ctx.origin: a live
query re-runs with no request behind it and its cached result is shared across
hosts, so a query that builds such a URL needs a configured base.
:::caution
ctx.origin is the origin the current caller reached, so use it only for
URLs handed back to that same caller, such as a signed storage URL. Never build
links that go to other people from it: password-reset or invite emails,
webhooks, notifications. On a host that derives it from the Host header
(Node, rated emulated), the caller chooses that value, and a link built from
it is host-header poisoning. Use a configured canonical base URL for those.
:::
ctx field matrix
| Field | QueryCtx | MutationCtx | ActionCtx |
|---|---|---|---|
db | DatabaseReader | DatabaseWriter | DatabaseWriter* |
auth | ✓ | ✓ | ✓ |
log | ✓ | ✓ | ✓ |
ip | ✓ | ✓ | ✓ |
now | ✓ | ✓ | ✓ |
origin | — | ✓ | ✓ |
storage | read-only | read-only | read/write |
vectors | read-only | read/write | read/write |
scheduler | — | ✓ | ✓ |
workflows | — | ✓ | ✓ |
fetch | — | — | ✓ |
runQuery | ✓ | ✓ | ✓ |
runMutation | — | ✓ | ✓ |
runAction | — | — | ✓ |
* An action's ctx.db is not transactional; see above.
Add-on surfaces (codegen-wired)
Opt-in packages attach extra surfaces onto ctx through codegen. When a
project uses one, the generated _generated/server.ts widens the context type
so the surface is typed and present at runtime. Add-ons that wire a ctx
surface include:
@lunora/ai→ctx.ai(action context)@lunora/flags→ctx.flags(every context)@lunora/bindings/analytics→ctx.analytics@lunora/bindings/kv→ctx.kv@lunora/hyperdrive→ctx.sql(action context)@lunora/browser→ctx.browser(action context)@lunora/bindings/images→ctx.images@lunora/container→ctx.containers
The base QueryCtx / MutationCtx / ActionCtx in @lunora/server do not
declare these; they appear only after codegen detects the add-on. Middleware
can also augment ctx for downstream handlers (see
Middleware).