Subscriptions

How reactive query subscriptions stay live over WebSocket.

A subscription is what the browser gets when it calls useSubscription(...) for a defineQuery RPC. The app code writes a query pair; the runtime keeps that query live over WebSocket and pushes new snapshots or deltas when matching data changes.

Subscriptions are not a separate file convention anymore. The file convention is queries: *.query.ts for the descriptor and *.query.server.ts for the executor.

Live — add a todo (or open this page in a second tab) and the list updates with no refetch; the <DataTable> is a subscription under the hood:

<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" />   {/* live subscription */}

Lifecycle

const { data } = useSubscription('app', 'notes.list', { archived: false })
  1. The client opens or reuses the API WebSocket.
  2. It sends the query tag and input.
  3. The runtime runs notes.list.query.server.ts.
  4. The first value is delivered as a snapshot.
  5. Later mutations emit change events when their transaction commits.
  6. Matching query subscribers receive the updated value.

From React, data just changes. There is no refetch call.

Query Descriptor

// 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({ archived: Schema.Boolean }),
  output: Schema.Array(Schema.Struct({
    id:    Schema.String,
    title: Schema.String,
  })),
})

source declares which table re-runs computed queries and lets mutations with matching target metadata patch the client cache optimistically.

Query Executor

// apps/api/queries/notes.list.query.server.ts
import { eq } from '@voltro/database'
import { database } from '../database/schema'

export default (input: { archived: boolean }) =>
  database.notes.where(eq('archived', input.archived)).orderBy('createdAt', 'desc')

Descriptor-returning executors get fine-grained row matching. Computed-return executors can return arrays, objects, scalars, or null; they should declare source so the runtime knows what table should re-run them.

skip

Use { skip } when the input is not ready yet:

const { data } = useSubscription(
  'app',
  'messages.list',
  { channelId },
  { skip: channelId === undefined },
)

While skipped, no WebSocket subscription opens and data stays undefined.

Skipped is idle, not loading

A skipped subscription reports idle: true, loading: false. The two are different questions — "waiting for the first snapshot" and "not asking at all" — and conflating them breaks the pattern this hook otherwise blesses:

// WRONG on a skipping call site — renders a skeleton for a query you switched off
if (loading) return <Skeleton/>

Passing a dynamic skip therefore changes the return type: you get a third state, and TypeScript will not let you ignore it.

const s = useSubscription<Team[]>('app', 'teams.list', {}, { skip: !open })

if (s.idle)    return null          // deliberately not asking
if (s.loading) return <Skeleton/>   // asking, no answer yet
return <TeamsTable teams={s.data}/> // `data` is Team[] — narrowed

Call sites that never skip are untouched — if (loading) still proves data is present there, and a literal { skip: false } counts as never skipping. The third state exists only where "not asking" is a real outcome.

With a fallback there is nothing to narrow either way: data is always present, and idle tells you whether what is on screen is the fallback because you chose not to ask.

A subscription that was live and is then skipped goes idle — it does not keep serving the snapshot it still holds. Otherwise skip: !open would show last time's data the moment a dialog reopens.

Streams Are Different

For non-database or transient element feeds, use streams, not subscriptions:

const ticker = useAgentStream('app', 'ticker.watch')
ticker.start({ symbol: 'BTC' })

Queries/subscriptions are for live state. Streams are for one-shot element flows such as tokens, progress events, and import logs.

Reconnect

On reconnect, the client re-subscribes to active queries and receives a fresh snapshot. Optimistic patches are client-local and are reverted when their mutation settles.

Tenant Scoping

Tables with the tenant() mixin are scoped by the runtime using ctx.request.subject.tenantId. Do not add duplicate tenant predicates in query executors unless you are deliberately narrowing further.

Inspect

The devtools subscription surfaces show active subscribers, recent deltas, and cache state. Use them when a query updates too often or not at all.

See also

  • Subscribers (*.subscribe.ts) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
  • Streams — transient, non-database element feeds (no snapshot/reconnect replay).