# Durable Objects Setup

Quickback can generate realtime notification helpers for broadcasting changes to connected clients. This enables live updates via WebSocket using Cloudflare Durable Objects.

The targeting model is shared across ws tickets, websocket attachment, and server-side broadcasts:

- string target: historical `organizationId` shorthand
- object target: `{ scopeKey?, organizationId?, userId? }`

That means the same mental model drives both `createRealtime()` and the generated Broadcaster filter path.

## Enabling Realtime

Add `realtime` configuration to individual table definitions:

```typescript
// features/applications/applications.ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";

export const applications = sqliteTable("applications", {
  id: text("id").primaryKey(),
  candidateId: text("candidate_id").notNull(),
  jobId: text("job_id").notNull(),
  stage: text("stage").notNull(),
  organizationId: text("organization_id").notNull(),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text("created_by"),
  modifiedBy: text("modified_by"),
  deletedAt: text("deleted_at"),
  deletedBy: text("deleted_by"),
});

export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  realtime: {
    enabled: true,           // Enable realtime broadcasts for this table
    onInsert: true,          // Broadcast on INSERT (default: true)
    onUpdate: true,          // Broadcast on UPDATE (default: true)
    onDelete: true,          // Broadcast on DELETE (default: true)
    access: { roles: ["recruiter", "hiring-manager"] }, // Audience — MANDATORY, fail-closed
    fields: ["id", "candidateId", "stage"],   // Fields to include (optional)
    scopeKeyFrom: "events:{eventId}",         // Sugar for scopeTable + scopeField (optional)
    emitFromActions: true,                    // Custom-action writes auto-emit after commit (optional)
  },
  // ... guards, crud
});
```

`scopeKeyFrom` routes broadcasts to a resource lane instead of the default
org room — the template form (`"events:{eventId}"`) names the prefix and the
row column in one string; a bare column name inherits the prefix from
`scopeTable` (or the project-level ws-ticket default). `emitFromActions`
extends the same broadcasts to writes made through the scoped `db` inside
custom record-bound and bulk actions — see the boundary table below. It
triggers the same mandatory-audience check auto-CRUD broadcasts have.

When any table has realtime enabled, the compiler generates `src/lib/realtime.ts` with helper functions for sending notifications.

At the project level, `providers.database.config.realtime` can stay boolean or become object-shaped when you need resource-scoped tickets. The mint gate accepts three authorization vocabularies via the discriminated `access` form — pick exactly one arm:

```typescript
providers: {
  database: defineDatabase("cloudflare-d1", {
    realtime: {
      wsTicket: {
        access: { authzRole: "attendee" },   // authz relationship role (legacy shorthand: role: "attendee")
        // access: { roles: ["member+"] },   // org-membership roles — verifies the org role AND that the
        //                                   // scope row is in the caller's ACTIVE org (org column required)
        // access: { fga: { relation: "viewer", object: "event:{id}" } }, // FGA relation on the scope row —
        //                                   // same evaluator + object template as read-path access:{ fga }
        requestField: "eventId",
        scopeTable: "events",
      },
    },
  }),
}
```

