# Named Invalidations

CRUD broadcasts tell every subscriber in a scope *"a row changed."* **Named invalidations** let an action say something precise and typed: *"the mobile bundle for event `evt_1` changed — reason `document.created`"* — so clients invalidate exactly the right query or cache, and only the intended audience is notified.

They ride the same `/broadcast/v{n}` channel as CRUD broadcasts (see [Using realtime](/platform/realtime/using-realtime)), but they are:

- **Typed** — the event, its payload shape, and a contract `version` are declared once in a registry.
- **Named + versioned** — clients listen for a stable `wireName` (e.g. `event.mobile-bundle.changed`) and branch on a `reason`.
- **Targeted** — a **delivery profile** sets the maximum audience (a scope room + roles); an optional **recipient resolver** narrows it further, fail-closed.
- **After-commit + best-effort** — fired from inside an action's `execute`, only if the write commits.

## Enable

Set `realtime: true` on the database provider and add a registry file under `services/realtime/`:

```typescript
// quickback.config.ts
database: defineDatabase("cloudflare-d1", { realtime: true }),
```

## 1. The registry — `defineRealtime`

Declare the typed events. Each event has a `wireName` (what clients subscribe to), a contract `version`, one or more named **variants** (a `reason` + a **strict** Zod `payload`), and the **delivery profiles** it is allowed to use.

```typescript
// services/realtime/attend.ts
import { z } from "zod";
import { defineRealtime, defineRealtimeDelivery } from "@quickback/compiler";

export const attendRealtime = defineRealtime({
  name: "attend",
  events: {
    mobileBundleChanged: {
      wireName: "event.mobile-bundle.changed",  // clients listen for this
      version: 1,                                 // event version (rides as qbversion)
      variants: {
        documentCreated: {
          reason: "document.created",             // clients branch on this
          payload: z.object({ eventId: z.string(), documentId: z.string() }).strict(),
        },
      },
      deliveries: ["selectedEvent", "documentWatchers"], // allow-list; profiles defined in steps 2 & 3
    },
  },
});
```

The `payload` must be a **strict** Zod object — its keys are the projection boundary: only declared keys ever reach the wire. `version` and `reason` are reserved contract fields; a payload key named `version` or `reason` is rejected at compile time.

## 2. Delivery profiles — `defineRealtimeDelivery`

A profile is the **maximum audience** for an invalidation — a scope room plus the roles allowed to receive it. `access.roles` is required and non-empty (a profile is the ceiling, so *who* is not optional — fail-closed).

```typescript
// The event room, attendees + admins.
export const selectedEvent = defineRealtimeDelivery({
  target: { scopeKeyFrom: "events:{eventId}" },   // the room; {eventId} resolves from the payload
  access: { roles: ["member", "admin"] },
});
```

`target` is either a **scope room** (`scopeKeyFrom: "events:{eventId}"` — the `{...}` columns resolve from the invalidation payload/meta) or the **account lane** (`{ organizationId, userIdFrom }`). Fail-closed: if a template column can't be resolved at runtime, the frame goes to **nobody** — never a full-room fan-out.

## 3. Recipient narrowing — `defineRealtimeRecipients` (optional)

To deliver to *specific* people rather than the whole room+roles audience, attach a recipient resolver. It runs **post-commit on the firewalled db** and returns **identities** (never raw tags). Quickback intersects `scope ∩ roles ∩ recipient-tags` — a resolver can only **narrow**, never widen. Fail-closed: a throw or `[]` delivers to **nobody**.

```typescript
import { defineRealtimeRecipients } from "@quickback/compiler";

export const documentWatchers = defineRealtimeDelivery({
  target: { scopeKeyFrom: "events:{eventId}" },
  access: { roles: ["member", "admin"] },
  recipients: defineRealtimeRecipients({
    input: z.object({ eventId: z.string() }).strict(),   // projected from payload/meta
    resolve: async ({ db, input, recipient }) => [
      recipient.user("usr_1"),                            // a specific user
      recipient.scope({ kind: "team", subjectId: input.eventId }), // everyone with a scope
    ],
  }),
});
```

