Skoolsewa - School Management System Docs
Developer ResourcesRealtime

Realtime Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Realtime.

Realtime Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/realtime/realtime.module.ts, admin/realtime-admin.module.ts, shared/realtime-shared.module.tsImports, providers, exports, controller composition.
Controlleradmin/realtime-admin.controller.tsRoutes, guards, auth model.
Contractshared/realtime.contract.tsEvent kinds, permissions, sound classes, classifiers.
Publishershared/realtime-publisher.service.tsOutbox-to-event mapping, persistence, dispatch order.
Streamshared/realtime-stream.service.tsFan-out, replay, backpressure, shutdown.
DTOadmin/dto/realtime-catalogue-response.dto.tsCatalogue response shape.
Schemapackages/db/src/schema/notifications.tsnotification, notification_read tables.
Testsrealtime.contract.spec.ts, realtime-publisher.int.spec.ts, realtime-stream.service.spec.tsContract coverage, publish behavior, stream behavior.

2. Backend Scope and Boundaries

Owns

  • The admin-facing live event stream (GET /api/admin/realtime/stream) and its catalogue (GET /api/admin/realtime/catalogue).
  • The allowlist of outbox events that may reach an admin toast (REALTIME_EVENT_MAP), and the permission each one requires.
  • Publishing a relayed outbox row to every connected API instance over Redis pub/sub.
  • The durable notification record of every published event, and the per-user notification_read join table.
  • Reconnect replay: a bounded history buffer in Redis, read on connect and filtered by the caller's permissions.
  • The control channel (realtime:control): instructions about the connections themselves, today only close-user, which ends every open stream of one person on every API instance. The staff role edit publishes it when it removes a role.
  • The attach re-check: a stream whose session's active role changed between authentication and registration is ended as soon as it registers.

Does Not Own

  • The personal notification stream (GET /api/mobile/notifications/stream), the notification centre list, unread counts, or push devices — those belong to the notification module. See /docs/developer/notification/backend.
  • The operational admin feed that lists persisted notification rows for browsing (GET /api/notifications) — that belongs to the separate notification-feed module, which reads the same table this module writes.
  • The outbox itself (the transactional write, the dispatcher, retry and dead-letter handling) — owned by the outbox module. This module is a consumer that is called after a batch has already been dispatched.
  • Email, SMS, or push delivery. This module never sends anything to a device; it only tells an already-open admin tab what happened.

Source of Truth

ConcernSource of TruthNotes
Whether an event fires at all, and its shapeThe outbox row, relayed by the dispatcherThis module only reacts; it never originates an event.
Whether an event reaches a given adminRealtimeStreamService.canReceive against that admin's resolved permissionsMirrors RoleGuard's superadmin bypass exactly.
Durable event history for a browser sessionPostgres notification tableRedis history is a bounded reconnect buffer, not the archive.
Read/unread state for the persisted rowsnotification_read (owned by the notification-feed module)This module writes notification rows; it does not track read state.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
RealtimeModuleAggregateapps/api/src/modules/realtime/realtime.module.tsNoneNoneRealtimeSharedModuleComposes the leaf and re-exports the shared transport so the outbox dispatcher can publish into it.
RealtimeAdminModuleLeafadmin/realtime-admin.module.tsRealtimeAdminControllerNone (imports RealtimeSharedModule, RoleModule)NoneOwns the only controller: catalogue and stream routes.
RealtimeSharedModuleLeafshared/realtime-shared.module.tsNoneRealtimePublisherService, RealtimeStreamServiceBoth servicesThe transport: publish and consume, shared by the admin controller and the outbox dispatcher.
RealtimeControlModuleLeafshared/realtime-control.module.tsNoneRealtimeControlPublisherRealtimeControlPublisherThe publishing half of the control channel and nothing else. Imported by PeopleModule.

app.module.ts composes RealtimeAdminModule directly into ADMIN_MODULES — not the RealtimeModule aggregate — because Swagger's module include does not recurse and the aggregate declares no controllers of its own.

