Overview
How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.
The api side of a Voltro app is a collection of typed RPC primitives. You drop descriptor/server-executor pairs into the api tree; the framework discovers them; the typed client lights up.
This section covers how data flows between server and client.
A form bound to a mutation, a table bound to a query, one reactive backend — add a row and it appears instantly, pushed from the server (your own throwaway sandbox; it resets on refresh):
<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" />
The shape of it
┌──────────────────────────────────────────────────────────────────┐
│ Client (React) │
│ useSubscription('app', 'notes.list', input) │
│ useMutation('app', 'notes.create') │
│ useAction('app', 'invites.send') │
│ useAgentStream('app', 'support.run') │
└──────────────────────────┬───────────────────────────────────────┘
│ @effect/rpc over WebSocket
▼
┌────────────────────────────────────────────────────────────────────┐
│ api app │
│ *.query.ts + *.query.server.ts — reactive reads │
│ *.mutation.ts + *.mutation.server.ts — transactional writes │
│ *.action.ts + *.action.server.ts — external I/O / unary │
│ *.stream.ts + *.stream.server.ts — one-shot element push │
│ *.route.tsx — public raw-HTTP route │
│ *.workflow.tsx + *.trigger.tsx — durable work + events │
│ *.agent.tsx — persisted AI chat │
└────────────────────────────────────────────────────────────────────┘Most of it goes through one WebSocket (REST routes are the public raw-HTTP exception). Queries and streams are both streaming RPCs, but they mean different things: a query emits reactive snapshot/delta envelopes; a stream emits plain elements and then finishes.
Every primitive at a glance
Voltro ships more building blocks than this one section holds — each is a typed descriptor in a file the CLI discovers. The five at the top live here; the rest have their own sections but are the same kind of thing. The complete set:
| Primitive | File | What it is |
|---|---|---|
| Query | *.query.ts |
reactive read → Queries |
| Mutation | *.mutation.ts |
transactional write → Mutations |
| Action | *.action.ts |
unary external I/O → Actions |
| Stream | *.stream.ts |
one-shot element push → Streams |
| REST route | *.route.tsx |
public raw-HTTP endpoint → REST routes |
| Aggregate | *.aggregate.ts |
scheduled materialised query → Aggregates |
| Subscriber | *.subscribe.ts |
per-table post-commit reaction → Subscribers |
| Workflow | *.workflow.tsx |
durable multi-step work → Workflows |
| Event trigger | *.trigger.tsx |
event → workflow fan-out → Event triggers |
| Schedule | *.cron.tsx |
cron-fired handler → Scheduling |
| Agent | *.agent.tsx |
persisted AI chat → Agents |
| Tool | *.tool.tsx |
model-callable function → Tools |
| Seed | *.seed.ts |
boot/data seeding → Seeds |
| Startup | *.startup.tsx |
run-once boot hook (long-lived work + teardown) → Startup hooks |
*.email.tsx |
React-Email template → Mail | |
| Webhook | *.webhook.tsx |
signed inbound / outbound → Webhooks |
What's in this section
Primitives — Queries (reactive reads + dependency tracking) · Mutations (transactional writes, typed errors, auto-optimistic) · Actions (unary RPC, no transaction) · Subscriptions (the reactive engine behind snapshots/deltas) · Streams (defineStream element push) · REST routes (public raw-HTTP for third parties) · Aggregates (scheduled materialised queries) · Subscribers (per-table post-commit reactions).
Protocol & errors — Wire protocol (framing, multiplexing) · Error handling (Schema-tagged errors, retries, client narrowing).
The contract
Every query, mutation, action, and stream has two files:
queries/notes.list.query.ts # descriptor: browser-safe schema + metadata
queries/notes.list.query.server.ts # executor: server-only implementationThe descriptor defines the wire surface:
defineX({
name: 'feature.action',
input: Schema.Struct({ /* ... */ }),
output: Schema.Struct({ /* mutations/actions */ }),
element: Schema.Struct({ /* streams */ }),
error: Schema.Union(ErrorVariantA, ErrorVariantB),
})The server executor receives (input, ctx) and can return a plain value, Promise, Effect, query descriptor, or Stream, depending on the primitive. Everything is Schema-validated at the boundary. The TypeScript types flow to the client automatically through rpcGroup.generated.ts.
namecharset. A procedurenameis.-separated and becomes a JS identifier inrpcGroup.generated.ts(todos.list→todosListRpc). It must start with a letter and use only letters, digits, and./-/_.-and_are camelCased away, so two names differing only by separator collide. Codegen rejects an invalid or colliding name atvoltro devboot, naming the file. Prefer plain camelCase segments (todos.list).
Reactivity model
Queries are live. A query descriptor declares source: 'tableName'; the server executor returns either a query builder/descriptor or a computed value. When a mutation writes to the table, the runtime recomputes affected subscriptions and pushes a snapshot or delta.
Streams are not live subscriptions. A stream executor returns a one-shot Stream of elements. Use streams for transient token feeds, progress events, logs, or external event sources. If the result should survive reloads or update other tabs, persist rows and expose them through a query.
Tenant scoping
Every executor receives ctx.request.subject — the typed identity of the caller. Tables with the tenant() mixin auto-scope reads and writes. See Multi-tenancy for the full story.
When to use what
| You want… | Use |
|---|---|
| Read data that updates live | *.query.ts + *.query.server.ts + useSubscription |
| Write data atomically | *.mutation.ts + *.mutation.server.ts + useMutation |
| External I/O / unary side effect | *.action.ts + *.action.server.ts + useAction |
| Public HTTP endpoint for a third party (URL + JSON) | *.route.tsx (defineRestRoute) |
| Transient server-to-client element stream | *.stream.ts + *.stream.server.ts + useAgentStream |
| Durable persisted AI chat | *.agent.tsx or action + query over agent_messages |
| Background job | *.workflow.tsx |
| Fan a domain event out to one or more workflows | *.trigger.tsx + ctx.events.emit(...) |
| Pre-computed query result (top-N, summary) | *.aggregate.ts |
| React to every commit on a table (server-side) | *.subscribe.ts |
| Event ingestion + analytical aggregates over events | Analytics sink |
Pages don't have to pick only one. Most use useSubscription for reads and useMutation for writes; add actions or streams only when the workflow calls for them.