Runtime context
The `AppContext` passed to server executors.
Every server executor receives an AppContext as its second argument:
import type { AppContext } from '@voltro/runtime'
export default async (input: Input, ctx: AppContext) => {
const subject = ctx.request.subject
const rows = await ctx.store.query(/* ... */)
}Query, mutation, action, stream, and workflow files export both descriptor metadata and the default executor. The default executor receives ctx; descriptor objects stay data-only and browser-safe.
ctx.request
Per-call runtime metadata:
ctx.request.subject // user, apiKey, system, or anonymous subject
ctx.request.traceId // trace id shared with client/server spans
ctx.request.spanId // current span id when tracing is active
ctx.request.clientId // WebSocket connection id for RPC callsMost auth and tenant checks read ctx.request.subject:
const tenantId = ctx.request.subject.tenantId
if (ctx.request.subject.id == null) throw new Error('sign-in required')ctx.store
The typed mutation store. It applies schema mixin behavior for audit(), tenant(), and softDelete() where configured.
await ctx.store.insert('notes', { title: 'Hello' })
await ctx.store.update('notes', noteId, { title: 'Updated' })
await ctx.store.delete('notes', noteId)
const rows = await ctx.store.query(database.notes.descriptor)ctx.store.query returns the row type the builder already knew — database.notes
resolves to that table's row, so rows[0].title is a string with no cast. A
hand-built descriptor still resolves to the untyped Row.
Mutations receive a transactional store view. Actions and streams receive a normal store view; writes from them are not automatically rolled back as one unit.
ctx.load / ctx.loadMany — request-scoped batching
relations() + .with() is the right answer whenever the shape of the related
data is known statically: it compiles to ONE query. Reach for it first.
This is for the case it cannot express — assembly whose shape depends on the DATA. A breadth-first walk over a tree is the canonical example: each level's ids come from the level above, so no declarative relation spec covers it, and the natural code is one query per node.
let level = [await ctx.load('nodes', rootId)]
while (level.length > 0) {
const childIds = level.flatMap((n) => n?.childIds ?? [])
if (childIds.length === 0) break
level = [...await ctx.loadMany('nodes', childIds)]
}Every load issued in the same tick for the same table is coalesced into one
WHERE id IN (...), so that walk costs one query per LEVEL, not per node.
Repeated ids — a diamond where two parents share a child — are fetched once.
A missing row is null rather than a throw, because a dangling edge in a graph
walk is usually data; use .one() when absence is an error.
The cache lives exactly as long as the request. That is a correctness requirement, not a tuning choice: a longer-lived cache would serve one subject's rows to another (a data-isolation bug on a tenant-scoped store) and would go stale across a mutation in the same request.
ctx.cache
Async cache facade for request handlers:
const value = await ctx.cache.wrap(
`summary:${id}`,
{ ttlMs: 60_000, tags: ['notes'] },
() => computeSummary(id),
)
await ctx.cache.invalidateTag('notes')Mutation target metadata can invalidate matching tagged cache entries automatically.
ctx.kv
Durable key-value facade — always present, and unlike ctx.cache its entries are never evicted for capacity (they live until deleted or their TTL lapses). Use it for state you can't recompute; the default backend on a sql app is the database, so values survive restarts.
const state = await ctx.kv.getOrElse(`onboarding:${userId}`, () => ({ step: 0 }))
await ctx.kv.set(`onboarding:${userId}`, { step: state.step + 1 })get / getOrElse / set (optional { ttlMs }) / delete / has / list(prefix) / clear. See Durable key-value.
ctx.webhooks
Present only when the webhooks plugin is configured. Plugin-specific packages expose typed helpers for their optional context slots; the core runtime keeps the slot structurally typed so apps do not pay for unused plugins.
ctx.workflows
Present when the API app has discovered workflows. It starts durable work and controls existing runs:
const run = await ctx.workflows.start('notes.summarise', { noteId })
await ctx.workflows.signal({ id: run.id }, 'approval', { approved: true })
const approval = await ctx.workflows.update({ id: run.id }, 'approve', { decision: true })
const latest = await ctx.workflows.query('notes.summarise', run.executionId)
const snapshot = await ctx.workflows.wait('notes.summarise', run.executionId)Use signal(...) for fire-and-forget external events. Use update(...) when the caller needs a tracked result from the workflow's awaitUpdate(...) handler.
Inside a mutation, ctx.workflows.start(...) queues the actual launch until after the transaction commits. If the mutation rolls back, the workflow is not started. ctx.workflows.run(...) and start(..., { wait: true }) are intentionally rejected inside mutation transactions.
Workflow starts record their start source (workflow-rpc, app-context, schedule:<name>, incoming:<id>, or inspect). Request-backed starts also carry the current request trace, and authenticated request starts carry the resolved subject into the run context. Keep authorization-critical tenant/user ids in the workflow payload so resumed work stays deterministic.
Effect Services
Server executors may return Effects. Actions and streams receive the base platform layer, including HttpClient. AI helpers are imported from @voltro/ai:
import { generateText } from '@voltro/ai'
import { Effect } from 'effect'
export default (input: { prompt: string }) =>
Effect.gen(function* () {
const { text } = yield* generateText({ prompt: input.prompt })
return { text }
})Writing a wrapper that PROVIDES a service
A helper that provides a service must be generic over R and subtract the tag
it provides, or it silently narrows what callers may pass it. The failure mode is
confusing because it shows up at the CALL site, not in the wrapper:
import { Context, Effect } from 'effect'
class Tenant extends Context.Tag('Tenant')<Tenant, { readonly id: string }>() {}
// WRONG — `R` defaults to `never`, so this only accepts effects that need
// nothing else. Pass it an effect that also needs `Db` and it stops compiling.
const withTenantBad = <A, E>(id: string, effect: Effect.Effect<A, E>) =>
Effect.provideService(effect, Tenant, { id })
// RIGHT — generic over `R`, and the return type SUBTRACTS the tag it provided.
const withTenant = <A, E, R>(
id: string,
effect: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, Exclude<R, Tenant>> =>
Effect.provideService(effect, Tenant, { id })Exclude<R, Tenant> is what makes the wrapper composable: the caller's remaining
requirements pass through untouched, and only the tag you actually supplied
disappears. Without it, the wrapper's own signature dictates the caller's entire
requirement set.
The same rule applies to any callback the framework takes from you (an AI tool
body, a media generator, a workflow step): declare its R as unknown rather
than letting it default to never, then let the framework's own bridge discharge
it. A callback typed Effect<A, E, never> cannot use ANY service, which is
rarely what you meant.
Context By Primitive
| Server file | Transaction | Typical store usage | External I/O | Returns |
|---|---|---|---|---|
*.query.server.ts |
No | Read/query | Avoid | Query descriptor or computed value |
*.mutation.server.ts |
Yes | Atomic writes | Avoid | Unary output |
*.action.server.ts |
No | Optional, non-atomic | Yes | Unary output |
*.stream.server.ts |
No | Optional, non-atomic | Yes | Stream, Effect<Stream>, or Promise<Stream> |
*.workflow.tsx |
Step-specific | Durable reads/writes | Yes, inside activities | Workflow result |
Use mutations for atomic state changes, actions for one-shot side effects, streams for progressive element output, and workflows for durable multi-step work.