RealtimeControlModule is separate from RealtimeSharedModule on purpose. Importing the shared module instantiates RealtimeStreamService, which opens a Redis subscriber connection. PeopleModule only needs to publish close-user, and the data-transfer worker imports PeopleModule; importing the control module instead keeps a stream subscriber out of anything that merely publishes.

4. File and Directory Map

apps/api/src/modules/realtime/
  realtime.module.ts
  admin/
    realtime-admin.controller.ts
    realtime-admin.module.ts
    dto/
      realtime-catalogue-response.dto.ts
  shared/
    realtime.contract.ts
    realtime.contract.spec.ts
    realtime-publisher.service.ts
    realtime-publisher.int.spec.ts
    realtime-stream.service.ts
    realtime-stream.service.spec.ts
    realtime-shared.module.ts
    realtime-control.ts
    realtime-control.module.ts
    realtime-control.int.spec.ts
FilePurposeKey ExportsNotes
realtime.module.tsAggregate composing the leaf and re-exporting the shared transport.RealtimeModuleNot used for Swagger inclusion — the leaf is.
admin/realtime-admin.controller.tsThe only HTTP surface: catalogue and stream.RealtimeAdminControllerTakes @Res() for the stream route, not @Sse().
admin/dto/realtime-catalogue-response.dto.tsResponse shape for the catalogue route.RealtimeCatalogueResponseDto, RealtimeEventKindDtoMirrors REALTIME_SOUND_CLASSES and the contract map.
shared/realtime.contract.tsThe allowlist of outbox events, their sound class and permission, plus the wire shape of an event.REALTIME_EVENT_MAP, RealtimeEvent, RealtimeSoundClass, realtimeEventKeyAn outbox event not listed here is silently dropped.
shared/realtime-publisher.service.tsTurns a relayed outbox row into a persisted, published event.RealtimePublisherService, REALTIME_CHANNEL, REALTIME_HISTORY_KEY, REALTIME_HISTORY_LIMIT, REALTIME_COLD_START_LIMIT, RelayedOutboxEventCalled by the outbox dispatcher after commit.
shared/realtime-stream.service.tsFan-out to every connection on this instance; replay; backpressure; the control channel's consumer; the session re-read.RealtimeStreamService, RealtimeSubscription, RealtimeStreamCloser, REALTIME_CLOSE_ROLE_CHANGED, REALTIME_HEARTBEAT_SECONDS, REALTIME_MAX_BUFFERED_BYTES, REALTIME_RECONNECT_DELAY_MSOwns a dedicated Redis subscriber connection, subscribed to both channels.
shared/realtime-control.tsThe control channel's name, message type and publisher.REALTIME_CONTROL_CHANNEL, RealtimeControlMessage, RealtimeControlPublisherNot an outbox key and not in realtime.contract.ts.
shared/realtime-control.module.tsProvides and exports RealtimeControlPublisher alone.RealtimeControlModuleRegisters nothing that subscribes.

5. Data Model

5.1 Schema Source

packages/db/src/schema/notifications.ts

5.2 Tables and Collections

notification

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNogeneratedPKN/AInternal integer key.
public_iduuidNouuid7()UNIQUEN/ANot exposed by this module — no public read route for individual rows lives here.
outbox_event_idintegerNo—UNIQUE, not a FKoutbox row (by convention, not FK)Deliberately not a foreign key: dispatched outbox rows are purged separately, and a cascade would erase notification history with them. The unique constraint is the entire at-least-once dedupe story.
kindtextNo—indexed via permission composite readsN/AThe contract's stable event name, e.g. feedback.submitted.
permissiontextNo—notification_permission_idxN/AThe permission a viewer must hold to see the row; not a foreign key into the permission catalogue.
aggregate_idtextNo—noneN/APublic id of the thing that changed.
summarytextNo—noneN/APre-built, already-safe toast text. Never raw payload.
sound_classtextNo—noneN/AOne of REALTIME_SOUND_CLASSES, stored as text rather than an enum.
occurred_attimestamptzNo—notification_occurred_at_idx (desc)N/AWhen the underlying thing happened, not when this row was written.
created_attimestamptzNonow()noneN/ARow insert time.

