Skip to main content

Choose Query, Mutation, or Action

Every public operation in a Runku application is a Query, Mutation, or Action. The choice defines what the handler may do, how Runku executes it, and what a caller may safely retry.

Decision table

NeedUseWhy
Read application dataQueryone read-only snapshot; eligible for Realtime
Create, replace, or delete documentsMutationatomic commit with optimistic-concurrency retry and durable operation identity
Schedule work as part of a data changeMutationschedules commit atomically with document/index/outbox changes
Upload/download application filesActionstorage is an external capability, outside the document transaction
Call an external HTTPS serviceActionexternal effects cannot be rolled back with application data
Run Node.js/npm codeFull Node ActionQuery and Mutation always use the Safe runtime
Subscribe to changing resultspublic QueryRealtime reruns the Query after relevant committed changes

The shortest correct rule is: read with Query, transact with Mutation, perform effects with Action.

Function layout and name

Create modules below runku/ and export declarations:

runku/orders.ts export create → orders.create
runku/admin/users.ts export disable → admin.users.disable

A logical Function name is its relative module path plus export name. It is at most 128 UTF-8 bytes, begins with an ASCII letter, and may contain ASCII letters, digits, _, ., /, or -. Treat it as a public API name.

Every declaration has exactly six fields:

export const example = query({
auth: "user",
visibility: "public",
capabilities: ["auth:read", "db:read"],
args: v.object({ id: v.documentId("notes") }),
returns: v.union(v.null(), note),
async handler(ctx, input) {
// implementation
},
})
FieldWhat you decide
authWhich functional principal is accepted
visibilityWhether an external client or only another Function can call it
capabilitiesWhich members Runku exposes on ctx
argsExact runtime-validated input
returnsExact runtime-validated result
handlerApplication behavior

Declarations must be statically extractable. Use literal metadata and validators declared with @runku/server; do not compute auth, visibility, capabilities, or validators at runtime.

Query

A Query reads one logical snapshot. All get and scan calls in one Query see that same snapshot, even if another Mutation commits while the Query is running.

// runku/notes.ts
import { query, v } from "@runku/server"
import schema, { note } from "./schema.js"

export const get = query({
auth: "user",
visibility: "public",
capabilities: ["auth:read", "db:read"],
args: v.object({ id: v.documentId("notes") }),
returns: v.union(v.null(), note),
async handler(ctx, input) {
const principal = ctx.auth.principal
if (principal === null || principal.kind !== "user") {
throw new Error("user required")
}

const document = await ctx.db.get(schema.tables.notes, input.id)
if (document === null) return null
if (document.value.ownerId !== principal.id) {
throw new Error("note access denied")
}
return document.value
},
})

ctx.db.get returns null or:

FieldMeaning
documentIdtyped doc_* identity
revisionpositive document revision used by replace/delete
commitSequenceEnvironment commit that produced this revision
createdAt, updatedAtsigned microsecond timestamps
valueschema-validated document

A Query may derive deterministic IDs with ctx.db.documentId(table, stableKey) and scan a declared index. It cannot insert, replace, delete, schedule work, use file storage, or perform HTTPS effects.

Query and Realtime

A public Query is the only subscribable Function kind. Runku records its point/range dependencies. After a Mutation commits a relevant change, Runku reruns the Query and sends a new authoritative result. A Query must therefore remain deterministic for a given snapshot, identity, arguments, configuration revision, and exact code pin.

Do not read the current wall clock, random state, filesystem, or a remote service from a Query. Those capabilities are not present in the Safe context.

Query limits

  • each index scan requests 1..=1000 entries;
  • all scans in one Query may return at most 10,000 entries in total;
  • one Query may record at most 10,000 distinct dependencies;
  • the configured invocation deadline includes queueing and Platform operations.

See Documents, indexes, and concurrency for scan behavior and the current range-key limitation.

Mutation

A Mutation is the only Function kind that writes application documents. Reads establish an optimistic read set; writes remain buffered until the handler returns a valid result and the entire commit succeeds.

import { mutation, v } from "@runku/server"
import schema, { note } from "./schema.js"

export const create = mutation({
auth: "user",
visibility: "public",
capabilities: ["auth:read", "db:read", "db:write"],
args: v.object({
title: v.string({ minBytes: 1, maxBytes: 200 }),
body: v.string({ maxBytes: 20_000 }),
}),
returns: v.object({ id: v.documentId("notes"), note }),
async handler(ctx, input) {
const principal = ctx.auth.principal
if (principal === null || principal.kind !== "user") {
throw new Error("user required")
}

// The same invocation intent derives the same ID across an OCC rerun.
const id = ctx.db.documentId(schema.tables.notes, ctx.invocation.invocationId)
const value = {
ownerId: principal.id,
title: input.title.trim(),
body: input.body,
priority: 0n,
labels: [],
}
await ctx.db.insert(schema.tables.notes, id, value)
return { id, note: value }
},
})

