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 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', /* ... */ })
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)
  • 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.

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