Skip to content
DocsconceptsDocumentation

Actions

Non-deterministic functions for external APIs and side effects — the action builder and its ActionCtx.

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 transactionallymutation
Call an external HTTP API (fetch)action
Send email, enqueue jobs, run a non-deterministic side effectaction
Drive an AI model, browser, or BYO databaseaction

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 platform fetch, for outbound HTTP.
  • ctx.runQuery(ref, args) / ctx.runMutation(ref, args) / ctx.runAction(ref, args): call other functions (see below).
  • ctx.scheduler: runAfter / runAt to defer follow-up work.
  • ctx.storage: the full read/write R2 surface (store, delete, download, generateUploadUrl, getSignedUrl, …).
  • ctx.vectors: query and upsert Vectorize 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 (undefined for 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).