Insert, replace, and delete

// Insert requires the ID to be absent.
await ctx.db.insert(schema.tables.notes, id, value)

// Replace the complete document only if revision is still current.
const current = await ctx.db.get(schema.tables.notes, id)
if (current === null) return false
await ctx.db.replace(schema.tables.notes, id, current.revision, {
...current.value,
title: nextTitle,
})

// Delete only the exact revision you observed.
await ctx.db.delete(schema.tables.notes, id, current.revision)

replace is a full replacement, not a partial patch. The replacement must satisfy the complete table schema. Never fabricate a revision or catch a conflict and force the write: let Runku rerun the Mutation from a fresh snapshot.

Mutations do not expose index scans in the current public Function API. Design writes around point reads by deterministic/document IDs. When a write needs a lookup that only an index can provide, resolve the document through a Query before the Mutation and re-check every security/precondition inside the Mutation, or maintain an explicit deterministic lookup document.

Atomic commit

One successful Mutation commit includes all buffered:

  • document inserts, replacements, and deletes;
  • derived index additions/removals;
  • Realtime outbox records;
  • schedules created by the Mutation;
  • durable operation result used for replay.

If validation, application code, a limit, or commit fails, none of those buffered changes becomes visible. Realtime never observes a pre-commit state.

OCC retry and operation replay

Runku may rerun the complete Mutation handler up to three times after an optimistic-concurrency conflict. Mutation code must not perform external effects; its available capabilities enforce that rule.

The public caller supplies one opn_* operation ID. @runku/client creates it and preserves it across retryable attempts. A committed replay with the same target, Function, arguments, scope, and operation ID returns the original durable result. Reusing the ID for different intent fails.

Mutation limits in the current execution contract are 10,000 distinct document reads, 1,000 document writes, and 100 schedules per invocation. These are hard ceilings, not batch-size recommendations; split large jobs into bounded, idempotent scheduled work.

Action

An Action coordinates work that is not one atomic document transaction. It may use file storage, scheduling, nested Functions, secrets, and—when the deployment provides it—mediated HTTPS.

import { action, v } from "@runku/server"

export const notifyLater = action({
auth: "user",
visibility: "public",
capabilities: ["scheduler:create"],
args: v.object({ notificationId: v.string({ minBytes: 1, maxBytes: 128 }) }),
returns: v.string(),
handler(ctx, input) {
return ctx.scheduler.runAfter(
5_000_000n,
"notifications.deliver",
input,
{ idempotencyKey: `notification:${input.notificationId}` },
)
},
})

Actions do not expose ctx.db. Read or change documents through explicit nested calls:

const order = await ctx.runQuery("orders.getInternal", { orderId })
await chargeExternalSystem(order)
await ctx.runMutation("orders.markPaid", { orderId, receiptId })

The Action must declare function:query and function:mutation. The called Functions should be visibility: "internal" when clients must not call them directly.

Effects and retry

An Action is never automatically retried by @runku/client. A timeout, cancellation, lost response, or worker termination does not prove that an external effect did not happen.

For every effect:

  1. create a stable application-level idempotency key before the call;
  2. pass it to the downstream service when supported;
  3. record/reconcile the downstream result through an idempotent Mutation;
  4. on an uncertain result, query the downstream system before attempting the effect again.

Safe Action and Full Node Action

Actions use the Safe runtime by default. It has no ambient filesystem, process, environment, Node built-ins, npm resolution, or arbitrary sockets. Prefer Safe Actions and declared Platform capabilities.

When Node APIs or production npm dependencies are essential, put this directive on the first line of the Action module:

"use runku node"

import { createHash } from "node:crypto"
import { action, v } from "@runku/server"

export const digest = action({
auth: "none",
visibility: "public",
capabilities: [],
args: v.string({ maxBytes: 1_000_000 }),
returns: v.string({ minBytes: 64, maxBytes: 64 }),
handler(_ctx, input) {
return createHash("sha256").update(input).digest("hex")
},
})

Local development supports Full Node on the developer machine. The compact Self-Hosted release does not publish the shared-untrusted Full Node Agent/VM profile; do not treat its container as a multi-tenant Node isolation boundary.

HTTPS availability in the compact profile

network:https and ctx.https.request are public application contracts, but the current compact Self-Hosted composition does not attach an HTTPS egress broker. A Function declaring it fails with ACTION_HTTPS_UNAVAILABLE. Do not design a compact deployment around this capability until a published profile explicitly supplies and documents the broker.