notification_read

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
notification_idintegerNo—PK (composite), FK ON DELETE CASCADEnotification.id
user_iduuidNo—PK (composite), FK ON DELETE CASCADE, notification_read_user_idxusers.id
read_attimestamptzNonow()noneN/AAbsence of a row means unread — there is no explicit unread state.

This module writes notification rows only. It never writes notification_read — that belongs to the feed the admin panel's inbox reads.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 RealtimePublisherService

MethodCalled ByReadsWritesSide EffectsErrors
publishRelayed(relayed)Outbox dispatcher, after its own transaction commitsREALTIME_EVENT_MAPnotification rows, Redis history list, Redis sequence keyRedis PUBLISH on REALTIME_CHANNEL per eventSwallows per-row errors; logs at error and continues the batch.
buildEvent(row) (private)publishRelayedREALTIME_EVENT_MAP via realtimeEventKeyNoneAllocates a monotonic id via redis.incr(REALTIME_SEQUENCE_KEY)Returns null for an unmapped event or a classify() result of null.
persist(outboxEventId, event) (private)buildEvent's caller (publishRelayed)NoneOne notification row, onConflictDoNothing on outbox_event_idNoneA failed insert is caught by the caller's try/catch and logged; the publish still proceeds.
dispatch(event) (private)publishRelayedNoneRedis RPUSH + LTRIM on REALTIME_HISTORY_KEY (bounded to 500), then PUBLISHFans the event to every subscribed API instanceNone thrown directly; failures propagate to the caller's catch.

Explain:

  • Transaction boundaries. None of this runs inside the outbox's own transaction. It runs after commit, so a failure here can never roll back a dispatch that already enqueued a job.
  • Idempotency. The unique outbox_event_id constraint on notification makes a redelivered outbox row a no-op insert. The Redis publish itself is not deduplicated — a redelivery still fans out a second live frame, which the client de-duplicates on id where possible, or simply sees as a second toast for the rare redelivery case.
  • Ordering. History is written before the pub/sub publish, deliberately: a client that reconnects in the gap between the two would otherwise be told (via a Last-Event-ID it saw live) about an id it can no longer replay. Reversing this order can at worst replay an event the client already received live, never lose one.
  • Fail-open. Every failure here is caught, logged, and does not propagate — publishing a notification is an enhancement, never allowed to fail the underlying business write it rides on.

6.2 RealtimeStreamService

MethodCalled ByReadsWritesSide EffectsErrors
onModuleInit()Nest lifecycleNoneNoneOpens a dedicated Redis subscriber connection, subscribes to REALTIME_CHANNEL and REALTIME_CONTROL_CHANNEL, and dispatches each message by channelA malformed frame on either channel, or an unknown control message, is logged at error and dropped, not thrown.
canReceive(subscription, event)deliver(), replay(), the per-connection listenerIn-memory subscription stateNoneNonePure.
replay(subscription)attach()Redis history listNoneNoneA Redis error propagates to the caller, which logs it and still opens the live stream.
attach(response, subscription)RealtimeAdminController.stream()Redis (via replay)NoneWrites SSE headers, ready, replay frames, event frames, heartbeat comments; registers a listener and a closer, the closer also under subscription.userId; returns that closerGuards on shuttingDown (returns a no-op closer); closes the connection on a slow-consumer or transport error.
closeUserStreams(userId)The control-channel handler, on close-userThe per-user closer indexNoneEnds every open stream of that user on this instance; returns how manyNone.
currentActiveRoleId(sessionId)RealtimeAdminController.stream(), right after attachsessions.active_role_id for the sessionNoneNoneA database error propagates to the caller, which closes the stream.
deliver(event)The event-channel handlerIn-memory listener setNoneCalls every registered listener synchronouslyNone.
beforeApplicationShutdown()Nest lifecycleIn-memory closer setNoneEnds every open response before server.close() waits on themNone.