That tells `/broadcast/v1/ws-ticket` (the broadcast surface tracks `contract.routes`, so `/broadcast/v2/ws-ticket` when that is `"v2"`) to authenticate with Better Auth, run the declared gate against `body.eventId`, and mint a short-lived ticket scoped to the resolved `events:<id>` lane. See [Realtime → Enabling Realtime](/platform/realtime#enabling-realtime) for the full per-arm semantics; all three forms fail closed at compile time (ambiguous names living in two vocabularies, missing `auth.roleHierarchy` for `+`, missing `authz.fga`, unknown FGA type/relation, missing organization column, non-tenant-scoped `scopeTable`).

### Namespace-backed tickets (per-matched-lane)

The single-`role` form above authorizes the ticket through **one** relationship.
When the room is gated by an [authz namespace](/define/scopes#mintscope-mint-the-scope-token-from-the-namespace-resolver)
with a **multi-lane `via:`** — e.g. an event reachable by attendees, organizers,
contractors, *and* org admins — point the ticket at the namespace instead, and it
authorizes through the namespace's full `via` (per-matched-lane):

```typescript
realtime: {
  wsTicket: {
    namespace: "eventScope",   // authorize via this namespace's full multi-lane `via`
    requestField: "eventId",
    scopeTable: "events",
  },
},
```

`/broadcast/v1/ws-ticket` now runs the **same first-match-wins gate** the
namespace uses, and issues the ticket for whichever lane matched:

| Caller matched via | Ticket role |
|---|---|
| `attendeeOf` (relationship lane) | `attendee` |
| `organizerOf` | `organizer` |
| `contractorOf` | `contractor` |
| `{ roles: ["admin","owner"] }` (bulk lane) | the caller's **org/membership roles** — no scope role |

This closes the gap where the single-`role` form denied (`403`) an org admin who
could legitimately reach the room through the bulk lane but isn't a confirmed
attendee — their realtime socket now opens.

`wsTicket.namespace` may also name a namespace declared by a
[feature area](/define/areas) (`_area.ts`). This is the one
deliberate exception to the rule that root config never references area-declared
names: namespace-identity wiring attaches the area's **complete** gate (its full
lane set) and constructs no new grant, so it cannot widen what the area declared.
Such consumers are listed in `quickback/authz-manifest.json` under the owning area.

> **`scopeTable` must be tenant-scoped.** Before minting, the route loads the
> `scopeTable` row by `requestField` through *its own firewall* — so a ticket can
> only target a room whose resource resolves under the caller's tenant (an org-A
> admin requesting an org-B `eventId` gets `403`, not a cross-tenant socket). Because
> the room *is* the data — there is no firewall between a ticket and a Durable
> Object room — the compiler **rejects at build time** a namespace-backed or
> FGA-gated `wsTicket` whose `scopeTable` has `firewall: { exception: true }` or a
> non-tenant (literal-only) firewall with no org/owner/team isolation. (The
> org-membership form binds by the organization column directly, so it requires
> that column instead.) Use a tenant-scoped `scopeTable`.


## When auto-broadcast fires (and when you call it manually)

Quickback emits auto-broadcasts in **only one place**: the generated CRUD route handlers (`POST /` create, `PATCH /:id` update, `DELETE /:id` delete) for resources that **explicitly declare** a `realtime.enabled` block. The project-level `dbConfig.realtime` switch wires the *transport* (Broadcaster DO + ws-ticket route) but does **not** auto-enable broadcasting on any resource — broadcasting is opt-in per resource, like CRUD. Those handlers wrap each successful write with a `realtime.insert / update / delete` call before responding.

Auto-broadcasts carry the table's declared gates: the `realtime.access.roles`
audience (or the deprecated `requiredRoles` alias) becomes the broadcast's
`targetRoles` (with role-hierarchy `+` expanded and UPPERCASE pseudo-roles like
`PUBLIC`/`USER` evaluated at fanout exactly as REST evaluates them), and the
table's effective masking config —
declared plus auto-detected sensitive columns — is applied per subscriber
role at fanout, matching what the HTTP routes mask. A `type: 'custom'` mask
function can't be replicated by the Broadcaster, so those columns broadcast
as `[REDACTED]` (fail closed). Tables that declare neither broadcast openly,
as before.

Everywhere else — action handlers, queue consumers, scheduled jobs, custom Hono routes — you broadcast manually **unless the table opts in to action emission** (see below). In action handlers on realtime-enabled projects, Quickback injects a typed `realtime` helper directly into `execute(...)`; outside actions you still call `createRealtime(env)` yourself.