When available in a compatible deployment, the request surface is:

const response = await ctx.https.request({
method: "POST",
url: "https://payments.example.com/charges",
headers: {
"content-type": ["application/json"],
"idempotency-key": [paymentId],
},
body: new TextEncoder().encode(JSON.stringify(payload)),
idempotencyKey: paymentId,
})

The mediated contract bounds request/response bodies to at most 16 MiB, header aggregate to 64 KiB/256 values, an idempotency key to 128 bytes, and a call to at most one minute; deployment policy may be stricter.

Authentication and visibility

Authentication identifies the functional principal. It does not replace document authorization.

authExternal caller accepted
noneno functional bearer required; any presented principal is discarded for this call
optionalno bearer, guest, user, or service
guestguest, user, or service; absent is rejected
useruser only
serviceservice only for external calls

Use capabilities: ["auth:read"] to receive ctx.auth. Then verify resource ownership, membership, and application-specific scopes inside the Function. auth: "user" proves the principal kind; it does not prove that the user owns input.id.

visibility: "public" allows the public Function API. visibility: "internal" permits only a nested call in the already-authorized invocation tree. Internal visibility is not a substitute for authorization checks: nested calls still apply their own auth policy.

Capability matrix

CapabilityQueryMutationActionContext member
db:readyesyesnoctx.db.get, documentId; Query also scan
db:writenoyesnoctx.db.insert, replace, delete
auth:readyesyesyesctx.auth
function:queryyesyesyesctx.runQuery
function:mutationnoyesyesctx.runMutation
function:actionnonoyesctx.runAction
scheduler:createnoyesyesctx.scheduler
storage:readnonoyesread methods on ctx.storage
storage:writenonoyeswrite methods on ctx.storage
network:httpsnonoyesctx.https when the deployment attaches a broker
variable:NAMEyesyesyesctx.env.get("NAME")
secret:NAMEnonoyesctx.secrets.get("NAME")

The context member is absent unless the exact capability is declared. Configuration capabilities are name-specific: variable:API_ORIGIN does not authorize ctx.env.get("OTHER_NAME").

Nested calls and exact code pinning

Use ctx.runQuery, ctx.runMutation, and ctx.runAction; do not import another Function handler and call it directly. A nested call preserves:

  • Project and Environment;
  • exact Release or Dev Revision;
  • application and functional identity subject to the child auth policy;
  • deadline and cancellation;
  • capability and visibility checks.

It never resolves a moved Channel again in the middle of the call tree. Default runtime limits are 8 nested edges and 100 nested calls for one root tree in the compact/local composition.

Scheduling and Cron

Mutation and Action can declare scheduler:create:

await ctx.scheduler.runAt(
executeAt.value,
"orders.expire",
{ orderId },
{ idempotencyKey: `expire:${orderId}` },
)

runAfter receives relative microseconds; runAt receives absolute Unix microseconds. The maximum scheduled delay is currently ten years. The target Function and arguments are validated before durable creation, and the work item pins the exact code version. Delivery is at-least-once, so the handler must deduplicate effects.

Declare recurring UTC work with cron:

import { cron, value } from "@runku/server"

export const hourlyCleanup = cron({
schedule: "0 * * * *",
function: "maintenance.cleanup",
args: { batchSize: value.int64(100n) },
})

Cron targets an existing Mutation or Action. An operator separately activates/deactivates the declaration. Use value.int64, value.float64, value.timestamp, value.id, and value.bytes for non-JSON constants in Cron arguments.

Context available to every Function

Every handler receives:

  • ctx.invocation: exact Project, Environment, Release, request, invocation, Function, kind, and effective capabilities;
  • ctx.log.debug/info/warn/error: bounded structured operational logs;
  • ctx.cooperate(): an explicit cooperative yield/checkpoint for CPU loops.

Do not log arguments wholesale, credentials, configuration secrets, file grants, or customer documents. Use stable bounded identifiers for correlation.

Failure and retry checklist

SituationSafe caller behavior
Query transport/retryable errorretry under a bounded policy; result may reflect a newer snapshot
Mutation response lostrepeat exact target, Function, arguments, identity, and operation ID
Mutation conflict returnedread/reconcile current state; create a new operation ID only for new intent
Action response lost/timeoutassume effects may have happened; reconcile externally before retry
Scheduled handler repeatsdeduplicate using durable application/downstream identity
Capability deniedchange the declaration only if the Function genuinely requires that authority
Return validation failsreturn a value matching returns; no Mutation commit is exposed

Next: Documents, indexes, and concurrency, TypeScript client, or HTTP API without an SDK.