RealtimeSubscription carries permissions, bypassPermissions, an optional lastEventId, and — both required — the subscriber's userId (what close-user matches) and sessionId (what the attach re-check reads).

Explain:

  • Why a dedicated Redis connection. ioredis puts a client into subscriber mode on SUBSCRIBE, and a subscriber-mode client cannot run ordinary commands. Sharing the app's main client would silently break every cache read process-wide — a failure mode indistinguishable from "Redis is down." replay() still reads through the shared client; only the message listener uses the dedicated one.
  • Fan-out granularity. One Redis subscription per API instance serves every connected admin on that instance; the per-connection cost is a permission filter and a listener callback, not a second subscription.
  • Backpressure. response.writableLength (bytes queued, not yet flushed to the socket) is checked after every write. Above REALTIME_MAX_BUFFERED_BYTES (1 MiB) the connection is closed — the connection is expendable, the process is not.
  • Response object, not @Sse(). Nest's @Sse() decorator serialises every emission as a data: frame (so a heartbeat would look like an event to the client) and delivers through an rxjs Subject, which is synchronous and defeats any buffered-bytes check. Writing the response directly also removes this route from the global interceptors' per-response logging, so a long-lived connection does not log a line per frame.
  • Shutdown ordering. app.close() runs beforeApplicationShutdown before server.close(). server.close() waits for every open connection to finish, and an SSE stream never finishes on its own — so ending every response here is what lets a deploy's SIGTERM complete instead of requiring a force-kill.
  • Why a stream is closed when its user loses a role. Permissions are resolved once, at connect. A stream left open after its person lost a role would keep delivering what that role could read until it happened to reconnect. Ending it makes EventSource reconnect after retry: 3000, and the reconnect resolves the permissions the person holds now — or is refused if they hold none in that session. A role-change close is logged at log, not warn: it is routine.
  • Registered before the re-read. attach files the stream under its userId synchronously, before returning the closer. The controller then reads the session's active role again. A role removal committed before that read is seen by it; one committed after it finds the registered stream through the control channel. Registering after the read would leave a window neither covers.

6.3 RealtimeControlPublisher

MethodCalled ByReadsWritesSide EffectsErrors
onModuleInit()Nest lifecycleREDIS_URL, REALTIME_CONTROL_CONNECT_TIMEOUT_MS, REALTIME_CONTROL_PUBLISH_TIMEOUT_MSNoneOpens its own Redis connection with createRedisClient: no offline queue, the configured connect timeout and the configured command timeoutThrows if either timeout is missing from config, which the validated config never allows (both default).
closeUserStreams(userId)StaffService after a role edit that removed a role commitsNoneNonePUBLISH of { kind: "close-user", userId } on REALTIME_CONTROL_CHANNELNever throws. A failure — Redis unreachable, reconnecting, or slow past the timeout — is logged at warn naming the user and what the loss leaves behind.
onApplicationShutdown()Nest lifecycleNoneNoneQuits its connectionNone.
  • Its own connection, failing fast. The shared client queues commands while Redis is unreachable and retries without limit, so a publish through it during an outage would hold the request that triggered it until Redis returned. This connection refuses instead. The caller's write has already committed; the close is the part that may be lost.
  • Both bounds come from config. They are declared in config/env.validation.ts with defaults and listed in apps/api/.env.example; see the table below.
  • A lost message is accepted. Pub/sub carries no delivery guarantee. A close-user published while Redis is down, or while an instance's subscriber is reconnecting, is lost; that instance's streams for the person keep their connect-time permissions until they next reconnect. While Redis is down no events flow on the stream at all, so the gap matters only once it recovers.
  • Publishes, never subscribes. It is provided by RealtimeControlModule, which registers nothing that subscribes.
