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 of guards:, openAccess: '<reason>' or internal: 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 outputdefineExecutor

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 value defineExecutor returns is typed as the ExecutorReturn UNION (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 raw Effect separately (a named export const execute = …, or import the un-wrapped function) and compose THAT; keep the defineExecutor-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 check source: against. The descriptor stays yours either way: crud.* supplies only the executor, you write the source: beside it. crud.count counts as a read too — it returns a number rather than rows, but an insert changes that number, so the counted table belongs in source: or "page 3 of 12" stops moving. crud.create / update / remove issue 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 in source: 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)
  • timestampMsDate in 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) is 1970-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 readable

The 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 about

The 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 the security.defaultDeny field 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 pendingPatches

Once 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 true

That 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.