The resolver gets `{ db, input, recipient }` — the scoped (firewalled) db, the projected `input`, and a `recipient` builder (`recipient.user(id)` / `recipient.scope({ kind, subjectId })`). It sees only what the acting caller could see. Resolved identities beyond a safety cap are truncated with a warning; malformed returns are dropped.

## 4. Firing from an action — `invalidates`

Bind events + profiles on the action, then call the handle inside `execute`. The binding accepts the **symbol-ref** form (imported registry members) or the string-name form:

{/* doc-compile: skip — cross-fence dependency: the `attendRealtime` registry and its delivery profiles are declared in `services/realtime/attend.ts` in steps 1–3 above. The gate compiles each fence as an isolated project, and a `services/` file is not a selectable part, so the registry cannot be resolved here. */}

```typescript
// features/events/actions/createDocument.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { attendRealtime, selectedEvent, documentWatchers } from "../../../services/realtime/attend";

export default defineAction({
  description: "Attach a document to an event and notify everyone watching it.",
  path: "/events/:eventId/documents",
  method: "POST",
  input: z.object({ documentId: z.string() }),
  access: { roles: ["admin", "owner"] },
  invalidates: {
    bundle:   { event: attendRealtime.mobileBundleChanged, delivery: selectedEvent },
    watchers: { event: attendRealtime.mobileBundleChanged, delivery: documentWatchers },
  },
  async execute({ input, invalidate }) {
    // invalidate.<binding>.<variant>(payload, meta?)
    invalidate!.bundle.documentCreated({ eventId: "evt_1", documentId: input.documentId });
    invalidate!.watchers.documentCreated({ eventId: "evt_1", documentId: input.documentId });
    return { success: true };
  },
});
```

Each `invalidate.<binding>.<variant>(payload)` call queues **one** after-commit frame. Works on every action kind — standalone, per-record (`/:id`), bulk (`/batch`), and namespace-hoisted.

## Delivery semantics

- **After-commit, best-effort.** Calls queue frames that fire only after `execute` returns successfully; a `throw` or a `dryRun` discards them (they ride the after-commit effects collector). A rolled-back batch record fires nothing.
- **Fail-closed targeting.** An unresolvable scope room or a missing recipient set delivers to nobody — never a broad fan-out.
- **Projection.** The frame payload is projected to the variant's declared keys; undeclared fields never reach the wire.
- **Contract fields.** `version` and `reason` are baked from the registry and always present, riding as the `qbversion` / `qbreason` CloudEvents extensions.

## The wire frame

Clients receive a [CloudEvents 1.0](/platform/realtime/using-realtime#cloudevents-envelopes)
frame on `/broadcast/v{n}` (the segment tracks `contract.routes`):

```jsonc
{
  "specversion": "1.0",
  "id": "evt_9f2…",
  "source": "/broadcast/v1/websocket",
  "type": "event.mobile-bundle.changed",   // the wireName, VERBATIM
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "broadcast",                  // the discriminator — match on this
  "qbversion": 1,                          // event version → negotiation
  "qbreason": "document.created",          // what changed → what to refresh
  "data": {                                // payload MINUS version/reason
    "eventId": "evt_1",
    "documentId": "doc_9"
  }
}
```

`version` and `reason` ride as the `qbversion` / `qbreason` extension
attributes, not inside `data` — the projected payload keys are `data`'s only
contents. Your `wireName` is the `type` verbatim: author-supplied event names
are never reverse-DNS prefixed.

Match on `qbframe === "broadcast"` (never on `type`, whose shape is not a
stable contract), then dispatch on `type` for the wireName, use `qbversion` for
negotiation, and branch on `qbreason` to decide which query/cache to
invalidate. Role gating (`targetRoles`) and — when a recipient resolver is used — identity narrowing (`targetTags`) are applied by the Broadcaster before the frame reaches any socket; the client just receives the frames meant for it.

## Related

- [Using realtime](/platform/realtime/using-realtime) — the `/broadcast` channel these frames ride.
- [Durable Objects](/platform/realtime/durable-objects) — how `scopeKeyFrom` rooms and the broadcast frame formats work.
- [Scopes](/define/scopes) — how scope identities (attendees, vendors) are established and carried.
- [Actions](/define/actions) — the `execute` handler and the after-commit effects model.
