Wire protocol

What's on the WebSocket — @effect/rpc over JSON, the snapshot/delta subscription envelope, and the POST /rpc one-shot path.

Voltro's client and api speak @effect/rpc over a WebSocket. Every primitive — queries, mutations, actions, agents, subscriptions — rides the one bidirectional connection, multiplexed by the rpc layer. The serialization is JSON (RpcSerialization.layerJson), not a bespoke binary format.

You usually don't think about the wire — the typed client + @voltro/protocol handle it end-to-end. This page is for when you DO need to: debugging a mystery, proxying through a gateway, or understanding what the inspect tooling shows you.

The transport

The api boots two rpc server instances on the same HttpLayerRouter:

  • WebSocketRpcServer.layerProtocolWebsocketRouter({ path }), the primary transport. Long-lived; carries streaming queries (subscriptions), unary mutations/actions, and agent runs.
  • HTTP one-shotRpcServer.layerProtocolHttpRouter({ path: '/rpc' }), registered at POST /rpc. Non-streaming; one request → one batched response.

Both are provided RpcSerialization.layerJson and the SAME rpc group (options.group), so the SAME per-rpc handlers + the group-level AuthMiddleware / ConnectionInfoMiddleware run on either path. A forwarded session cookie resolves the same Subject + tenant whether the call arrives over WS or HTTP.

Because @effect/rpc owns the framing, there's no app-level frame taxonomy to learn — the rpc client encodes a request, the server decodes it, runs the handler, and streams back the result. What's app-specific is the payload schema of each rpc (your defineQuery / defineMutation input + output) and, for streaming queries, the subscription-event envelope below.

Subscription events — snapshot / delta

A streaming query (what useSubscription opens) emits a sequence of subscription events. The envelope is defined in @voltro/protocol's subscriptionEvent(output) — a Schema.Union of three variants, parameterised on the query's declared output schema so the rpc layer enforces the row shape end-to-end:

// snapshot — always the FIRST event; the full initial query result
{ _tag: 'snapshot', revision: number, data: <output> }

// delta — every subsequent event; an id-keyed JSON-patch, NOT full data
{
  _tag: 'delta'
  revision: number
  emittedAt: number
  patch: {
    ops: Array<
      | { op: 'add';     path: '/<id>'; value: <row> }
      | { op: 'replace'; path: '/<id>'; value: <row> }
      | { op: 'remove';  path: '/<id>' }
    >
    order: Array<id>   // the full id sequence of the next set, in order
  }
}

// error — this ONE subscription's handler failed; surfaced in-band on its own
// stream (never a defect that would stall siblings on the shared connection)
{ _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }
  • revision — monotonically increasing; lets the client order events. Revisions may JUMP forward — under socket backpressure the server coalesces updates a slow consumer hasn't read yet into one event whose patch is computed against the row set of the last event it actually handed over, so patch continuity holds across the jump. A jump is therefore normal, never a gap; only a regressing or repeating revision would be a protocol violation.
  • emittedAt — epoch milliseconds, present on delta only.
  • data (snapshot) — the full payload, typed by the query's output schema (a row, an array of rows, a computed value — whatever the handler returns).
  • patch (delta) — an id-keyed RFC-6902-style patch against the row set the client last held.
  • error — a JSON-safe shape of a typed error (its _tag + fields + message preserved, so the client can pattern-match error._tag). useSubscription exposes it as .error for THAT query key.

The first event is always a snapshot carrying the full initial result — the client materialises it as its base. Subsequent events are deltas carrying only the rows that changed, plus the next id order. The server still re-runs the query on a change (the patch saves wire egress, not the re-query); it then diffs the previous row set against the new one into the patch.

Per-subscription errors — isolated, not connection-wide

