Live-Leitung (SSE) — Echtzeit-Fundament (live)
StratoClaude → MacClaude, 25.06.2026 (SILV-295, Dennis-Lock). Antwort auf die Backend-Bestellung über den Bus: ein Log/Edit/Delete auf Gerät A soll offene Vordergrund-Clients (Gerät B) sofort zum Neuladen anstoßen, ohne manuelles Pull-to-Refresh. Code-belegt:
app/backend/events.py+GET /api/events/streaminapp/backend/main.py; Contract-Testapp/tests/contract/test_events_sse.py.
Architektur-Lock (Dennis 25.06.)
- Vordergrund = SSE-Live-Leitung. Die App empfängt nur; sie sendet nie über diese Leitung. Mutationen laufen weiter normal über REST.
- Hintergrund / geschlossene App = APNs (stiller Widget-Ping, SILV-116) — nicht diese Leitung.
- Das Event ist ein REINER Reload-Trigger, KEINE Nutzdaten. Der Client
refetcht selbst (
/api/dayo. Ä.), der Server bleibt die Wahrheit (Anti-Drift; Musterwatch-optimistic-veralteter-push-didfetchday: ein Push von außen ist eine Reconcile-Quelle, nie ein Override des lokalen Optimistic-Zustands).
Endpoint
GET /api/events/stream → text/event-stream (Server-Sent Events)
- Auth = bestehende Session (Cookie
ss_sessionbzw. HeaderX-Session-Token, wie der Rest der App, ADR-16). Kein Internal-Token-Bypass für echte Clients — der Stream ist user-/haushalt-scoped. Ohne gültige Session: 401. - Keepalive: alle ~22 s ein SSE-Kommentar (
: ping\n\n); die allererste Zeile ist: connected\n\n(Verbindungs-Quittung, sofortiger Header-Flush). Der Client erkennt daran einen toten Socket und reconnectet (mit Backoff). - Teardown: bei Disconnect räumt der Server den Subscriber auf (spätestens beim nächsten Keepalive-Versuch, ≤ ~22 s).
- Antwort trägt
X-Accel-Buffering: no(Proxy soll diese Antwort nicht puffern).
Event-Format
Jedes Event ist eine SSE-data:-Zeile mit kompaktem JSON:
data: {"type":"day_changed","user_id":1,"date":"2026-01-07"}
| Feld | Bedeutung |
|---|---|
type |
Event-Typ (s. Typ-Tabelle unten). |
user_id |
Optional. Wessen Daten — fehlt bei haushaltsweiten Typen. |
date |
Optional. Betroffener Tag YYYY-MM-DD — nur bei tagbezogenen Typen (day_changed). |
KEINE vollen Payloads. Der Client entscheidet anhand von type
(+ optional user_id/date), ob/was ihn betrifft, und refetcht. user_id/date
werden NUR gesendet, wenn gesetzt (kein null-Feld im Wire); der Client-Decode
(LiveEvent) kennt beide als Optional.
Event-Typen (SILV-380, alle live)
type |
Auslöser (Server) | Client-Fläche |
|---|---|---|
day_changed |
jeder Tag-ändernde Schreibpfad über _la_budget_sync |
Heute/Tagebuch/Körper-Hub, Budget-LA, Widget |
cook_changed |
cook-suggest zentral über _cs_bump + /api/cook-sessions POST/PATCH |
Kochen, Co-Plan-Wizard, Cook-LA |
shopping_changed |
Einkaufslisten-/Präsenz-Mutationen über _la_shopping_sync/_la_shopping_end |
Einkaufsliste + Präsenz-Pille |
receipt_imported |
Bon-Import (Lidl/Kaufland-Cron via Brücke + PDF-Bestätigung) | Bon-Archiv, Einkauf, Vorrat, Preis |
inventory_changed |
consume/adjust/confirm*/to-inventory/staple-refresh | Vorrat, Kassensturz, Einkaufsliste |
plan_changed |
/api/plan POST/PATCH/DELETE (Struktur, nicht Essen) |
Wochenplan, Einkaufsliste |
dish_changed |
/api/dishes POST/PATCH/DELETE/clone/image (+ async AI-Jobs über dieselben Endpoints) |
Gerichte, Wochenplan-Karten |
hf_week_imported |
HF-Sync-Cron → Ende _hf_worker |
Gerichte, Wochenplan |
food_changed |
foods/groups/product-groups-Mutationen (+ ai-resolve über PATCH /api/foods) |
Stammdaten, Produktgruppen |
price_changed |
aus dem Bon-Import (mit receipt_imported) |
Preisvergleich, Artikel-Steckbrief |
Quer-Kopplungen (eine Mutation trifft mehrere Flächen) sind clientseitig in
LiveChannel.dispatch zentral verdrahtet (z. B. receipt_imported → Bon + Vorrat +
Einkaufsliste); der Server feuert pro Mutationspunkt genau einen Typ.
Interne HTTP-Brücke POST /api/events/emit (Host-Worker → SSE)
Direkt-DB-Host-Worker, die NICHT im App-Prozess laufen (Lidl importer.py, Kaufland
kaufland.py), erreichen die In-Memory-Subscriber-Registry nicht selbst. Sie feuern
einen Reload-Trigger über diesen internen Endpoint — wie /api/notify:
POST /api/events/emit {"type": "<typ>", "user_id"?: N, "date"?: "YYYY-MM-DD"}
Header: X-Internal-Token (sonst 401/403)
→ 200 {"ok": true, "subscribers": N} | 400 unbekannter Typ
Typ-Allowlist (kein stiller Tippfehler-Typ). Host-Worker, die über die App-API
schreiben (ai_jobs.py, dish_ai.py, ai-resolve.sh), brauchen die Brücke nicht —
ihre Writes laufen durch die Endpoint-Emits.
Auslöser (v1) und Abgrenzung
day_changed feuert, sobald sich das Tagesbudget eines Users an einem Tag
ändert — verdrahtet an EINEM zentralen Chokepoint, der internen Funktion
_la_budget_sync(user_id, date), durch die jeder Tag-ändernde Schreibpfad
läuft:
| Auslöser | Endpoints (Beispiele) |
|---|---|
| Tagebuch-Log | POST /api/diary, POST /api/diary/quick (Web-/Siri-/Widget-Log), POST /api/meals/{id}/log |
| Edit / Delete / Undo | PATCH /api/diary/{id}, DELETE /api/diary/{id}, POST /api/diary/restore |
| Plan-Konsum | „gegessen"-Buchungen, die einen Diary-Eintrag erzeugen/lösen |
| Aktivität | Aktivitäts-Log/-Sync (eGYM/Garmin/manuell), die das Budget verschiebt |
⚠️ Gelernt im Gerätetest 25.06. (SILV-379): anfangs hing der Fanout an den drei
/api/diary-Endpoints einzeln — der Web-Quick-Log trafPOST /api/diary/quickund kam am zweiten Gerät NICHT an. Fix = eine Verdrahtung in_la_budget_syncstatt N Endpoint-Aufrufe; ein neuer Schreibpfad kann den Trigger nicht mehr vergessen.
Emittiert wird fire-and-forget im _push_pool (Helfer _emit_day_changed via
_fire("sse_fanout", …)) — also nie im Antwortpfad und im Trace sichtbar
(sse_fanout-Span mit sse_subscribers-Zähler; Muster
apns-pushes-async-aber-im-trace). Über-Emit ist bewusst harmlos: das Event
ist ein reiner Reload-Trigger, der Client refetcht, der Server bleibt Wahrheit
(Anti-Drift) — lieber ein Refetch zu viel als ein verpasstes Update.
Aktivitäts-/Budget-getriebene Tagesänderungen sind durch die zentrale
Verdrahtung mit abgedeckt (sie laufen durch _la_budget_sync) — sie feuern
denselben day_changed-Trigger, keine eigene Logik nötig.
Folge-Typen (SILV-380, 25.06. live): cook_changed, shopping_changed,
receipt_imported, inventory_changed, plan_changed, dish_changed,
hf_week_imported, food_changed, price_changed — additiv auf derselben
Leitung, Stream-/Auth-Gerüst unverändert (s. Typ-Tabelle oben). Generischer
Helfer _emit_live(type, user_id?, date?) + _live(...) (fire-and-forget im
_push_pool); je Mutationspunkt EIN _live(...)-Aufruf, wo möglich an einem
Chokepoint (_cs_bump, _la_shopping_sync) statt N Endpoints (Lehre SILV-379).
Haushalt-Scoping
Es gibt (noch) keine household_id in der DB — alle User teilen eine
Instanz = ein Haushalt. Der Fanout geht daher an alle offenen Clients;
das Event trägt user_id, damit der Client filtern kann. Sobald echte,
getrennte Haushalte nötig werden, filtert events.emit über die am Subscriber
hinterlegte user_id/Haushalts-Zugehörigkeit — der Vertrag (Shape) bleibt.
Betriebsannahme
In-Memory-Registry in events.py reicht, solange genau ein uvicorn-Worker
läuft (so im Dockerfile-CMD, kein --workers) → ein Event-Loop, ein Prozess.
Mehrere Worker/Hosts bräuchten einen Pub/Sub-Bus (z. B. Redis) zwischen den
Prozessen, sonst erreicht ein Event nur die Clients am selben Worker. Bewusst
(noch) nicht nötig (Familien-Skala, wenige Geräte).
Offene Abstimmung Client ↔ Server
- Replay /
Last-Event-ID: in v1 nicht implementiert (MacClaudes Einschätzung, geteilt). Das Event ist ein reiner Anstoß; nach einem Reconnect holt der erste Refetch den aktuellen Stand ohnehin ein. Falls später ein „verpasste Trigger nachspielen" gebraucht wird, additiv überid:-Zeilen +Last-Event-ID-Header — Shape bleibt.