Named Invalidations
Typed, targeted "this changed — refresh" signals an action fires after commit, delivered to exactly the right subscribers over the broadcast channel.
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), but they are:
- Typed — the event, its payload shape, and a contract
versionare declared once in a registry. - Named + versioned — clients listen for a stable
wireName(e.g.event.mobile-bundle.changed) and branch on areason. - 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/:
// 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.
// 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, // contract version (baked into every frame)
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).
// 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.
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:
// 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({
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
executereturns successfully; athrowor adryRundiscards 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.
versionandreasonare baked from the registry and always present.
The wire frame
Clients receive a broadcast frame on /broadcast/v{n} (the version tracks contract.version):
{
"type": "broadcast",
"event": "event.mobile-bundle.changed", // the wireName
"payload": {
"version": 1, // contract version → negotiation
"reason": "document.created", // what changed → what to refresh
"eventId": "evt_1",
"documentId": "doc_9"
}
}Subscribe to the wireName, use version for contract negotiation, and branch on reason 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 — the
/broadcastchannel these frames ride. - Durable Objects — how
scopeKeyFromrooms and the broadcast frame formats work. - Scopes — how scope identities (attendees, vendors) are established and carried.
- Actions — the
executehandler and the after-commit effects model.