| Mutation site | Auto-broadcast? | Why |
|---|---|---|
| Generated CRUD route (`POST /`, `PATCH /:id`, `DELETE /:id`) | Yes | The compiler emits the call after the write returns. |
| Generated batch route (`/batch/...`) | Yes | Same pattern, emitted per record. |
| `db.insert/update/delete` inside a record-bound action, table declares `realtime.emitFromActions: true` | Yes — after commit | The scoped db records each write into a per-request capture collector (recording only — it never calls the transport itself); the route emits identical auto-CRUD frames after `execute` succeeds, via the after-commit effects machinery. |
| `db.insert/update/delete` inside an action handler (no `emitFromActions`) | No — manual | Scoped-db stays a query-shape wrapper (firewall + soft-delete WHERE injection) with no broadcast side-effects. |
| `unsafeDb` writes in actions (or any custom route) | No — manual | `unsafeDb` is the un-wrapped escape hatch — invisible to capture too. |
| Queue consumers, cron jobs, webhook handlers | No — manual | These run outside the CRUD path entirely. |

The rule of thumb: opt the table into `realtime: { emitFromActions: true }` (or give the action an `emit:` block for the pre-bound `emitCrud(row, oldRow?)` helper) and the frames take care of themselves; otherwise, if your code calls `db.insert(...)` directly, you also call `realtime.insert(...)` directly on the next line.

Two capture caveats: inserts that skip `.returning()` emit the values as written (DB-default columns absent), and updates/deletes cost one extra pre-select on the same WHERE — it replaces the hand-written read every manual emit needed anyway. Capture covers the feature's own table on record-bound and bulk routes; standalone actions and cross-feature writes use `emit:`/`emitCrud` or the raw `realtime.*` API.

## Generated Helper

Action handlers on realtime-enabled projects receive a typed `realtime` param. The underlying `createRealtime()` factory still powers it and remains the API to use from queues, cron jobs, and other non-action code:

```typescript
export const execute: ActionExecutor = async ({ db, ctx, input, realtime }) => {
  // After creating a record
  await realtime.insert('applications', newApplication, ctx.activeOrgId!);

  // After updating a record
  await realtime.update('applications', newApplication, oldApplication, ctx.activeOrgId!);

  // After deleting a record
  await realtime.delete('applications', { id: applicationId }, ctx.activeOrgId!);

  return { success: true };
};
```

`env` is also typed in action handlers, so custom bindings like R2, D1, Queues, Durable Objects, and auth env vars no longer need `(env as any)` casts.

For explicit targeting, pass an object instead of the historical org string:

```typescript
await realtime.broadcast("event-page-updated", payload, {
  scopeKey: `events:${input.eventId}`,
});

await realtime.broadcast("dm", payload, {
  userId: recipientId,
});
```

## Event Format

Quickback broadcasts events in a structured JSON format for easy client-side handling.

### Insert Event

```typescript
await realtime.insert('applications', {
  id: 'app_123',
  candidateId: 'cnd_456',
  stage: 'applied'
}, ctx.activeOrgId!);
```

Broadcasts:
```json
{
  "specversion": "1.0",
  "id": "evt_…",
  "source": "/broadcast/v1/websocket",
  "type": "dev.quickback.myapp.applications.insert",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "postgres_changes",
  "data": {
    "table": "applications",
    "schema": "public",
    "eventType": "INSERT",
    "new": { "id": "app_123", "candidateId": "cnd_456", "stage": "applied" },
    "old": null
  }
}
```

### Update Event

```typescript
await realtime.update('applications',
  { id: 'app_123', stage: 'interview' },  // new
  { id: 'app_123', stage: 'screening' },  // old
  ctx.activeOrgId!
);
```

Broadcasts:
```json
{
  "specversion": "1.0",
  "id": "evt_…",
  "source": "/broadcast/v1/websocket",
  "type": "dev.quickback.myapp.applications.update",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "postgres_changes",
  "data": {
    "table": "applications",
    "schema": "public",
    "eventType": "UPDATE",
    "new": { "id": "app_123", "stage": "interview" },
    "old": { "id": "app_123", "stage": "screening" }
  }
}
```

### Delete Event

```typescript
await realtime.delete('applications',
  { id: 'app_123' },
  ctx.activeOrgId!
);
```

Broadcasts:
```json
{
  "specversion": "1.0",
  "id": "evt_…",
  "source": "/broadcast/v1/websocket",
  "type": "dev.quickback.myapp.applications.delete",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "postgres_changes",
  "data": {
    "table": "applications",
    "schema": "public",
    "eventType": "DELETE",
    "new": null,
    "old": { "id": "app_123" }
  }
}
```