Many subscriptions multiplex over ONE WebSocket. If a single subscription's handler fails (e.g. a live single-row getter that throws for a stale/foreign id), the server emits an error event on THAT subscription's own stream and completes it — it does not let the failure become a defect, which would propagate to the shared connection and stall every other subscription on it (the classic "one not-found and the whole dashboard hangs on loading"). The client surfaces it as useSubscription(...).error for that one query key; siblings keep delivering their snapshots and deltas.

Slow consumers — coalescing and SubscriptionOverrun

A consumer that stops reading (a backgrounded tab, a saturated link) does not grow the server without bound. While its socket is blocked, updates coalesce: the server keeps only the newest state per subscription and, when the socket accepts again, sends ONE event — a patch against the last state the consumer was actually handed (revision jumps accordingly, see above). Memory per blocked subscription is bounded by construction: one pending state, regardless of how far behind the consumer is.

A consumer that stays more than reactive.socket.maxBufferedBytes (default 1 MiB, env VOLTRO_REACTIVE_MAX_BUFFERED_BYTES) behind for reactive.socket.overrunAfterMs (default 10 s) is closed loudly: it receives an error event with error._tag: 'SubscriptionOverrun' (carrying bufferedBytes + maxBufferedBytes) and the stream ends — never a silent drop. The client re-subscribes and starts from a fresh snapshot.

Oversized events are telemetry, not a cap: an event over reactive.socket.oversizedEventBytes (default 256 KiB) is delivered normally and counted (voltro_subscription_oversized_total) with a WARN naming the query — alongside voltro_subscription_buffered_bytes, voltro_subscription_coalesced_total and voltro_subscription_overrun_total in the Prometheus exporter and the inspect Metrics panel.

Reconnect — delta-resume

A client that reconnects inside the resume window does not have to pay for a full snapshot: it sends the last revision it materialised in the per-call voltro-resume-from request header (the same header surface the idempotency key rides), and the server — which kept the subscription alive server-side for the window after the disconnect — replays only the deltas that were missed and re-attaches the stream on the SAME revision line.

The signal is the first event's tag, not a schema field:

  • first event delta — the resume was honoured; apply the patch onto the rows you already hold and continue.
  • first event snapshot — the resume was declined; reset to the snapshot. This is the answer whenever anything is in doubt, because a wrong snapshot costs bytes while a wrong replay would leak rows.

@voltro/client does both automatically — the reconnect-seeded cache keeps its rows and revision, presents the header, and treats a snapshot-first stream as the reset it already knows how to do. Replayed deltas may coalesce exactly as slow-consumer updates do (revisions jump; patch continuity holds).

A resume is declined — always with a fresh snapshot — when:

  • the window expired (reactive.resume.windowMs, default 60 s, env VOLTRO_REACTIVE_RESUME_WINDOW_MS), or more deltas were missed than the ring retains (reactive.resume.maxDeltas, default 256, env VOLTRO_REACTIVE_RESUME_MAX_DELTAS);

  • the query's guards: were revoked while the client was away — the per-delivery re-check keeps running on the detached subscription, and a revocation drops the retained history outright;

  • the resuming caller is a different subject or tenant (a login, logout or tenant switch between disconnect and resume) — the retained history is keyed by subject AND tenant, so a changed identity simply never finds it;

  • the query is a computed query — it re-runs a handler, so there is no delta chain to replay;

  • a registered row filter (setRowFilter) can narrow THIS subscription's source table, or the query declares an eager .with(...). A row-filtered subscription's visible row set exists only per delivery, so replaying it could serve rows the subject has since lost.

    This is per table, not per app. A filter that declares tables: [...] (see row-level security) keeps delta-resume on every subscription whose source is not in that set — the common case, since most filters narrow a handful of tables. Without the declaration the framework cannot know which tables the predicate may reach and excludes them all. Eager loads are excluded wholesale because a relation resolves below the seam that narrows. They reconnect with a fresh snapshot, exactly as before.

