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



---

<!-- source: en/plugins/broadcast.md -->
## 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](/docs/database/multi-replica). This page documents the plugin itself.

## Wiring

```ts
// 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_URL` → `BROADCAST_REDIS_URL` (this connection's own) → the shared `REDIS_URL`. A `redis://` / `rediss://` URL selects Redis; a `nats://` URL selects NATS.

```ts
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_URL` → `REDIS_URL` convention as the [cache, KV and rate limiter](/docs/caching/key-value) — 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.

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.

```ts
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.

<Callout type="warn">
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.
</Callout>

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

```sh
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.