## Custom Broadcasts

For arbitrary events that don't map to CRUD operations:

```typescript
await realtime.broadcast('screening-complete', {
  applicationId: 'app_123',
  candidateId: 'cnd_456',
  stage: 'interview'
}, ctx.activeOrgId!, {
  targetRoles: ['AUTHENTICATED'],
});
```

**Every send needs an audience.** Delivery is fail-closed: a broadcast whose
`targetRoles` is omitted or empty reaches **nobody**. "Everyone in the room" is
spelled explicitly — `targetRoles: ['AUTHENTICATED']` (any authenticated
subscriber) or `['PUBLIC']`.

Broadcasts:
```json
{
  "specversion": "1.0",
  "id": "evt_…",
  "source": "/broadcast/v1/websocket",
  "type": "screening-complete",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "broadcast",
  "data": {
    "applicationId": "app_123",
    "candidateId": "cnd_456",
    "stage": "interview"
  }
}
```

## User-Specific Broadcasts

Target a specific user instead of the entire organization:

```typescript
// Only this user receives the notification
await realtime.insert('notifications', newNotification, ctx.activeOrgId!, {
  userId: ctx.userId,
  targetRoles: ['AUTHENTICATED'],
});

// Notify a hiring manager of a new application
await realtime.broadcast('application-received', {
  applicationId: 'app_123',
  jobId: 'job_789'
}, ctx.activeOrgId!, {
  userId: hiringManagerId,
  targetRoles: ['AUTHENTICATED'],
});
```

