Naalya Handbook
Realtime

Invalidating Hub queries

After a DB write, publish a domain action so every open Education Hub screen refetches the right React Query keys.

A staff member (or Rover, via an RPC tool) creates a department. Another teacher has the department list open. That list should refresh without a full reload.

The API does not publish React Query keys. It publishes a domain action (department.create) plus any ids the Hub needs to be precise. The Hub maps that action onto key prefixes. Backend and frontend can refactor their caches independently; a missed mapping is a stale list, not a leak — the authorised REST endpoint is still the only path data travels.

This page is the ticket "when we mutate X, Hub screens showing X should catch up."

The path

invalidate lives on school-staff only. Not personal (fan-out), not campus-staff (presence). One channel, every staff connection in the school.


Step 1: Name the action (API)

libs/shared/src/realtime/invalidation-action.ts
export enum InvalidationAction {
  // convention: {resource}.{verb} — resource matches Resource snake_case
  DEPARTMENT_CREATE = 'department.create',
  DEPARTMENT_UPDATE = 'department.update',
  DEPARTMENT_REMOVE = 'department.remove',
}

Then the payload map — what extra fields travel with that action:

libs/shared/src/realtime/invalidation-payload.ts
export type InvalidationPayloadMap = {
  [InvalidationAction.DEPARTMENT_CREATE]: Record<string, never>;
  [InvalidationAction.DEPARTMENT_UPDATE]: { departmentId: string };
  [InvalidationAction.DEPARTMENT_REMOVE]: { departmentId: string };
};

InvalidateChange is action plus those optional id fields. Both the enum and the map already export through realtime.chowbea.ts. Run pnpm bus:extract after you add members.

Verb is the service method in present tense (create, remove, submit), not an HTTP operationId and not an RPC tool event.


Step 2: Fire after commit (API)

Inject RealtimeInvalidationRealtimeModule is @Global(), so there is no module import to add. Call changed after the awaited write returns. It no-ops when there is no tenant schoolId (platform-admin / unscoped paths).

apps/server/src/app/department/department.service.ts
this.realtimeInvalidation.changed(
  InvalidationAction.DEPARTMENT_CREATE,
  creator.sub,
  {},
);

On update/remove, pass the id:

this.realtimeInvalidation.changed(
  InvalidationAction.DEPARTMENT_UPDATE,
  jwt.sub,
  { departmentId: id },
);

Call it inside the transaction closure and it fires before commit — and still fires on rollback. That is why the call sits after the await.

The listener coalesces per schoolId:actorId in a 300ms window, then publishes once. You never debounce in the service. Coalescing keys on the action plus every non-action prop, so two departments updated in the same window both survive while duplicate emits for the same one collapse.

Rover writes go through the same services, so they invalidate the same way. There is no special "agent" event.


Step 3: The Hub half

The action is on the wire; nothing refreshes until the Hub maps it onto query keys. That is a one-file change in the Education Hub repo, and it is not optional — an unregistered action resolves to zero keys and the event is silently dropped.

src/queries/deparment/department.invalidation.ts
registerInvalidation(InvalidationAction.DEPARTMENT_UPDATE, (p) => [
  departmentQueryKeys.get(p.departmentId),
  departmentQueryKeys.list,
])

The same resolver serves the socket path and the actor's own onSuccess (via applyInvalidation), so the two cannot drift. Full walkthrough — registration, the import that makes it load, reconnect behaviour, and the buffer that waits out in-flight mutations — is Realtime Invalidation in the Education Hub docs.

Only staff receive these events: the listener is gated on me.user.type === 'staff' and rides school-staff.


Checklist

  • InvalidationAction + InvalidationPayloadMap members.
  • changed(...) after commit in the service (every code path that mutates, including RPC tools that share the service).
  • pnpm bus:extract; Hub api:watch.
  • registerInvalidation + registerReconnectInvalidation.
  • Import the module from use-realtime-invalidation.tsx.
  • Hub mutation onSuccess uses applyInvalidation when there is a user-driven mutate.

Gotchas

Do not publish query keys from the API. A key refactor on the Hub would silently stop live updates. Domain vocabulary is the contract.

Do not also publish invalidate on campus or personal. The listener is deliberately school-staff-only.

Create payloads can be empty. The Hub invalidates the whole list. Update/remove should carry the id so a detail panel can refetch without blowing every query.

Window focus remains the backstop when a publish is lost. Do not fail the HTTP request because Centrifugo was down.

Where to go next

On this page