Actions
The one place the outside world happens — HTTP, model providers, global storage.
An action is for everything a mutation may not do: HTTP calls, model providers, payment APIs, and writes to global storage.
import { action } from "@highx/core";
export const enrich = action({
name: "notes.enrich",
args: domainArgs,
returns: enrichment,
auth: can("notes:write"),
handler: async (ctx, args) => {
const response = await ctx.fetch(`https://api.example.com/${args.domain}`);
return ctx.run(saveEnrichment, { domain: args.domain, value: await response.json() });
},
});What an action can reach
ctx.fetch | HTTP, with the request deadline already attached |
ctx.run(mutation, args) | Run a mutation as its own transaction |
ctx.db | Read tenant data directly |
ctx.global | The D1 read model |
ctx.saga | A durable, replayable sequence of steps |
ctx.signal | Aborts when the deadline passes |
An action is not a transaction
This is the one thing to internalize. ctx.run commits immediately; if the
action fails afterwards, that write stays.
Three shapes, of which two are safe on a retry:
| Shape | On retry |
|---|---|
| Same idempotency key, first attempt succeeded | The stored result is replayed; the handler never runs again |
ctx.run inside a saga step | The step replays its output, or was compensated and re-runs clean |
Bare ctx.run, no saga and no key | Repeats. Nothing recorded the earlier commit, so a second row is written |
The third is a real hazard, not a theoretical one. Give any committing action a saga step or an idempotency key.
Deadlines
ctx.signal aborts when the request deadline passes, and ctx.fetch already
carries it. Pass the signal to anything else you await, so a slow provider ends
the action rather than holding the object.
Actions are not live
An action returns a value to its caller and nothing else. It does not create a subscription, and it is not re-run on data changes. If a UI should update as a result, the mutation the action ran is what patches the subscribers.