# Realtime

Quickback ships **two parallel realtime primitives**, both backed by Cloudflare Durable Objects:

- **`/broadcast/v1/*` — server fan-out.** One DO instance per subscription scope. Postgres-changes events from CRUD routes plus custom broadcast events flow out to every subscriber, with role filtering and per-role field masking applied. Best for "tell every client in this shared scope that something changed." The scope can be an organization, a specific user, or a compiler-resolved resource key like `events:evt_123`. The `/broadcast/v1` version segment tracks `contract.routes` — set it to `"v2"` and the same surface mounts at `/broadcast/v2` (no hidden v1 alias). Framing is independent of the path: frames are [CloudEvents 1.0](/platform/realtime/using-realtime#cloudevents-envelopes) envelopes regardless of which prefix you serve them on. Documented on this page. For **typed, targeted "this changed — refresh" signals** fired from an action after commit (with optional per-recipient narrowing), see [Named invalidations](/platform/realtime/named-invalidations).
- **`/realtime/<binding>/<room-id>` — bidirectional rooms.** One DO instance per room (per document, per interview, per game). Client↔client messages, presence, and CRDT-friendly state. Use these for collaborative editing. See [PartyServer rooms](/platform/realtime/partyserver-rooms).

Both share the same auth model and ride on the same compiled worker; they solve different problems.

## Architecture

```
┌──────────────────────────────────────────┐
│            Quickback Worker              │
│                                          │
│  ┌──────────┐    DO binding   ┌────────────────────┐
│  │ Hono API │ ──────────────► │ Broadcaster (DO)   │
│  │          │                 │ WebSocket manager   │
│  └──────────┘                 └─────────┬──────────┘
│                                         │
└─────────────────────────────────────────┼┘
                                          │ WebSocket
                                          │
                                 ┌────────▼─────────┐
                                 │  Browser Clients  │
                                 │   (CMS, Account,  │
                                 │    Admin, Custom)  │
                                 └──────────────────┘
```

The Broadcaster Durable Object runs **inline** in your main Quickback worker — no separate worker deployment needed. The API calls the DO directly via its binding (in-process, no network hop).

1. **Quickback Worker** — Your compiled API with the Broadcaster DO class exported from the same worker.
2. **Broadcaster (Durable Object)** — Manages WebSocket connections per subscription scope. One instance per `scopeKey`, organization, or user lane.
3. **Browser Clients** — CMS, Account, Admin, and custom frontends connect via WebSocket on the same origin.

## Key Features

| Feature | Description |
|---------|-------------|
| Scope-aware fan-out | Routes by `scopeKey`, `organizationId`, or `userId` |
| Role-based filtering | Only send events to users with matching roles |
| Per-role masking | Different users see different field values based on their role |
| User-specific targeting | Send events to a specific user within an org |
| Resource-scoped tickets | Mint scope-bound ws tickets gated by org-membership roles, authz relationship roles, or FGA relations |
| Custom broadcasts | Arbitrary events beyond CRUD |
| Custom namespaces | `defineRealtime()` for type-safe event helpers |
| Ticket-based auth | HMAC-signed tickets verified at WebSocket upgrade — no HTTP round-trip |

## Enabling Realtime

Broadcasting is **opt-in per resource**, exactly like CRUD — a table broadcasts
only if it declares its own `realtime` block. Add `realtime` to individual table
definitions:

```typescript
// quickback/features/applications/applications.ts
import { feature, q } from "@quickback/compiler";

export default feature("applications", {
  columns: {
    id:             q.id(),
    candidateId:    q.text().required(),
    stage:          q.text().required(),
    organizationId: q.scope("organization"),
    ...q.audit(),
    ...q.softDelete(),
  },
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  realtime: {
    enabled: true,
    onInsert: true,
    onUpdate: true,
    onDelete: true,
    // Audience — WHO may receive these live rows. Same { roles } shape as
    // read.access / crud.*.access. MANDATORY and fail-closed: a broadcast
    // reaches every subscriber in the room, so the audience is not optional.
    // Omit it (or leave it empty) and compilation fails. Roles may be concrete
    // membership/scope roles or UPPERCASE pseudo-roles.
    access: { roles: ["recruiter", "hiring-manager"] },
    fields: ["id", "candidateId", "stage"],
  },
});
```

> There is **no implicit fanout**. A resource with a broadcasting `realtime` block
> must declare a non-empty `access.roles`, and an empty audience delivers to *no
> one*. To broadcast to everyone in the room, say so deliberately with
> `access: { roles: ["PUBLIC"] }`. (`requiredRoles` is the deprecated spelling of
> `access.roles` — it still compiles but should be migrated.)


Omitting it fails the compile:

```text
Resource "conversations" enables realtime broadcasts but declares no audience.
A broadcast fans out to every subscriber in the room, so the audience is not
optional. Declare realtime.access: { roles: [...] } naming who may receive it
(use roles: ['PUBLIC'] to broadcast to everyone in the room, deliberately).
```

**The audience is a second read surface, and it does not inherit `read.access`.**
Whatever gates the REST read must gate the broadcast too, or the live channel
publishes rows the REST route would have withheld — private messages being the
usual casualty. Copy the read rule across, and where the read is gated by a
relationship (participants of a conversation, accepted followers), use
`access: { authzRole: "<relationship-role>" }` here rather than falling back to
`roles: ["AUTHENTICATED"]`, which reaches every signed-in subscriber.

And enable the realtime **transport** in your database config. This is a
project-level switch that stands up the Broadcaster Durable Object + the
connection route; it does **not** by itself make any resource broadcast
(that's the per-resource `realtime` block above). The compiler generates the
Durable Object class, helper functions, and wrangler bindings — all within
your main worker.

For most apps — including org-scoped apps like chat, feeds, and dashboards —
the boolean form is all you need. Session-authenticated members subscribe
directly; audiences come from each resource's `realtime.access.roles`:

```typescript
providers: {
  database: defineDatabase("cloudflare-d1", {
    realtime: true,
  }),
}
```

The object form is the **advanced** variant, only needed when you mint
resource-scoped tickets (e.g. an external attendee who may join one event's
channel, or an org member who may join one project's room). The mint gate is
declared with `wsTicket.access`, a discriminated union — the arm names the
authorization vocabulary, so a bare name can never silently mean two things:

```typescript
providers: {
  database: defineDatabase("cloudflare-d1", {
    realtime: {
      wsTicket: {
        // Pick EXACTLY ONE arm:
        access: { roles: ["member+"] },                                    // org-membership roles (hierarchy expands)
        // access: { authzRole: "attendee" },                              // authz relationship role
        // access: { fga: { relation: "viewer", object: "event:{id}" } }, // FGA relation on the scope row
        requestField: "eventId",
        scopeTable: "events",
      },
    },
  }),
}
```

- **`{ roles }` — org-membership roles.** `"member+"` expands via
  `auth.roleHierarchy` (compile error without it). Firewall-consistent: the
  route verifies the caller's org role **and** that the requested `scopeTable`
  row belongs to the caller's *active* organization — an org-A member cannot
  mint a ticket to org B's room. The scope table must therefore carry an
  organization column (compile error otherwise). UPPERCASE pseudo-roles are
  rejected here: a `PUBLIC`/`AUTHENTICATED` ticket factory would hand signed
  room credentials to callers with no org standing.
- **`{ authzRole }` — a declared `authz.roles` relationship role** (`{ via }`).
  The route probes the relationship table against `body.eventId`; the ticket
  carries the relationship role (e.g. `attendee`). The legacy
  `role: "attendee"` string is the back-compat shorthand for this arm and
  compiles identically. Org-membership names (`member`/`admin`/`owner`) are
  still rejected in this arm — spell those `access: { roles: [...] }`.
- **`{ fga }` — an FGA relation.** The route loads the `scopeTable` row through
  its own firewall (tenant bind), derives the object from the template
  (`"event:{id}"` — `{field}` tokens read from the row, the same template
  language as the read path's `access: { fga }`), and requires the caller to
  hold `relation` on it via the same org-scoped check evaluator the read path
  uses. Requires `authz.fga`; the object type and relation are validated
  against the model at compile time.

Whichever arm you pick, `/broadcast/v1/ws-ticket` authenticates the caller,
runs that gate against `body.eventId`, and mints a short-lived ticket scoped to
the resolved `events:<id>` channel. Everything fails closed at compile time:
a bare name that exists in more than one vocabulary is an ambiguity error
demanding the explicit arm, and a missing hierarchy, missing FGA model, or
missing organization column fails the build with an actionable message.

## Pages

- **[Durable Objects Setup](/platform/realtime/durable-objects)** — Broadcaster configuration, wrangler bindings, event formats, masking, and custom namespaces
- **[Using Realtime](/platform/realtime/using-realtime)** — Subscribing to `/broadcast/v1/*`: WebSocket connection, ticket auth, client-side handling
- **[Named Invalidations](/platform/realtime/named-invalidations)** — Typed, targeted "this changed — refresh" signals fired from actions after commit, with optional per-recipient narrowing
- **[Live Views](/platform/realtime/live-views)** — Keep an open view's full join-surface (root + related children) live with hybrid-delta updates and resync-on-reconnect
- **[PartyServer Rooms](/platform/realtime/partyserver-rooms)** — Per-room collab via `/realtime/<binding>/<room-id>`: presence, live notes, room IDs

There are **three declarative emission surfaces** — pick by what did the write:

| The write is… | Emit with | Where |
|---|---|---|
| A CRUD row change | the table's **`realtime`** block (postgres_changes frames) | this page |
| A custom action | **`invalidates:`** on the action | [Named invalidations](/platform/realtime/named-invalidations) |
| An [aggregate changeset](/define/changesets) (`owns`) | **`afterCommit`** on the aggregate root — one CloudEvent after commit | [changesets → afterCommit](/define/changesets#aftercommit--emit-a-realtime-signal-after-the-changeset-commits) |

## CloudEvents + AsyncAPI

Realtime data frames are delivered as CloudEvents 1.0 envelopes (control
frames stay raw), on whatever path `contract.routes` mounts. Match frames on
the `qbframe` extension, never on `type`. See
[Using Realtime](/platform/realtime/using-realtime#cloudevents-envelopes)
for the frame mapping.

The project's event surface — realtime channels, named-invalidation events,
and outbound webhook messages — is published as an AsyncAPI 3.0 document at
`GET /asyncapi.json` (same auth gating as `/openapi.json`), for any project
with realtime or webhooks.

## Realtime emit from actions

Tables that lock auto-CRUD but mutate through custom actions no longer need
hand-written `realtime.insert/update/delete` blocks. Opt the table in:

```typescript
realtime: {
  enabled: true,
  access: { roles: ["member+"] },
  scopeKeyFrom: "events:{eventId}",   // sugar for scopeTable + scopeField
  emitFromActions: true,              // scoped-db writes auto-emit after commit
}
```

Writes to the table through the scoped `db` inside record-bound and bulk
actions emit frames byte-compatible with auto-CRUD (same envelope, masking,
audience), after commit, best-effort. For projections, standalone actions, or
cross-feature targets, declare an action-level `emit:` to get a pre-bound
`emitCrud(row, oldRow?)` helper. Full details and the capture caveats live in
the [realtime docs](/platform/realtime/durable-objects).