VariableTypeDefaultWhat it bounds
REALTIME_CONTROL_CONNECT_TIMEOUT_MSPositive integer, milliseconds2000How long one connection attempt to Redis may take before it is abandoned and retried.
REALTIME_CONTROL_PUBLISH_TIMEOUT_MSPositive integer, milliseconds2000How long one PUBLISH may take before it fails and is logged at warn.

Both are optional. A value that is not a positive integer fails validation at boot.

7. Runtime Flows

7.1 Outbox row to delivered frame

StepCode PathBehaviorFailure Case
1publishRelayedIterates relayed outbox rows one at a time.A per-row exception is caught and logged; the loop continues with the next row.
2buildEventLooks up REALTIME_EVENT_MAP; runs classify() if present.No entry, or classify() returns null → the row is skipped entirely, no persistence, no publish.
3persistInserts into notification, onConflictDoNothing on outbox_event_id.A redelivery is a no-op insert.
4dispatchWrites history, then publishes.If the Redis multi fails, the publish never happens and the caller logs the whole row as failed.
5Every instance's RealtimeStreamServiceReceives the pub/sub message, parses it, calls deliver().An unparseable frame is logged and dropped for that instance only.
6Per-connection listenercanReceive filters by permission; writes the frame if allowed.A listener that is already closed is a no-op.

7.2 Client connect and replay

Covered as flow 5.1 in the Features and Flows doc; the code path is RealtimeAdminController.stream() → RealtimeStreamService.attach() → replay(), then currentActiveRoleId().

7.3 A role removal closes the person's streams

StepCode PathBehaviorFailure Case
1StaffService.update → afterRoleChangeAfter commit, only when the edit removed a role.—
2RealtimeControlPublisher.closeUserStreamsPublishes close-user.Redis unreachable or slow: logged at warn, the edit still answers 200, the close is lost.
3Every instance's RealtimeStreamServiceParses the message and calls closeUserStreams(userId).Unparseable or unknown: logged at error, nothing closed.
4The person's browserEventSource reconnects; the new request resolves the permissions the session holds now.A session whose active role was the one removed has it nulled, and RoleGuard refuses it until a role is chosen.

7.4 The attach re-check

StepCode PathBehaviorFailure Case
1stream()Resolves the permissions of user.activeRole, read when the request was authenticated.—
2attach()Registers the stream, including under user.id, and returns its closer.—
3currentActiveRoleId(user.sessionId)Reads sessions.active_role_id again.A database error: the stream is closed (logged at warn) — the response is already streaming, so it cannot become an error response.
4stream()If the active role is now null or a different role, closes the stream at once.—

8. Caching

This module does not use CacheKeyUtil or the application cache layer. Its Redis usage is transport and buffer state, not a cache:

Redis KeyBuilderValueBoundInvalidationCaller
realtime:admin:events (REALTIME_CHANNEL)Literal constantPub/sub channel, no persisted valueN/AN/ARealtimePublisherService.dispatch, RealtimeStreamService subscriber
realtime:control (REALTIME_CONTROL_CHANNEL)Literal constantPub/sub channel carrying RealtimeControlMessage, no persisted valueN/AN/ARealtimeControlPublisher.closeUserStreams, RealtimeStreamService subscriber
realtime:admin:seq (REALTIME_SEQUENCE_KEY)Literal constantMonotonic integer via INCRUnbounded (resets only if Redis is flushed)Never explicitly invalidatedRealtimePublisherService.buildEvent
realtime:admin:history (REALTIME_HISTORY_KEY)Literal constantRedis list of JSON-serialised RealtimeEventTrimmed to the last REALTIME_HISTORY_LIMIT (500) on every writeSelf-trimming; no explicit invalidation pathRealtimePublisherService.dispatch, RealtimeStreamService.replay

9. BullMQ, Schedulers, and Async Work

