Notifications
The upload wizard no longer blocks until processing finishes. That trade — a fast wizard for a delayed outcome — is only safe because every terminal outcome is pushed to a human. Without this feed, a failed upload is a title that never appears and nobody knows why.
From a readiness transaction to the bell
Firestore is the log; the hub polls it; SSE carries it to the browser.
The same system, laid out automatically. Use the controls to zoom, open it full screen, or download it for a deck.
Two writers, both transactional
| Writer | Emits | Guard |
|---|---|---|
evaluateVideoReadiness() | video.live | Only on the isReady transition, inside a Firestore transaction |
markVideoFailed() | video.failed | Once per distinct failure reason; a re-delivered dead letter does not announce twice |
A failed re-transcode of an already-live title keeps the title live and says so in the message.
Storage is a log, not a queue
notifications/{autoId} holds {type, videoId, title, message, createdAt}.
This is an append-only log. Nothing claims, acknowledges, deletes or re-orders it. That is what lets the hub run on more than one replica — every instance may read the same documents and fan them out to its own clients. It is also why there is no retention policy yet: the collection grows forever.
Adding a notification type means updating three places: the AdminNotificationType union, the
writer that emits it, and the client's toast/routing switch.
Transport: Server-Sent Events
GET /notifications/stream is SSE, because the browser must receive frames without polling:
| Item | Contract |
|---|---|
| Auth | Admin JWT in the Authorization cookie. EventSource cannot set a Bearer header. |
| Headers | text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no |
| Frame | event notification, data: one JSON object, id: the Firestore document ID |
| Reconnect | retry: 3000; the browser sends Last-Event-ID; the server replays documents created after that anchor (max 50) |
| Keep-alive | A comment heartbeat every 20 seconds |
| History | GET /notifications?limit= (1–200, default 50), newest first |
The global compression() middleware must not touch this route. Without the Accept-header
filter in src/app.ts, text/event-stream is considered compressible and frames sit in the
gzip buffer until the connection closes. The symptom is a bell that works in curl and never in
the browser.
Why the hub polls
The Firestore Admin SDK has no onSnapshot. The hub is therefore a two-second poll over
limit 100, with 500 remembered IDs and a high-water mark. It starts when the first client
attaches and stops when the last detaches. That is a deliberate trade: SSE gives the browser a
live stream; the server-side tail is a poll.
What the client does
gstv-admin-web/src/hooks/useNotifications.js:
- Fetches history once (
limit=50). - Opens
EventSource(url, {withCredentials: true}). - Dedupes by document ID.
- Toasts —
message.errorfor failures,message.successotherwise. - Dispatches
window.dispatchEvent(new CustomEvent('gstv:notification')).
videoContext listens for that event and refreshes the catalogue, so a successful upload appears
in the list without a manual reload.
Unread state is a local timestamp in localStorage.gstv.notifications.lastSeenAt, cleared
when the bell opens. It is per-browser, not per-admin, and is not stored on the server.
Debugging the feed
| Symptom | Check |
|---|---|
No frames in the browser, works in curl | Compression or proxy buffering on the stream route |
| Bell empty after a failed upload | GET /notifications?limit=10 — if the document exists, the writer is fine and the transport is not |
| Duplicate toasts | A second hub instance or a reconnect that replayed an anchor older than the last seen ID |
| Reconnect loses events | Last-Event-ID replay is capped at 50; a long disconnect can skip the middle and keep the newest |
| Feed grows without bound | Expected until a TTL policy exists |
Next: TV pairing — the other realtime surface, with weaker guards.