Quickback Docs

Durable Objects Setup

Broadcaster configuration, wrangler bindings, event formats, masking, and custom event namespaces.

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:

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

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.version, so /broadcast/v2/ws-ticket on contract-v2 projects) 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 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 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):

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 viaTicket role
attendeeOf (relationship lane)attendee
organizerOforganizer
contractorOfcontractor
{ 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 (_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 siteAuto-broadcast?Why
Generated CRUD route (POST /, PATCH /:id, DELETE /:id)YesThe compiler emits the call after the write returns.
Generated batch route (/batch/...)YesSame pattern, emitted per record.
db.insert/update/delete inside a record-bound action, table declares realtime.emitFromActions: trueYes — after commitThe 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 — manualScoped-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 — manualunsafeDb is the un-wrapped escape hatch — invisible to capture too.
Queue consumers, cron jobs, webhook handlersNo — manualThese 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:

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:

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

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

Broadcasts:

{
  "type": "postgres_changes",
  "table": "applications",
  "eventType": "INSERT",
  "schema": "public",
  "new": {
    "id": "app_123",
    "candidateId": "cnd_456",
    "stage": "applied"
  },
  "old": null
}

Update Event

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

Broadcasts:

{
  "type": "postgres_changes",
  "table": "applications",
  "eventType": "UPDATE",
  "schema": "public",
  "new": { "id": "app_123", "stage": "interview" },
  "old": { "id": "app_123", "stage": "screening" }
}

Delete Event

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

Broadcasts:

{
  "type": "postgres_changes",
  "table": "applications",
  "eventType": "DELETE",
  "schema": "public",
  "new": null,
  "old": { "id": "app_123" }
}

Custom Broadcasts

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

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:

{
  "type": "broadcast",
  "event": "screening-complete",
  "payload": {
    "applicationId": "app_123",
    "candidateId": "cnd_456",
    "stage": "interview"
  }
}

User-Specific Broadcasts

Target a specific user instead of the entire organization:

// 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 — one send reaches both.

Role-Based Filtering

Limit which roles receive a broadcast using targetRoles:

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

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

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.

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

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:

TypeExample Output
emailj***@y*********.com
phone******4567
ssn*****6789
creditCard**** **** **** 1111
nameJ***
redact[REDACTED]

Owner-Based Masking

Show unmasked data to the record owner:

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.

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

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

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

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

  // Handle custom broadcasts
  if (msg.type === 'broadcast') {
    const { event, payload } = msg;

    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.

VariableDescription
BETTER_AUTH_SECRETSigns/verifies WebSocket tickets (already set for auth)

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

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

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

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

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

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

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 use the broadcast type with namespaced event names:

{
  "type": "broadcast",
  "event": "screening:started",
  "payload": {
    "applicationId": "app_123"
  }
}

Multiple Namespaces

Define multiple namespaces for different parts of your application:

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

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 CaseApproach
One-off custom eventrealtime.broadcast('event-name', payload, orgId)
Repeated event patternsdefineRealtime namespace
Type-safe eventsdefineRealtime namespace
Event discoverydefineRealtime (appears in generated types)

On this page