Queries
`*.query.ts` + `*.query.server.ts` pairs — reactive reads consumed with useSubscription, with dependency tracking and delta updates.
A query is a reactive read. The client subscribes to it; when the underlying data changes, Voltro pushes a fresh snapshot or delta over the WebSocket. No polling, no manual refetch, no mutation response gymnastics.
Live — a computed-return query: the { open, done, total } counts recompute the
instant you add or toggle a todo (it declares source: 'todos'):
const stats = useSubscription('app', 'todos.stats') // computed, live — no refetch
Minimal query pair
Descriptor file:
// apps/api/queries/notes.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const listNotes = defineQuery({
name: 'notes.list',
source: 'notes',
guards: [{ scope: 'notes:read' }],
input: Schema.Struct({}),
output: Schema.Array(Schema.Struct({
id: Schema.String,
title: Schema.String,
})),
})Server executor:
// apps/api/queries/notes.list.query.server.ts
import { database } from '../database/index'
export default () =>
database.notes.orderBy('createdAt', 'desc').limit(100)Save both files and notes.list becomes a streaming query in the typed client.
guards:is not decoration here — it is what makes the file boot. Every wire-exposed procedure must declare exactly one ofguards:,openAccess: '<reason>'orinternal: true; a descriptor that declares none is refused at boot, naming the file. Which one is right is a real decision, and both of the other two appear on this page below. Full rules: Authorization.
Consuming a query
import { useSubscription } from '@voltro/client'
export default function Notes() {
const { data, loading, error } = useSubscription('app', 'notes.list', {})
if (error) return <p>Error: {String(error)}</p>
if (loading) return <p>Loading...</p>
return (
<ul>
{data.map((note) => <li key={note.id}>{note.title}</li>)}
</ul>
)
}The first argument is the api name from app.config.ts.apis; the second is the descriptor's name.
Inputs
Descriptor:
// apps/api/queries/messages.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const listMessages = defineQuery({
name: 'messages.list',
source: 'messages',
guards: [{ scope: 'messages:read' }],
input: Schema.Struct({
channelId: Schema.String,
limit: Schema.Number,
}),
output: Schema.Array(Schema.Struct({
id: Schema.String,
channelId: Schema.String,
body: Schema.String,
})),
})Server executor:
// apps/api/queries/messages.list.query.server.ts
import { eq } from '@voltro/database'
import { database } from '../database/index'
export default (input: { channelId: string; limit: number }) =>
database.messages
.where(eq('channelId', input.channelId))
.orderBy('createdAt', 'desc')
.limit(input.limit)Client:
const { data } = useSubscription('app', 'messages.list', {
channelId: 'c_123',
limit: 50,
})An undeclared field is refused, not dropped
The input schema is the whole accepted key set. A field it does not declare
fails the call with a ParseError naming the key and listing what was expected:
{ readonly channelId: string; readonly limit: number }
└─ ["employeeId"]
└─ is unexpected, expected: "channelId" | "limit"
It used to be discarded silently, and the reason that is worse than it sounds is
what a discarded FILTER means. A deployment's layout called a userSettings.list
that declares userId with { employeeId }; the payload decoded to {}, which
for a list query is not a narrower filter but the absence of one, and an admin
was served another user's row.
The decoder cannot tell a projection field from a filter field, so it refuses either way. Three consequences worth knowing:
- Nested objects and union members follow the same rule — a stray key inside
{ page: { limit, offset } }is refused too. Schema.Struct({})means "this procedure takes nothing", and a call carrying anything is refused.Schema.Record(...)keeps its open key set: there the openness is declared.- If a call site legitimately holds more than the procedure declares — a spread of a wider filter object — narrow it at the call site rather than widening the schema. Widening restores the silent drop under a different name: the field is accepted and still does nothing.
Descriptor-return vs computed-return
A query executor can return either:
| Executor returns | Use when |
|---|---|
A database.<table> query builder / descriptor |
You are streaming rows from one table and want fine-grained predicate-aware invalidation. |
| A computed value | You are building an aggregate, join, projection, or other derived shape. |
Computed example:
// apps/api/queries/notes.summary.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const notesSummary = defineQuery({
name: 'notes.summary',
source: 'notes',
guards: [{ scope: 'notes:read' }],
input: Schema.Struct({}),
output: Schema.Struct({
open: Schema.Number,
done: Schema.Number,
}),
})// apps/api/queries/notes.summary.query.server.ts
export default async (_input: Record<string, never>, ctx) => {
const notes = await ctx.store.select('notes').all()
return {
open: notes.filter((note) => !note.done).length,
done: notes.filter((note) => note.done).length,
}
}For computed queries, source is the reactivity trigger. When any row in the source table changes, the runtime re-runs the executor and emits the new value if it changed. If the executor reads more than one table (a join or matrix), declare source as an array — the executor re-runs when any listed table changes (e.g. source: ['skills', 'ratings']).
Type the computed return against output — defineExecutor
A computed executor's return is encoded through the descriptor's output schema — but the bare default export's return is not type-checked against it. So a handler that builds { publishedAt: row.publishedAt.getTime() } where output is timestampMs (Type Date) compiles green and throws Expected DateFromSelf, actual 1784… at encode time, which Dies the subscription. For a nullable date it's a time-bomb: fine while the value is null, exploding the instant it becomes non-null (a publish, say).
Wrap the handler in defineExecutor(descriptor, fn) — it pins the return to Schema.Type<output>, turning that into a compile error at the handler:
// apps/api/queries/notes.summary.query.server.ts
import { defineExecutor } from '@voltro/runtime'
import { notesSummary } from './notes.summary.query'
export default defineExecutor(notesSummary, async (_input, ctx) => {
const notes = await ctx.store.select('notes').all()
return {
open: notes.filter((note) => !note.done).length,
done: notes.filter((note) => note.done).length,
}
})It's a runtime identity (returns the handler unchanged) — the whole value is the compile check. The Effect error and requirement channels stay inferred; only the success value is constrained. A descriptor-return (reactive) executor is allowed through unchecked: the store produces the rows, so a value-level return type can't express the row-vs-output check. defineExecutor works the same for defineMutation / defineAction handlers.
Composing one executor inside another (e.g. a workflow
step). The valuedefineExecutorreturns is typed as theExecutorReturnUNION (Output | Promise | Effect | descriptor-return), so it has no.pipe— you can't feed the wrapped default export straight into another Effect. Export the handler's rawEffectseparately (a namedexport const execute = …, or import the un-wrapped function) and compose THAT; keep thedefineExecutor-wrapped default only as the procedure's entry point. The union return is deliberate — it's what lets one helper type every executor shape — so this is a "import the raw effect for composition" convention, not a gap to route around.
Auto-optimistic source
source also connects query caches to mutation target metadata:
defineQuery({ name: 'notes.list', source: 'notes', guards: [{ scope: 'notes:read' }], /* ... */ })
defineMutation({
name: 'notes.create', target: { table: 'notes', op: 'insert' },
guards: [{ scope: 'notes:write' }], /* ... */
})With that pairing, useMutation('app', 'notes.create') can stage an optimistic row in active notes.list caches without client-side cache plumbing.
A guard that reads a second table belongs in source
source is the reactive trigger set: the query re-runs when a listed table
changes, and only then. So a guard that loads a row from ANOTHER table to decide
access has made that table part of what the result depends on:
export const teamBoard = defineQuery({
name: 'boards.forTeam',
input: Schema.Struct({ teamId: Schema.String }),
output: BoardRows,
// The guard resolves the caller's membership of THIS team — a read of
// `teamMembers` that happens before the executor runs.
guards: [{ action: 'view', resourceType: 'team', resource: (input) => input.teamId }],
// `boards` alone is wrong here — that membership read is part of what the
// result depends on.
source: ['boards', 'teamMembers'],
})Leave teamMembers out and the subscription does not re-run when membership
changes. That is an authorization staleness, not a cosmetic one: revoke
someone's membership and their open subscription keeps serving rows they may no
longer see, until something else happens to invalidate it.
Reported by a team whose own invariant caught it after five computed queries
under-declared their source; the fix was array sources.
voltro dev now says when a query reads a table it did not declare
That paragraph used to end "nothing warns about this at runtime". It does now.
While voltro dev is running, every read a query makes is attributed to it and
compared against its own source:. Read a table you did not declare and the
terminal says so, once:
source: tasks.list: read `task_sub_tasks` without declaring it in `source:`.
A write to that table will not re-run this query, so an open view keeps
showing what it showed before. The write itself is fine, which is why nothing
else reports this.
It is deliberately narrow, and knowing where its edges are is the difference between acting on it and learning to skim it:
- It reports what it SAW. A branch that did not run contributes nothing, so
it never claims your
source:is otherwise complete — only that a table it watched you read is missing from it. - Once per query per boot. A per-request warning on a hot list would be its own outage.
- A query with no
source:at all is left alone. It has made no claim; the finding is about an incomplete list, not a missing one. - An eager-loaded relation COUNTS, and it is the case worth knowing about.
.with({ subTasks: true })issues no second read — the whole spec folds into one round trip — so the loaded table never appears as a read of its own. The recorder resolves it through the relation registry instead, target and (for a many-to-many) junction alike. A write to the junction changes membership, which is exactly the change a user makes. - A
crud.*executor is watched exactly like a hand-written one — and it is the case that needs it most.crud.list('tasks', { include: { subTasks: true } })reads a table your own file never names, so there is nothing in front of you to checksource:against. The descriptor stays yours either way:crud.*supplies only the executor, you write thesource:beside it.crud.countcounts as a read too — it returns a number rather than rows, but an insert changes that number, so the counted table belongs insource:or "page 3 of 12" stops moving.crud.create/update/removeissue no read at all and never produce a finding. - A table read only to NARROW a result is not counted — a parent reached
through
inSubquery(...), or a read the framework made to resolve your row filter. Those decide which rows come back rather than contributing rows, and putting every one of them insource:would re-run every list on every membership write.
That last rule is a judgement the recorder makes for the common case and
deliberately does not make for yours. The section above is the case where you
want a restricting read in source: anyway — an authorization read whose
staleness you care about. The recorder will not nag you into it and will not
argue when you add it.
Dev only. voltro serve installs none of it — it costs a wrapper per read,
and a production log is not where this gets read. VOLTRO_SOURCE_RECORDER=off
turns it off in dev.
If your own helper resolves access somewhere the framework does not call it, wrap
it in restrictingReads (from @voltro/runtime) and its reads stop counting —
inside or outside a recording session, so it is safe to leave in place.
output is the serializer — timestampMs
A descriptor's output is not documentation of the shape. It is the
serializer: it is handed to the rpc as the success schema, so a handler's result
is encoded through it on the way out and decoded on the client. Any conversion
the schema describes, the framework performs — you never need a converter at the
tail of a handler.
That is worth stating plainly, because the symptom is usually read backwards.
A timestamp() column comes back from the store as a Date, and Date is not
JSON. Declaring that field as Schema.Number looks like the fix, but it
describes the wire type rather than the domain type — which leaves the schema
with nothing to convert, and pushes the conversion back into the handler:
// The shape that leads to hand-written converters everywhere
output: Schema.Array(Schema.Struct({ createdAt: Schema.Number })),
// …and then, at the tail of every executor:
return rows.map((row) => ({ ...row, createdAt: row.createdAt.getTime() }))Declaring Schema.DateFromNumber instead makes the conversion automatic.
@voltro/database/wire ships that mapping under names you can drop straight
into your own struct — including the nullable case, which is the one that goes
wrong silently:
// apps/api/queries/projects.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { timestampMs, timestampMsOrNull } from '@voltro/database/wire'
import { Schema } from 'effect'
export const listProjects = defineQuery({
name: 'projects.list',
source: 'projects',
guards: [{ scope: 'projects:read' }],
input: Schema.Struct({}),
output: Schema.Array(
Schema.Struct({
id: Schema.String,
name: Schema.String,
jiraProjectKey: Schema.String,
addedAt: timestampMs, // Date in the handler, epoch ms on the wire
archivedAt: timestampMsOrNull, // for a nullable timestamp column
seenAt: Schema.optional(timestampMs),
}),
),
})The executor returns its rows and computes nothing at the tail:
// apps/api/queries/projects.list.query.server.ts
export default () => database.projects.orderBy('addedAt', 'desc').limit(100)timestampMs—Datein the handler,number(epoch ms) on the wire.timestampMsOrNull— for a.nullable()timestamp. Use this rather than converting a null by hand:new Date(null)is1970-01-01, so "never archived" renders as a plausible date instead of as nothing.- An optional field is
Schema.optional(timestampMs)— there is no third export for it.
This also matches the shape real handlers have. Most return a struct assembled
by hand across several tables — { id, name, slug, addedAt, jiraProjectKey } —
where there is no single table to derive an output schema from anyway, and those
are exactly the places the hand-written Date → epoch converters pile up.
Why the field schemas import from @voltro/database/wire
@voltro/database/wire is a browser-safe entry: it contains plain
effect/Schema values and imports effect and nothing else. That matters
because a descriptor is loaded value-level by the web client — the rpc client
needs every procedure's schema — so everything a *.query.ts transitively
imports ends up in the browser bundle.
The package root is not browser-safe, and neither is anything that reaches a
table value. Importing ../database/schema to get at notes imports
@voltro/database itself, which pulls the store, the query builder and the SQL
driver into the client graph. voltro dev refuses to boot in that case and
prints the import chain:
browser-safety violation — the generated rpcGroup pulls a SERVER-ONLY module
into the client bundle.
import chain:
→ rpcGroup.generated.ts
→ ./queries/notes.list.query
→ ../database/schema
→ @voltro/database
So a descriptor's output is always written as your own Schema.Struct with
field schemas — never derived from a table. Deriving from a table is a
server-side operation; see below.
The Encoded / Type split
That split is the whole point — the handler works in domain types, the wire carries something JSON can represent:
| Column | In the handler (Type) | On the wire (Encoded) |
|---|---|---|
timestamp(), date() |
Date |
number (epoch ms) |
bigint() |
bigint |
string (decimal) |
text(), enum(), id(), reference() |
string |
string |
integer(), real(), decimal() |
number |
number |
boolean() |
boolean |
boolean |
json(), vector(), raw() |
unknown |
unknown |
bigint() crosses as a decimal string on purpose: a bigint sent as a JSON
number rounds silently past 2^53, and a value that is quietly wrong is worse than
one that is rejected.
A .nullable() column wraps its mapped type, so a null timestamp round-trips as
null rather than becoming epoch 0 (which would render as a plausible
1970-01-01 instead of "never").
Row codecs for server-side code — rowSchema, columnSchema
rowSchema(table) builds the whole struct from a table definition, and
columnSchema(def) maps one column. Both take the table as a value, so by
the section above they can only be used in code that already runs on the server
alone — a *.query.server.ts / *.mutation.server.ts executor, a *.seed.ts,
a startup or job module, a maintenance script, a test. Not a descriptor's
output.
What they are for is encoding or decoding table rows outside the rpc path, where
no output schema is doing it for you: writing rows to a file export or a queue
payload, or validating seed / import data against the actual table shape before
it is written.
// apps/api/database/notes.seed.ts — server-only, so the table value is fine
import { rowSchema } from '@voltro/database'
import { Schema } from 'effect'
import { notes } from './schema'
const rows = Schema.decodeUnknownSync(Schema.Array(rowSchema(notes)))(
JSON.parse(await readFile('seed/notes.json', 'utf8')),
)omit drops columns from the schema — the way to keep an internal column out of
an export:
rowSchema(users, { omit: ['passwordHash'] })It is a convenience, not a security boundary. The column is simply absent
from this schema; code that serializes the same row under a different schema
still emits it. For a real boundary, see
.encrypted() and column sensitivity.
columnSchema takes a column definition — what table.fields holds — not
the builder that text() or timestamp() returns (a builder's definition is
private, so it cannot be read from outside):
import { columnSchema } from '@voltro/database'
columnSchema(notes.fields.createdAt) // ✅ a definition, from table.fields
columnSchema(timestamp()) // ❌ a builder — not readableThe mapping is single-sourced: columnSchema returns the very same
timestampMs value for a timestamp() column, so a derived row schema and a
hand-written descriptor struct can never disagree about the wire shape.
What gets sent on the wire
Queries are streaming RPCs whose elements are subscription events: an initial snapshot followed by deltas. See Wire protocol for the envelope shape. For plain element streams, use Streams.
Anti-patterns
- Putting server-only imports in
*.query.ts. Descriptors are imported by browser-safe codegen. Put database/SDK/filesystem imports in*.query.server.ts. - Mutating from a query executor. Queries are reads. Use a mutation for writes.
- Using a stream for durable data. Streams are transient. Persist rows and expose them through a query when the UI should survive reloads or sync across tabs.
Contradictions refused at declaration
defineQuery({ name: 'q', guards: [], … }) // ✗ enforces nothing
defineQuery({ name: 'q', source: '', … }) // ✗ reactive, subscribed to nothing
defineQuery({ name: 'q', internal: true, overridesPlugin: true, … }) // ✗ removes, replaces nothing
defineQuery({ name: 'q', openAccess: '', … }) // ✗ a marker with no reason
defineQuery({ name: 'q', guards: [{ scope: 'x' }], openAccess: 'open', … }) // ✗ two decisions
defineQuery({ name: 'q', internal: true, openAccess: 'open', … }) // ✗ no wire to decide aboutThe first three are the shapes defineEvent refuses too, for the same reasons — a rule that
holds for one primitive and not another is worse than no rule, because the
answer then depends on which file you happened to open.
guards: [] reads at the call site as if the procedure were protected and
enforces nothing; the check runs only for a non-empty list. Omit the field.
An empty source declares reactivity and subscribes to nothing: the query
serves one snapshot and never updates, which is indistinguishable from "nothing
changed". Worse than a stale source, which the boot warning can at least name
— this one names no table at all, so nothing can report it.
internal: true + overridesPlugin removes the plugin's route and puts
something unreachable in its place: callers get a 404 for something that used to
work, with no diff that says so. Joins the existing refusals of internal with
publicApi or exposeAsTool.
openAccess without a reason is a marker that says nothing. The reason is
what a reviewer reads to decide whether this really should be callable without a
check — openAccess: 'public pricing, no caller data'.
openAccess + guards is two different access decisions at once: the
procedure is protected AND open. Keep the guards if a caller must hold a scope;
drop them if anyone may call it.
openAccess + internal: true decides about a surface that does not exist —
internal takes the procedure off the wire. Drop one of the two.
Every wire-exposed procedure must carry ONE of
guards:/openAccess:/internal: true, or the boot refuses it. See Authorization for the gate and thesecurity.defaultDenyfield that governs it.
Loading vs empty — don't conflate them
useSubscription returns loading and isEmpty alongside data. They are
different states, and branching on data === undefined alone is what causes
a flash of empty-state before the first snapshot:
| State | Meaning | Render |
|---|---|---|
loading |
no snapshot has arrived yet | skeleton |
isEmpty |
snapshot arrived, zero rows (or a null value) | empty state |
| neither | rows present | the list |
loading narrows data
SubscriptionState<T> is a discriminated union on loading, so loading is
not a flag sitting beside data — it is a type guard for it:
type SubscriptionState<T> =
| { loading: true; data: undefined; isEmpty: false }
| { loading: false; data: T; isEmpty: boolean }
// both members also carry revision, emittedAt, error and pendingPatchesOnce you have returned for loading, data is T. No ?? [], no !:
const { data, loading, isEmpty } = useSubscription<Note[]>('app', 'notes.list', {})
if (loading) return <TableSkeleton/>
if (isEmpty) return <EmptyNotes/>
return <NotesTable notes={data}/>A derived constant narrows just as well, as long as loading is part of it:
const { data, loading } = useSubscription<Note[]>('app', 'notes.list', {})
const isLoading = !currentUser || loading
if (isLoading) return <TableSkeleton/>
return <NotesTable notes={data}/> // data is Note[]fallback fills data while loading so a page can render its real (empty) shell
immediately — it never lies about loading:
const { data, loading } = useSubscription('app', 'notes.list', {}, { fallback: [] })
// data is [] before the first snapshot; loading is still trueThat call returns SubscriptionStateWithFallback<T> instead of the union: data
is always present (the fallback stands in until the first snapshot) and loading
is a plain boolean reporting the true state. There is nothing to narrow.
Errors. loading means no data has arrived yet — it is not a claim that
the subscription is healthy. A cold-start failure (nothing ever arrived) is
its own state: loading is false, failed is true, and error is
non-optional there, so branching on loading alone can no longer render a
skeleton forever. (It used to leave loading true, and the type's own comment
predicted the consequence — the fix was to stop making loading mean two
things rather than to keep warning about it.) A
failure AFTER data arrived deliberately does NOT replace good data with an error
banner (a transient websocket hiccup would blank a working screen); those reach
the api's error bus instead — subscribe with useOnRpcError for
connection-level UX.