Zuletzt aktualisiert:

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/stream in app/backend/main.py; Contract-Test app/tests/contract/test_events_sse.py.

Architektur-Lock (Dennis 25.06.)

Endpoint

GET /api/events/stream      → text/event-stream (Server-Sent Events)

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 traf POST /api/diary/quick und kam am zweiten Gerät NICHT an. Fix = eine Verdrahtung in _la_budget_sync statt 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