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',
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.
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',
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,
})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',
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', /* ... */ })
defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' }, /* ... */ })With that pairing, useMutation('app', 'notes.create') can stage an optimistic row in active notes.list caches without client-side cache plumbing.
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',
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/databaseSo 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.
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)
leaves loading true and sets error, so a component that branches on
loading alone renders a skeleton forever; check error to break out of 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.