`userId` targeting matches the **socket identity** — the Better Auth user id, or
the synthetic `scope:<subjectId>` principal for sessionless scope connections. If
the same person can connect through either door, prefer
[identity-tag targeting](#identity-tag-targeting) — one send reaches both.

## Role-Based Filtering

Limit which roles receive a broadcast using `targetRoles`:

```typescript
// Only hiring managers and recruiters receive this broadcast
await realtime.insert('applications', application, ctx.activeOrgId!, {
  targetRoles: ['hiring-manager', 'recruiter']
});

// Interviewers only see applications assigned to them
await realtime.update('applications', newRecord, oldRecord, ctx.activeOrgId!, {
  targetRoles: ['hiring-manager']
});
```

**Fail-closed (v0.48+):** an omitted or empty `targetRoles` delivers to
**nobody** — there is no implicit "everyone" fallback. To reach every
authenticated subscriber in the scope, say so explicitly with
`targetRoles: ['AUTHENTICATED']`. UPPERCASE pseudo-roles (`PUBLIC`,
`AUTHENTICATED`, `USER`, `SYSADMIN`) are evaluated exactly as the REST access
evaluator does.

> **Migrating from pre-0.48:** custom `realtime.broadcast(...)` /
> `realtime.insert(...)` calls that omitted `targetRoles` used to fan out to
> everyone; they now silently deliver to nobody. Audit every manual send and add
> an explicit audience.

## Identity-Tag Targeting

Every connection carries **identity tags**, proven at ticket mint and signed
into the ticket (never client-supplied):

- `user:<userId>` — every Better Auth caller
- `scope:<kind>:<subjectId>` — when a scope subject is proven, on **either**
  door: a sessionless scope principal's verified claim, or a Better Auth caller
  whose matched relationship lane maps to a scope kind with
  `subject: 'supplied' | 'both'`. Both doors stamp the **identical** tag (the
  subject row's id), which is the point.

Target them with `targetTags`:

```typescript
// ONE send reaches this guest whether they're connected via their Better Auth
// session or a sessionless scope link — no per-identity double-send.
await realtime.broadcast('chat.frame', payload, { scopeKey }, {
  targetTags: [`scope:guest:${guestId}`],
  targetRoles: ['AUTHENTICATED'],
});

// Reach a person under either identity explicitly (deduped per socket):
await realtime.broadcast('rsvp.updated', payload, { scopeKey }, {
  targetTags: [`scope:guest:${guestId}`, `user:${linkedUserId}`],
  targetRoles: ['AUTHENTICATED'],
});
```

Semantics:

- Delivery is the **deduplicated union** of the runtime's indexed tag lookup
  (`getWebSockets(tag)`) — no attachment scan, and a dual-identity socket
  matches once.
- `targetTags` **replaces the `userId` identity arm** when present; the
  scope/org bind and the `targetRoles` audience still apply on top.
- **Fail-closed:** `targetTags: []` delivers to nobody.
- Tags are **immutable** for a socket's lifetime (a Workers hibernation-tag
  constraint) — an identity change requires a reconnect.
- Limits: at most 10 tags per connection, 256 characters per tag. The mint
  rejects violations; it never truncates.

## Authorization Lease

The 60-second ws-ticket is verified once, at upgrade — but Durable Object
hibernation keeps sockets alive indefinitely. Without a lease, a ban, member
removal, or subject revocation (say, an organizer cancelling a guest) would
never affect an already-connected socket. The lease closes that gap:

- At upgrade, the connection stores `authExpiresAt = ticket.iat + lease`
  (default **15 minutes**).
- The fan-out **skips expired connections** — they receive nothing — and closes
  them with code **4401** so the client knows to re-ticket rather than blindly
  reconnect. Inbound messages (even pings) are enforced the same way.
- Renewal happens at mint: `POST /broadcast/v1/ws-ticket` re-runs the full
  authorization gate (session validity, revocation checks, relationship /
  scope-claim probes), so a revoked principal simply can't obtain a renewal
  ticket. Revocation propagates within one lease period with no polling.

Configure it on the realtime transport:

```typescript
providers: {
  database: {
    config: {
      realtime: {
        enabled: true,
        lease: '15m',   // default; also '900s', '1h', or a number of seconds
        // lease: false // disable (discouraged — reopens the revocation gap)
      },
    },
  },
}
```

The lease must be at least 60 seconds (the handshake-ticket TTL).

### In-place renewal (`reauth`)

Instead of reconnecting every lease period, a client can renew in place: mint a
fresh ticket and send it over the open socket.

```typescript
// On a timer comfortably inside the lease (e.g. every 10 min for a 15m lease):
const { wsTicket } = await mintTicket();          // POST /broadcast/v1/ws-ticket
ws.send(JSON.stringify({ type: 'reauth', ticket: wsTicket }));

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'reauth_ok') {
    scheduleNextRenewal(msg.authExpiresAt);        // lease extended, roles refreshed
  }
  if (msg.type === 'error' && msg.code === 'REAUTH_FAILED') {
    // Ticket invalid/expired — mint a fresh one and retry before the lease lapses.
  }
};

ws.onclose = (event) => {
  if (event.code === 4401) {
    // Lease expired or identity drift: mint a fresh ticket and reconnect.
    reconnectWithFreshTicket();
  }
};
```

Renewal is **strict**: the fresh ticket must carry the same `userId`,
`scopeKey`/`organizationId`, and the **exact same tag set** as the live socket
(tags are immutable). A renewal that matches extends the lease and refreshes
`role`/`roles` in place — live permission changes without socket churn. One
that doesn't closes the socket with 4401: reconnect fresh.

Clients that never send `reauth` still work — their socket is closed with 4401
once per lease and normal reconnect logic (with a fresh ticket) re-runs the
full gate. The lease is the server-side invariant either way.

## Per-Role Field Masking

Apply different field masking based on the subscriber's role using `maskingConfig`:

```typescript
await realtime.insert('candidates', newCandidate, ctx.activeOrgId!, {
  maskingConfig: {
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
    email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
    resumeUrl: { type: 'redact', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
  },
});
```

**How masking works:**
- Each subscriber receives a payload masked according to their role
- Roles in the `show.roles` array see unmasked values
- All other roles see the masked version
- Masking is pre-computed per-role (O(roles) not O(subscribers))

**Available mask types:**
| Type | Example Output |
|------|----------------|
| `email` | `j***@y*********.com` |
| `phone` | `******4567` |
| `ssn` | `*****6789` |
| `creditCard` | `**** **** **** 1111` |
| `name` | `J***` |
| `redact` | `[REDACTED]` |

### Owner-Based Masking

Show unmasked data to the record owner:

```typescript
maskingConfig: {
  phone: {
    type: 'phone',
    show: { roles: ['hiring-manager'], or: 'owner' }  // Hiring manager OR owner sees unmasked
  },
}
```

## Client-Side Subscription

### WebSocket Authentication

Quickback uses **ticket-based authentication** for WebSocket connections. The client first obtains a short-lived ticket from the main API, then connects with it as a URL parameter. The Broadcaster verifies the ticket cryptographically at upgrade time — no HTTP round-trip needed.

```typescript
// 1. Get a ws-ticket from your API (requires session auth)
const response = await fetch('/broadcast/v1/ws-ticket', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ eventId: 'evt_123' }),
});
const { wsTicket } = await response.json();

// 2. Connect with ticket as URL param
const ws = new WebSocket(
  `wss://api.yourdomain.com/broadcast/v1/websocket?ws_ticket=${wsTicket}`
);

