Quickback Docs

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

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

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.

  • Using realtime — the /broadcast channel these frames ride.
  • Durable Objects — how scopeKeyFrom rooms and the broadcast frame formats work.
  • Scopes — how scope identities (attendees, vendors) are established and carried.
  • Actions — the execute handler and the after-commit effects model.

On this page