Quickback Docs

Realtime

Real-time updates via WebSockets with Cloudflare Durable Objects.

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 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.
  • /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.

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

FeatureDescription
Scope-aware fan-outRoutes by scopeKey, organizationId, or userId
Role-based filteringOnly send events to users with matching roles
Per-role maskingDifferent users see different field values based on their role
User-specific targetingSend events to a specific user within an org
Resource-scoped ticketsMint scope-bound ws tickets gated by org-membership roles, authz relationship roles, or FGA relations
Custom broadcastsArbitrary events beyond CRUD
Custom namespacesdefineRealtime() for type-safe event helpers
Ticket-based authHMAC-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:

// 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:

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:

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:

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 — Broadcaster configuration, wrangler bindings, event formats, masking, and custom namespaces
  • Using Realtime — Subscribing to /broadcast/v1/*: WebSocket connection, ticket auth, client-side handling
  • Named Invalidations — Typed, targeted "this changed — refresh" signals fired from actions after commit, with optional per-recipient narrowing
  • Live Views — Keep an open view's full join-surface (root + related children) live with hybrid-delta updates and resync-on-reconnect
  • 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 withWhere
A CRUD row changethe table's realtime block (postgres_changes frames)this page
A custom actioninvalidates: on the actionNamed invalidations
An aggregate changeset (owns)afterCommit on the aggregate root — one CloudEvent after commitchangesets → afterCommit

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 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:

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.

On this page