ws.onopen = () => {
  console.log('Connected and authenticated!');
  // No auth message needed — pre-authenticated at upgrade
};
```

Tickets expire after 60 seconds, so fetch a fresh one before each connection or reconnection.

### Handling Messages

Data frames are [CloudEvents 1.0 envelopes](/platform/realtime/using-realtime#cloudevents-envelopes).
Discriminate on `qbframe`, never on `type`, and read the body from `data`:

```typescript
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  // Handle CRUD events
  if (msg.qbframe === 'postgres_changes') {
    const { table, eventType, new: newRecord, old: oldRecord } = msg.data;

    if (eventType === 'INSERT') {
      addRecord(table, newRecord);
    } else if (eventType === 'UPDATE') {
      updateRecord(table, newRecord);
    } else if (eventType === 'DELETE') {
      removeRecord(table, oldRecord.id);
    }
  }

  // Handle custom broadcasts — `type` is your wire name verbatim.
  if (msg.qbframe === 'broadcast') {
    const event = msg.type;
    const payload = msg.data;

    if (event === 'screening-complete') {
      refreshApplication(payload.applicationId);
    } else if (event === 'interview-scheduled') {
      showInterviewNotification(payload.applicationId);
    }
  }
};
```

## Environment

No additional environment variables are needed for realtime. The Broadcaster DO runs inline in your main worker and uses `BETTER_AUTH_SECRET` (already required for auth) to sign and verify WebSocket tickets.

| Variable | Description |
|----------|-------------|
| `BETTER_AUTH_SECRET` | Signs/verifies WebSocket tickets (already set for auth) |

The `BROADCASTER` binding is a `DurableObjectNamespace` — the compiler generates this in your `wrangler.toml` automatically:

```toml
[[durable_objects.bindings]]
name = "BROADCASTER"
class_name = "Broadcaster"

[[migrations]]
tag = "v1"
new_classes = ["Broadcaster"]
```

## Architecture

```
                              1. POST /ws-ticket
┌───────────────────────────────────────────────┐    ┌─────────────────┐
│              Quickback Worker                 │ ◄──│  Browser Client  │
│                                               │ ──►│                  │
│  ┌──────────┐  DO binding  ┌────────────────┐ │    └───────┬─────────┘
│  │ Hono API │ ───────────► │ Broadcaster DO │ │            │
│  └──────────┘  (in-process)└───────┬────────┘ │  2. WS ?ws_ticket=...
│                                    │          │            │
└────────────────────────────────────┼──────────┘            │
                                     │◄──────────────────────┘
                              WebSocket (verified at upgrade)
```

1. **Client gets a ticket** — `POST /broadcast/v1/ws-ticket` on the main API (session-authenticated). Returns a 60-second HMAC-signed ticket.
2. **Client connects** — Opens WebSocket with `?ws_ticket=<ticket>`. The Durable Object verifies the ticket cryptographically at upgrade and attaches the socket to the ticket's `scopeKey`, `organizationId`, or `userId`.
3. **API broadcasts** — After CRUD ops, or from custom handlers, the API calls the Broadcaster DO directly via its binding (in-process, no network hop) using the same target model.

## Best Practices

### Broadcast After Commit

Always broadcast after the database operation succeeds:

```typescript
// Good - broadcast after successful insert
const [newApp] = await db.insert(applications).values(data).returning();
await realtime.insert('applications', newApp, ctx.activeOrgId!);