None. This module has no queue, job, or scheduler of its own. It is invoked synchronously (in-process, not via a queue) by the outbox dispatcher's relay step, after that dispatcher's own retry and dead-letter handling has already run.

10. Realtime and Events

EventProducerRoom/TargetPayloadConsumerReliability Notes
feedback_submission:feedback.submittedSupport module, via the outboxEvery admin holding Feedback_READRealtimeEvent with kind: "feedback.submitted", soundClass: "soft"Admin panel stream listenerAt-least-once from the outbox; deduplicated at persistence, not at publish.
exam:exam.publishedExam module, via the outboxEvery admin holding Exams_READsoundClass: "neutral"Admin panelSame as above.
exam:exam.result_publishedExam module, via the outboxEvery admin holding ExamResults_READsoundClass: "positive"Admin panelSame as above.
exam_marks:exam.marks_submittedExam module, via the outboxEvery admin holding ExamMarksVerify_READsoundClass: "soft"Admin panelSame as above; the permission is the verifier's read code, not the marks read code, because the toast exists to route work to whoever can act on it.

One control message travels beside these events and is never delivered to a subscriber:

MessageProducerTargetPayloadConsumerReliability Notes
close-user on realtime:controlStaffService, after a role edit that removed a role commitsEvery open stream of that user, on every API instance{ kind: "close-user", userId }RealtimeStreamServiceAt-most-once pub/sub, not the outbox. A lost message leaves the streams on their connect-time permissions until they reconnect; the loss is logged.

The map also carries dormant infrastructure for a classify()-driven inventory reconciliation event (RECONCILIATION_REASONS, LOSS_REASONS, SILENT_REASONS, REALTIME_RECONCILIATION_URGENT_DELTA), but no inventory module exists in this repository today, so no outbox key currently triggers it.

