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.version — contract-v2 projects mount the same surface at /broadcast/v2 (no hidden v1 alias). 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.)

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 (contract v2) + AsyncAPI (any contract)

On the v2 contract, realtime data frames are delivered as CloudEvents 1.0 envelopes on /broadcast/v2 (control frames stay raw). 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). The document is contract-version-independent: it is emitted for any project with realtime or webhooks (v1 included); only the message payload schemas are shaped by the pinned contract — raw frames on v1, CloudEvents envelopes on v2.

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