// Bad - don't broadcast before confirming success
await realtime.insert('applications', data, ctx.activeOrgId!);
await db.insert(applications).values(data);  // Could fail!
```

### Minimal Payloads

Only include necessary data in broadcasts:

```typescript
// Good - minimal payload
await realtime.update('applications',
  { id: record.id, stage: 'interview' },
  { id: record.id, stage: 'screening' },
  ctx.activeOrgId!
);

// Avoid - sending entire record with large content
await realtime.update('applications', fullRecord, oldRecord, ctx.activeOrgId!);
```

### Use Broadcasts for Complex Events

For events that don't map cleanly to CRUD:

```typescript
// Hiring pipeline stage completed
await realtime.broadcast('pipeline-stage-complete', {
  applicationId: application.id,
  stages: ['applied', 'screening', 'interview'],
  currentStage: 'offer',
  candidateId: application.candidateId
}, ctx.activeOrgId!);
```

## Custom Event Namespaces

For complex applications with many custom events, you can define typed event namespaces using `defineRealtime`. This generates strongly-typed helper methods for your custom events.

### Defining Event Namespaces

Create a file in `services/realtime/`:

```typescript
// services/realtime/screening.ts
import { defineRealtime } from '@quickback/compiler';

export default defineRealtime({
  name: 'screening',
  events: ['started', 'progress', 'completed', 'failed'],
  description: 'Candidate screening pipeline events',
});
```

### Generated Helper Methods

After compilation, the `createRealtime()` helper includes your custom namespace:

```typescript
import { createRealtime } from '../lib/realtime';

export const execute: ActionExecutor = async ({ ctx, input }) => {
  const realtime = createRealtime(ctx.env);

  // Type-safe custom event methods
  await realtime.screening.started({
    applicationId: input.applicationId,
  }, ctx.activeOrgId!);

  // Progress updates
  await realtime.screening.progress({
    applicationId: input.applicationId,
    percent: 50,
    step: 'background-check',
  }, ctx.activeOrgId!);

  // Completion
  await realtime.screening.completed({
    applicationId: input.applicationId,
    result: 'passed',
    duration: 1234,
  }, ctx.activeOrgId!);

  return { success: true };
};
```

### Event Format

Custom namespace events carry `qbframe: "broadcast"`; the namespaced event
name is the CloudEvents `type`, verbatim:

```json
{
  "specversion": "1.0",
  "id": "evt_…",
  "source": "/broadcast/v1/websocket",
  "type": "screening:started",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "broadcast",
  "data": { "applicationId": "app_123" }
}
```

### Multiple Namespaces

Define multiple namespaces for different parts of your application:

```typescript
// services/realtime/notifications.ts
export default defineRealtime({
  name: 'notifications',
  events: ['interview-reminder', 'offer-update', 'application-received'],
  description: 'Recruitment notification events',
});

// services/realtime/presence.ts
export default defineRealtime({
  name: 'presence',
  events: ['joined', 'left', 'reviewing', 'idle'],
  description: 'Who is reviewing which candidate',
});
```

Usage:
```typescript
const realtime = createRealtime(ctx.env);

// Notification events
await realtime.notifications['interview-reminder']({ ... }, ctx.activeOrgId!);

// Presence events — who's reviewing which candidate
await realtime.presence.reviewing({ userId: ctx.userId, candidateId: 'cnd_456' }, ctx.activeOrgId!);
```

### Namespace vs Generic Broadcast

| Use Case | Approach |
|----------|----------|
| One-off custom event | `realtime.broadcast('event-name', payload, orgId)` |
| Repeated event patterns | `defineRealtime` namespace |
| Type-safe events | `defineRealtime` namespace |
| Event discovery | `defineRealtime` (appears in generated types) |
