Broadcast

Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another.

@voltro/plugin-broadcast closes the cross-replica reactivity gap for dialects that have no native cross-instance change feed. Postgres fans out natively via LISTEN/NOTIFY and mariadb via binlog CDC; every other dialect (mysql / mssql / cockroach / planetscale / azure) emits change events in-process on the writing instance only. Without a bus, a write on one pod never wakes a subscription on another pod.

Status: ✓ shipped.

For the full cross-replica reactivity story — when you need it, the dialect matrix, and the boot-log signals — see Database → Multi-replica. This page documents the plugin itself.

Wiring

// app.config.ts
import { broadcastPlugin } from '@voltro/plugin-broadcast'

export default {
  type: 'api' as const,
  name: 'api',
  plugins: [broadcastPlugin()],
}

Broadcast is opt-in — it never turns on from a shared REDIS_URL alone. The bus enables only on an explicit signal: BROADCAST_URL, BROADCAST_REDIS_URL, BROADCAST_PROVIDER, or the connection / provider / url option. So a deployment running the cache on REDIS_URL keeps broadcast on the single-process memory bus until you deliberately switch it on. This differs from ctx.cache / ctx.kv, which are gated by their own CACHE_BACKEND / KV_BACKEND selectors — same principle, explicit per concern.

Once enabled on redis, the connection resolves own → shared: an explicit url / broker-agnostic BROADCAST_URLBROADCAST_REDIS_URL (this connection's own) → the shared REDIS_URL. A redis:// / rediss:// URL selects Redis; a nats:// URL selects NATS.

broadcastPlugin({ connection: 'broadcast' }) // opt in + share the app's redis: BROADCAST_REDIS_URL → REDIS_URL
broadcastPlugin({ provider: 'redis', url: process.env.BROADCAST_URL }) // or an explicit url / provider

The connection option follows the same <NAME>_REDIS_URLREDIS_URL convention as the cache, KV and rate limiter — but setting it (or another explicit signal) is the deliberate opt-in; the shared REDIS_URL is only the connection detail. NATS stays broker-agnostic via BROADCAST_URL.

BroadcastPluginOptions: provider ('redis' | 'nats' | 'memory' or a BroadcastProvider), url, connection (named redis connection, default 'broadcast'), and name (disambiguates multiple instances of the plugin in one app — the second instance's name becomes @voltro/plugin-broadcast#<name>).

How it works

The plugin is mostly a carrier: it resolves a BroadcastProvider at construction and exposes it so the serve pipeline can attach the bus to the live DataStore after the store is built (the bus needs the store's onChange + injectExternalChange seam, which doesn't exist at plugin-activation time).

The bus is additive to the inline emit path:

  • It publishes { origin, event } on the <namespace>:changes channel.
  • It injects remote events into every other replica's store, skipping its own origin so there's no double-emit.
  • A broker outage degrades cross-replica fan-out only — local reactivity keeps working, including a broker that is down at boot. The replica starts, serves, and joins the bus when the broker returns; the time it spent unsubscribed is then reported as a gap and every live query re-runs. A crash loop across the fleet is the wrong answer to a broker restart, which is exactly the moment every replica is dialling at once.
  • A restarted peer is recognised as a new process, not the same one continuing. Each publish carries a per-process epoch, because a replica id survives a restart (POD_NAME on a StatefulSet, VOLTRO_REPLICA_ID by definition) and a serial does not.

The plugin declares the network:outbound:* permission. The boot banner names the resolved tier (cross-instance via redis/nats, or off for the dialect when no broker is configured).

A dropped message cannot leave a client stale

Pub/sub has no retention. If a replica's broker connection blips it simply never learns that a change happened — and its clients keep their sockets, so the client-side reconnect never fires and their live queries never re-run. They would show stale rows until something else touched the same table, which on a quiet table can be never.

Every change carries a per-origin serial, so a receiving replica can tell exactly how many it missed — a count, not an estimate. On a gap it re-runs every live subscription.

That works because a live query is idempotent: re-running one always lands on the truth, so a proven loss is repaired by refreshing rather than by replaying something nobody kept. The refresh goes through each subscription's own descriptor, so guards, row filters and tenant predicates apply unchanged — and it is a re-query, not a push: if the snapshot has not moved, the subscriber sees nothing.

A replica that just started reports no gap however high a peer's serial is. It missed nothing; it was not there.

Nothing to configure. It follows from having a broker.

Sharing one broker between apps — the namespace

Every framework channel on the broker hangs off one namespace: <ns>:changes, <ns>:events:<name>, <ns>:members, <ns>:presence.

It defaults to your app's name, so two different apps pointed at one Redis or NATS separate on their own — nothing to configure, and nothing to forget.

broadcast({ provider: 'redis', namespace: 'shop-prod' })

Set it explicitly for the one case the default cannot see: several deployments of the same app on one broker. Staging and production share a name, share the code and share every fingerprint, so nothing derivable tells them apart. There, this option — or VOLTRO_BROADCAST_NAMESPACE — is the only thing that works.

If you are upgrading and used `broadcast({ channel })`, note what the codemod tells you: **that option never took effect.** It was declared, and documented as the fix for exactly this, and nothing read it — your deployments were sharing channels regardless of what you set. `voltro update` rewrites it to `namespace` and strips the trailing `:changes`, since the framework appends the channel kind itself.

Resolution order: broadcast({ namespace })VOLTRO_BROADCAST_NAMESPACE → your app's name. The value is lowercased and reduced to a-z0-9_-, which closes three things NATS punishes (measured against nats:2 — Redis is indifferent):

In a name What NATS does
a . beside a > (shop.>) matches shop.other — wildcards are token-level, and tokens are dot-separated
a name that IS > or * matches every subject on the server
whitespace (My App) rejects the subject outright — the app receives nothing at all

A wildcard inside a token is harmless (shop>:changes does not match other:changes), so the dangerous inputs are narrower than they look — and a name that reduces to nothing falls through to the next candidate rather than becoming an empty prefix.

The namespace is a broker subject — and it is normalised

VOLTRO_BROADCAST_NAMESPACE="prod env"   # → resolves to "prod-env"
VOLTRO_BROADCAST_NAMESPACE="prod.env"   # → ALSO "prod-env" — same channel

Whitespace, dots and wildcards are folded to - and the result is lowercased, because the value becomes a broker subject: NATS refuses a subject containing whitespace outright and delivers nothing, with no error on the publishing side.

Two differently-configured deployments can therefore collapse onto one channel — which is what this option exists to prevent. Nothing refuses (the resolved value is safe either way, and failing a boot over a dot would be worse than the collapse), but the boot logs the substitution whenever it changes what you wrote. If you see it, check that the RESOLVED names differ, not the ones you typed.

Caveat — app-mutation changes only

The bus carries changes written through ctx.store (app mutations). It does not capture out-of-band DB writes (a psql session, another service) — only postgres LISTEN/NOTIFY and mariadb binlog observe those. Without a broker URL the plugin falls back to the in-process memory bus (single-process only) and warns at boot.