11. Security, Auth, and Abuse Controls

  • Guards. JwtAuthGuard and RoleGuard, both global-by-default and explicitly listed on RealtimeAdminController.
  • Permissions. The catalogue route requires System_READ. The stream route declares no @Permissions() — it only requires an authenticated admin with an active role — because the per-event filter in RealtimeStreamService.canReceive is the actual boundary, applied per event rather than gating the whole feature behind the widest permission it carries.
  • Rate limiting. The catalogue route carries IpThrottlerGuard with RATE_LIMITS.ADMIN_READ. The stream route carries no IP throttle — it is one long-lived connection per session, not a request rate to bound.
  • Fail-closed on missing identity. A request reaching stream() with no activeRole throws 403 PERMISSION_USER_CONTEXT_MISSING before any Redis or listener work happens.
  • A stream does not outlive a removed role. The attach re-check closes a stream whose session's active role changed after authentication, and a staff role removal closes the person's open streams through close-user. Both make the browser reconnect, and the reconnect is authorised afresh.
  • No credential in the URL. The route is documented and designed to be opened server-to-server (by the admin panel's Next.js BFF, which holds the session cookie) using an ordinary Authorization: Bearer header — never a token in a query string, which would otherwise land in access logs and Referer headers.
  • Audit. ActivityAuditInterceptor is global and covers mutating requests; the stream and catalogue routes are reads and are not separately audited by this module.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
PERMISSION_USER_CONTEXT_MISSING403RealtimeAdminController.stream()The authenticated request has no activeRole.Re-select an active role and retry.

No other module-specific error code exists. JwtAuthGuard/RoleGuard produce their own standard 401/403 responses for missing or invalid auth, and a RoleGuard permission failure on the catalogue route produces the platform's standard permission-denied response.

14. Observability

SignalLocationPurpose
Log (error)RealtimePublisherService.publishRelayed catch blockThe only channel for a swallowed publish failure — nothing else can see it.
Log (error)RealtimeStreamService Redis message handlerAn unparseable frame on either channel, or an unknown control message.
Log (error)RealtimeStreamService.attach replay .catchA failed history read on connect.
Log (warn)RealtimeStreamService close()A connection closed for a reason other than a normal client disconnect, shutdown or role change (chiefly the slow-consumer bound, a transport error, or a failed attach re-check).
Log (log)RealtimeStreamService close()A connection closed because its user's roles changed — by close-user or by the attach re-check.
Log (warn)RealtimeControlPublisher.closeUserStreamsA close-user that could not be published; names the user whose streams keep their old permissions until they reconnect.
Log (log)RealtimeStreamService.beforeApplicationShutdownHow many streams were ended for a deploy shutdown.
MetricRealtimeStreamService.connectionCount getterLive connections on this instance; exposed for tests and the health surface, not wired to an external metrics sink in this module.

15. Testing and Validation

Test TypeFilesCoverage
Unitshared/realtime.contract.spec.tsEvent map shape, classify() behavior for the inventory reconciliation thresholds, realtimeEventKey.
Unitshared/realtime-stream.service.spec.tscanReceive, replay filtering and slicing, backpressure closing, heartbeat, shutdown draining, frame formatting, closing one user's streams and nobody else's, the closer attach returns.
Integrationshared/realtime-publisher.int.spec.tsEnd-to-end publishRelayed against a real database and Redis: persistence, dedupe on redelivery, history trimming, publish.
Integrationshared/realtime-control.int.spec.tsAgainst a real Redis and Postgres: close-user ends both streams of one user and leaves another user's open; a malformed control message is ignored and the channel keeps working; the stream route keeps a stream whose session still acts as the resolved role, and ends one whose active role was removed or switched before it registered.
Integrationpeople/staff/staff.service.spec.tsA staff role removal closes the person's open stream through real Redis and leaves a bystander's open; an edit that only adds a role publishes nothing; a failed publish does not fail the edit.

Validation commands: pnpm --filter @skoolsewa/api test, or narrow to this module with pnpm --filter @skoolsewa/api test -- realtime. The *.int.spec.ts files run with pnpm --filter @skoolsewa/api test:int.

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Allowlist (REALTIME_EVENT_MAP) instead of relaying every outbox eventThe outbox carries the platform's entire internal event trafficOnly mapped events reach the streamRelay everything and filter downstreamPrevents payload leakage and unreviewed notificationsEvery new event kind needs a code changeA new domain module lands and wants a toast
One notification row per event, fan-out on readAvoids a row-per-recipient write multiplying by staff countFilter by permission at read/connect timeInsert one row per eligible recipient at publish timeNo write amplification; permission changes apply retroactively to old rows on next readA permission grant made after the event still shows it on the next fetch, which is accepted as correct hereNever — documented as the intended behavior
Resolve permissions once per connectionAvoids a role lookup on every fan-out for every connected adminCache the permission set for the connection's lifetime, and force a reconnect when a staff role is removed (close-user)Re-resolve per eventCheap fan-outA change to a role's own permissions on the Roles screen, a role's deletion, or a pupil's graduate access still takes effect only on the next reconnectIf one of those paths needs instantaneous revocation mid-stream
close-user over plain pub/sub, not the outboxA role removal must reach every instance's open streamsBest-effort Redis publish after commit, from a fail-fast connectionAn outbox event per removal; closing local streams as a fallback on publish failureOne path for every close, and a write that never waits on RedisA close published during an outage or a subscriber reconnect is lost, and the streams keep their old permissions until they reconnectIf a missed close must be recovered rather than logged

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented.
  • Every controller, service, and DTO is documented.
  • Every method with business behavior has a narrative in §6–7.
  • The notification/notification_read tables have field-level detail.
  • Every index, constraint, and FK delete behavior has rationale.
  • Runtime flows have sequence diagrams and branch notes.
  • Business invariants are cataloged in the feature doc's §12.3.
  • Every Redis key, event mapping, and external call is documented.
  • Architectural tradeoffs are documented with alternatives.
  • Operational failure modes are covered in §11 and §14.

See Also