Last updated:
An action runs outside Lunora's transaction. A query reads only and a
mutation does transactional reads and writes, but an action can fetch() a
third-party API, send an email, talk to a queue, or drive an AI model. Two
things give way in return: actions are not reactive (clients don't auto-subscribe
to them), and their ctx.db writes are not wrapped in a transaction — each one
commits on its own.
import { action, v } from "@/lunora/_generated/server";
import { api, internal } from "@/lunora/_generated/api";
export const syncWithStripe = action.input({ customerId: v.string() }).action(async ({ ctx, args }) => {
// 1. Read current state via a query, so the read runs in a transaction.
const customer = await ctx.runQuery(internal.customers.byStripeId, { stripeId: args.customerId });
// 2. Talk to the outside world.
const response = await ctx.fetch(`https://api.stripe.com/v1/customers/${args.customerId}`);
const remote = await response.json();
// 3. Persist the result through a mutation.
await ctx.runMutation(internal.customers.applyStripeSync, { id: customer._id, remote });
});When to use an action vs a mutation
| You need to… | Use |
|---|---|
| Read/write your own tables transactionally | mutation |
Call an external HTTP API (fetch) | action |
| Send email, enqueue jobs, run a non-deterministic side effect | action |
| Drive an AI model, browser, or BYO database | action |
A mutation runs inside the shard's transaction, so its ctx.db writes commit
atomically and broadcast reactive deltas. An action runs inside the shard's
Durable Object too, but outside a storage transaction, so it can do unbounded I/O
without holding one open. Reach for a mutation first, and promote to an action only
when you need a side effect a mutation can't safely perform.
ActionCtx
The handler receives ctx: ActionCtx. Its surface (from @lunora/server):
ctx.fetch: the platformfetch, for outbound HTTP.ctx.runQuery(ref, args)/ctx.runMutation(ref, args)/ctx.runAction(ref, args): call other functions (see below).ctx.scheduler:runAfter/runAtto defer follow-up work.ctx.storage: the full read/write R2 surface (store,delete,download,generateUploadUrl,getSignedUrl, …).ctx.vectors: query andupsertVectorize indexes.ctx.workflows: start / inspect durable workflows.ctx.auth: the resolved identity (ctx.auth.userId,ctx.auth.getIdentity()).ctx.log: structured, function-attributed logger.ctx.ip: the caller's IP (undefinedfor server-initiated dispatch).
An action does have a ctx.db writer, but it is not transactional: each write
commits on its own and none of them roll back together. Use it for one-off writes,
and route anything that must commit atomically through runQuery / runMutation,
each of which forwards to the owning shard and runs as its own transaction.
Calling queries and mutations from an action
Use the generated references (api.* for public functions, internal.* for
internal ones) so the call is fully typed and the argument validators are
enforced:
import { action, v } from "@/lunora/_generated/server";
import { api, internal } from "@/lunora/_generated/api";
export const importContacts = action.input({ url: v.string() }).action(async ({ ctx, args }) => {
const response = await ctx.fetch(args.url);
const rows = (await response.json()) as { email: string; name: string }[];
for (const row of rows) {
// Each runMutation is its own shard transaction — not atomic across rows.
await ctx.runMutation(internal.contacts.upsert, row);
}
return { imported: rows.length };
});runQuery from an action returns a point-in-time snapshot, not a live
subscription, so re-call it if you need fresh data. Each runMutation is its
own transaction. A partial failure mid-loop does not roll back the mutations
that already committed.
Scheduling follow-up work
ctx.scheduler enqueues a deferred call against the SchedulerDO. Schedule
another function (typically an internal one) to run later:
export const startDripCampaign = action.input({ userId: v.id("users") }).action(async ({ ctx, args }) => {
await ctx.runMutation(internal.email.queueWelcome, args);
// Send the day-2 follow-up later, off the request path.
await ctx.scheduler.runAfter(2 * 24 * 60 * 60 * 1000, internal.email.sendFollowUp, args);
});runAfter(delayMs, ref, args) fires after a delay; runAt(timestampMs, ref, args)
fires at an absolute time. Both return a job id you can later ctx.scheduler.cancel(id).