Which of your queries actually got a ring is recorded per label, since the excluded and the never-eligible look identical on the wire: /_voltro/inspect/subscriptions returns a resume array of { label, resumable, excluded }, and voltro dev logs each verdict once under the voltro:resume scope. A computed verdict is the one worth reading first: it means the executor returns a value rather than a descriptor, so no row-filter declaration can ever change it.

Author a live-subscribed getter to return, not throw. A subscription is a long-lived stream, so a getter that throws on every re-evaluation is a broken stream. For an expected-absent row, make the query output: Schema.NullOr(...) and return null — that's a normal snapshot the widget renders as "empty", cleaner than an error banner. Reserve throwing for genuinely exceptional cases; even then it's now contained to the one subscription.

How the patch is keyed — by row id, not array index

The diff keys on each row's id, addressing it as path: '/<id>', rather than by array index. Index-based paths are brittle the moment a row moves — and reordering is the common case for the live result sets this targets (leaderboards, collaborative lists, game state). Id-keying makes a reshuffle cost just the changed rows plus the id list:

  • replace — a row present in both prev and next whose content changed; value is the full new row.
  • add — a row new in next; value is it.
  • remove — a row gone from next.
  • reorder — carried entirely by order (the next id sequence); a pure reorder emits zero ops and just a new order.

The client keeps the last materialised row set, applies the ops to an id→row map, then materialises the result strictly in order. The round-trip is exact: applying the computed patch to the previous set reproduces the new set for every case — add, remove, replace, reorder, and combinations.

No-op suppression still applies. When a change re-runs the query but the resolved row set is unchanged, no event is emitted at all — the patch path covers "a small part of a big result changed", the suppression covers "nothing changed".

Fallbacks. A result set whose rows aren't id-keyed (a custom projection that drops id) can't be diffed by id, so its update ships as a full snapshot instead. Computed queries (a handler that returns a scalar or aggregate, not an id-keyed row set) likewise ship every update as a snapshot — there's no row set to patch.

HTTP one-shot rpc (POST /rpc)

The WebSocket is the primary transport, but the api ALSO exposes POST /rpc for non-streaming invokes. It speaks the same JSON envelope and runs through the SAME per-rpc handlers + the same auth middleware — a forwarded session cookie resolves the same Subject + tenant as the WS path.

This is what a server-side web-router loader's ctx.query uses: a loader runs inside the request handler with no WS connection, so it calls the backend over HTTP for SSR first-paint + meta. The HTTP protocol drains a streaming query handler's stream to completion and returns it, so a streaming query yields its FIRST (initial) snapshot in the response batch — exactly what first-paint needs. See Loaders & meta.

For live, after-hydration data, subscribe over the WebSocket with useSubscription instead — POST /rpc is one-shot and never streams deltas.

Authentication

The session cookie travels in the WebSocket upgrade headers (and in the POST /rpc request headers). The api's AuthMiddleware resolver decodes it and binds the resolved Subject to the connection; subsequent rpc calls on that connection inherit it.

For API-key authentication, send Authorization: Bearer <key> in the upgrade (or request) headers — the same resolver path maps it to a subject.type === 'apiKey'.

Debugging

Use the framework's own tooling rather than reading raw frames:

  • voltro traces — the per-request span waterfall, including each subscription snapshot / delta delivery with its produce→push latency. voltro traces --errors filters to failed hops.
  • voltro logs --trace <id> — every log line of one request across hops, in order.
  • The inspect dashboard's stream firehose (GET /_voltro/inspect/stream) surfaces rpc / cdc / log events live.

Anti-patterns

  • Hand-crafting rpc frames. Use @voltro/client@effect/rpc owns the framing and the protocol can change.
  • Long-polling fallbacks. None — Voltro is WS-or-bust for live data. Browsers without WebSocket support don't get reactive updates (one-shot reads still work over POST /rpc).
  • Proxying through a CDN without WebSocket support. Cloudflare / Fastly / Vercel Edge all support it; configure the upgrade headers.