Actions
The only way data changes: validated input, your code, a typed result.
runtime.auth are verified; two verbs are planned. Actions with Zod input and output, execution records, retry-safe request ids and runtime.auth run in development and production today. runtime.tx and runtime.query are planned; each section shows what you write today.An action is a named operation on your domain: what it accepts, what it returns, and the code that decides what changes. It is the only way data changes. The browser reads live, but it cannot write.
Why. A generic client write ("set status to paid") lets any screen or script change any field the rules allow. An action names the business operation (orders.pay), validates it and records who ran it. Reading the domain tells you every way its data can change.
import { domain, defineAction, i } from "@domainruntime/domain";
import { z } from "zod";
export default domain("orders")
.withSchema({
entities: {
orders_order: i.entity({
status: i.string().indexed(),
total: i.number(),
placedAt: i.date().indexed(),
}),
},
links: {
orders_orderOwner: {
forward: { on: "orders_order", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "orders" },
},
},
rooms: {},
})
.withActions({
place: defineAction({
input: z.object({ orderId: z.string().uuid(), total: z.number().positive() }),
output: z.object({ orderId: z.string() }),
async execute({ input, runtime }) {
const user = runtime.auth.user;
if (!user) throw new Error("Sign in to place an order");
await runtime.tx((tx) =>
tx.orders_order[input.orderId]
.update({ status: "open", total: input.total, placedAt: Date.now() })
.link({ owner: user.id }),
);
return { orderId: input.orderId };
},
}),
});The caller picks orderId, a fresh UUID. Retries explains why.
Today
async execute({ input, runtime }) {
const user = runtime.auth.user;
if (!user) throw new Error("Sign in to place an order");
const db = await runtime.db(); // the caller-scoped database
await db.transact(
db.tx.orders_order[input.orderId]
.update({ status: "open", total: input.total, placedAt: Date.now() })
.link({ owner: user.id }),
);
return { orderId: input.orderId };
}db.transact(db.tx…) is the transaction builder DomainRuntime shares with InstantDB; db.query reads once. Both run as the caller.
Calling an action
const { orderId } = await db.actions.orders.place({ orderId: crypto.randomUUID(), total: 120 });The runtime checks the input against the schema before your code runs, and the output before it returns it. If your code throws, the execution ends failed with error code action_failed and your message, which the caller receives.
Today
Call POST /actions/orders.place/invoke — see the HTTP API — or use createDomainClient from @domainruntime/domain/client:
const result = await client.action("orders.place")({ orderId, total: 120 });
// { type: "result", result: { orderId } }Inside execute
runtime.query(q) | read once, with the same query language as the browser (planned; today (await runtime.db()).query(q)) |
runtime.tx(build) | write; everything build returns commits together, or nothing does (planned; today db.transact) |
runtime.auth | who is calling: user and admin, below |
runtime.env | what your deployment put in the runtime: configuration and clients |
Reads and writes run as the caller, in development and in production: a query returns only what the caller could see, and a write fails if the caller's permissions forbid it. The execution then ends failed with action_failed.
runtime.auth
| Caller | runtime.auth.user | runtime.auth.admin |
|---|---|---|
| A signed-in user of your app | { id, email, claims }; id is the same auth.id your permission rules see | false |
| A member of your organization, from the console or the CLI | { id, email, claims } | false |
| A service key | null | true: rules do not apply |
The caller is captured when the call is admitted and cannot change while the execution runs, even across a workflow's steps. runtime.auth is read-only.
Writing: runtime.tx
await runtime.tx((tx) => [
tx.orders_order[orderId].create({ status: "open", total, placedAt: Date.now() }), // new object only
tx.orders_order[orderId].update({ status: "paid" }), // create or update
tx.orders_order[orderId].merge({ meta: { paidBy: "card" } }), // deep-merge a json attribute
tx.orders_order[orderId].link({ owner: userId }), // connect
tx.orders_order[orderId].unlink({ owner: previousOwnerId }), // disconnect
tx.orders_order[orderId].delete(), // remove
]);One runtime.tx is one transaction. Two calls are two transactions. If the second fails, the first stays applied. An action as a whole is not a transaction: changes that must succeed or fail together go in the same tx.
To address an object by a unique attribute instead of its id:
import { lookup } from "@instantdb/core";
tx.orders_customer[lookup("email", input.email)].update({ name: input.name });If no object has that value yet, update creates one.
Retries and idempotency
Every call carries a request id (a UUID the client sends) and gets an execution record.
| Situation | What happens |
|---|---|
| The client retries with the same request id and the same input | It gets the first execution back. Your code does not run again. |
| Same request id, different input | Rejected with REQUEST_CONFLICT (409). |
| New request id | A new execution. Your code runs again. |
The process running your code dies mid-execute | The execution is marked failed and is not re-run. Any tx that already committed stays committed. |
Your code returns something that fails output | The execution fails with execution_output_invalid. Writes already committed stay. |
So the runtime never runs your code twice for one request, but a failed execution may have done part of its work, and the caller will retry with a new request id. Make that retry harmless:
- Put all of an action's writes in one
runtime.txwhen you can. A failure then leaves everything or nothing. - Let the caller choose the id of what the action creates (
orderIdabove), or address it by a.unique()attribute withlookup. A second run then updates the same object instead of creating another.
Do not check with a query and then create: two concurrent calls can both see "nothing there". See Guarantees.
Keeping implementations elsewhere
When an action grows, move its body to its own file. The domain still shows the contract:
import { placeOrder } from "./actions/place-order";
export default domain("orders")
.withSchema({ /* … */ })
.withActions({
place: defineAction({
input: z.object({ orderId: z.string().uuid(), total: z.number().positive() }),
output: z.object({ orderId: z.string() }),
execute: placeOrder,
}),
});The Zod schemas are the contract; TypeScript infers the types from them. Do not declare separate input or output types.
Who may call an action
Every action in the published domain can be called by any identity of the environment: any signed-in user, or a service key. Deciding who may run it is your code's job, in the first lines of execute:
async execute({ input, runtime }) {
const { user, admin } = runtime.auth;
if (!user && !admin) throw new Error("Not signed in");
if (user && user.claims.role !== "manager") throw new Error("Managers only");
// ...
}Permission rules protect the data; the check in execute protects the operation. Write both.
execute.Exposing actions
withActions lists a domain's own actions. Including another domain brings its entities into scope; to offer its actions through your domain's typed client, re-expose them:
export default domain("shop")
.includes(orders)
.withSchema({ entities: {}, links: {}, rooms: {} })
.withActions({ place: orders.actions.place });The action keeps its id, orders.place. This shapes the typed client; it is not access control (see above).
Long-running work
An action should finish in seconds; a call waits up to 110 s. For work that waits on people, other systems or timers, make it a workflow.
Next
- Guarantees: what is atomic, what runs once, what is live.
- Error handling: failing on purpose, and what the caller sees.
- Patterns: ownership, idempotent creates, uniqueness, imports.