Publishing an event
Add a typed payload to RealtimeEventMap and publish with publishTo — the only sanctioned write path.
Something happened in a service — a department was created, an exam was closed — and open Hub screens should know. You publish an event: a type plus a small data object, onto a channel kind that is allowed to carry it.
You never call Centrifugo's HTTP API from a feature module. You never axios.post the broker. RealtimeService.publishTo is the only write path, and it will throw if the kind does not list that event.
What you will touch
libs/shared/src/realtime/realtime.types.ts—RealtimeEventMap.libs/shared/src/realtime/channel-registry.ts— add the event name to that kind'sevents.libs/shared/src/realtime/realtime.chowbea.ts— already re-exports the map; closed-world means every type the map references must also be on the bus (see Exporting a type on the bus).- The domain service —
this.realtime.publishTo(...)after the DB write commits. - Hub subscriber — Subscribing from the Hub.
If the event is "please refetch these queries", use the invalidation helper instead of inventing a parallel cache protocol — Invalidating Hub queries.
Step 1: Name the payload
export type RealtimeEventMap = {
invalidate: { changes: InvalidateChange[]; actorId: string };
'exam-status': { examId: string; type: 'exam-closed' };
'attempt-flag': {
examId: string;
attemptId: string;
studentId: string;
flagType: string;
occurredAt: string;
};
// add yours here
};Keep payloads small and domain-shaped. Ids, enums, counts. Not React Query keys, not full entities, not other students' answers on a student-visible channel.
CBT is the cautionary example: student-derived flags live on cbt-exam-proctor (staff only). cbt-exam only carries proctor → student commands (exam-status). A student subscribed to the room cannot receive a peer's flag because that event is not on their channel.
Wire values that already exist as enums (CbtAttemptStatus) can travel as string on the bus if you do not want to pull the whole enum onto the bus yet — document which enum it is in a comment.
Step 2: Allow it on the kind
'cbt-exam': {
pattern: 'cbt:exam.{examId}',
modes: ['participant', 'observer'],
events: ['exam-status'], // ChannelEventName<'cbt-exam'> is now this literal
},publishTo is generic: publishTo(kind, params, event, payload) types event as that kind's events member and payload as RealtimeEventMap[event]. An as any caller still hits the runtime channelAllowsEvent check.
Step 3: Publish after commit
await this.realtime.publishTo(
'cbt-exam',
{ examId },
'exam-status',
{ examId, type: 'exam-closed' },
);Call it after the transaction resolves. An emit inside a transaction callback can fire on rollback (the department invalidation code comments this). Inject RealtimeService (the module is @Global()).
Publish is best-effort. RealtimeService.publish logs a warning and swallows broker errors. A lost signal is bounded staleness — window-focus refetch is the backstop. A Centrifugo outage must not fail "close the exam."
For invalidation specifically, do not call publishTo from every service. Call RealtimeInvalidation.changed(...) and let the listener debounce onto school-staff. That path is documented on the invalidation page.
Step 4: Extract the bus
RealtimeEventMap already rides realtime.chowbea.ts. After you change it:
pnpm bus:extract # or chowbea-axios extractThe running server serves /.well-known/chowbea.json in file mode — no restart required if you used busHandler(). On the Hub, api:watch regenerates _generated/bus. Then type the subscriber as RealtimeEvents['exam-status'].
Gotchas
Unknown types are ignored. That is how you ship the publisher before every client is deployed. It also means a typo in type is a silent no-op on the Hub — use publishTo, not a hand-rolled { type: 'exam_status' }.
Do not put secrets or PII a subscriber should not see on that channel. Authorization is "who could subscribe," not field-level filtering on the payload.
Personal is not a dump channel. User-directed notifications can go there later. Cache invalidation must not — it belongs on school-staff.