Skip to main content

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.

Flow · admin feed

From a readiness transaction to the bell

Firestore is the log; the hub polls it; SSE carries it to the browser.

Rendering Mermaid diagram…
Zoom 100 percent
Mermaid

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​

WriterEmitsGuard
evaluateVideoReadiness()video.liveOnly on the isReady transition, inside a Firestore transaction
markVideoFailed()video.failedOnce 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:

ItemContract
AuthAdmin JWT in the Authorization cookie. EventSource cannot set a Bearer header.
Headerstext/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no
Frameevent notification, data: one JSON object, id: the Firestore document ID
Reconnectretry: 3000; the browser sends Last-Event-ID; the server replays documents created after that anchor (max 50)
Keep-aliveA comment heartbeat every 20 seconds
HistoryGET /notifications?limit= (1–200, default 50), newest first
Do not compress the stream

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:

  1. Fetches history once (limit=50).
  2. Opens EventSource(url, {withCredentials: true}).
  3. Dedupes by document ID.
  4. Toasts — message.error for failures, message.success otherwise.
  5. 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​

SymptomCheck
No frames in the browser, works in curlCompression or proxy buffering on the stream route
Bell empty after a failed uploadGET /notifications?limit=10 — if the document exists, the writer is fine and the transport is not
Duplicate toastsA second hub instance or a reconnect that replayed an anchor older than the last seen ID
Reconnect loses eventsLast-Event-ID replay is capped at 50; a long disconnect can skip the middle and keep the newest
Feed grows without boundExpected until a TTL policy exists

Next: TV pairing — the other realtime surface, with weaker guards.