# Realtime

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(),
});

export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  realtime: {
    enabled: true,           // Enable realtime for this table
    onInsert: true,          // Broadcast on INSERT (default: true)
    onUpdate: true,          // Broadcast on UPDATE (default: true)
    onDelete: true,          // Broadcast on DELETE (default: true)
    requiredRoles: ["recruiter", "hiring-manager"],  // Who receives broadcasts
    fields: ["id", "candidateId", "stage"],   // Fields to include (optional)
  },
  // ... guards, crud
});
```

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 relationship-authorized resource tickets:

```typescript
providers: {
  database: defineDatabase("cloudflare-d1", {
    realtime: {
      wsTicket: {
        role: "attendee",
        requestField: "eventId",
        scopeTable: "events",
      },
    },
  }),
}
```

That tells `/broadcast/v1/ws-ticket` to authenticate with Better Auth, verify the `attendee` relationship role against `body.eventId`, and mint a short-lived ticket scoped to the resolved `events:<id>` lane.

## 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 with `realtime.enabled` (or covered by `dbConfig.realtime: true`'s project-level default). Those handlers wrap each successful write with a `realtime.insert / update / delete` call before responding.

Everywhere else — action handlers, queue consumers, scheduled jobs, custom Hono routes — you broadcast manually via the `createRealtime(env)` helper described below.

| 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. |
| `scoped.insert/update/delete` inside an action handler | No — manual | Scoped-db is a query-shape wrapper (firewall + soft-delete WHERE injection). It deliberately has no side-effects so explicit handlers can write multiple records, conditionally roll back, etc. without surprise broadcasts. |
| `unsafeDb` writes in actions (or any custom route) | No — manual | Same reasoning — `unsafeDb` is the un-wrapped escape hatch. |
| Queue consumers, cron jobs, webhook handlers | No — manual | These run outside the CRUD path entirely. |

The rule of thumb: if your code calls `db.insert(...)` / `scoped.insert(...)` directly, you also call `realtime.insert(...)` directly on the next line. Otherwise the cross-tab live-update you wired up in the SPA won't fire.

## Generated Helper

The `createRealtime()` factory provides convenient methods for both Postgres Changes (CRUD events) and custom broadcasts:

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

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

  // 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 };
};
```

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
{
  "type": "postgres_changes",
  "table": "applications",
  "eventType": "INSERT",
  "schema": "public",
  "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
{
  "type": "postgres_changes",
  "table": "applications",
  "eventType": "UPDATE",
  "schema": "public",
  "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
{
  "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:

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

Broadcasts:
```json
{
  "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:

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

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

## 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']
});
```

If `targetRoles` is not specified, all authenticated subscribers in the chosen scope receive the broadcast.

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

```typescript
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.

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

```json
{
  "type": "broadcast",
  "event": "screening:started",
  "payload": {
    "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) |
