Realtime,
die einen Load Balancer überlebt.

Reaktivität ist kein Socket, den du verwaltest — sie ist der Default. Die Runtime streamt Änderungen über einen WebSocket als id-basierte Delta-Patches, getrieben von nativem Datenbank-Change-Data-Capture, und fächert sie über jede Replica, sodass ein Write auf einer Instanz auf allen erscheint.

posts.list.query.ts
TypeScript
// posts.list.query.ts  → source drives invalidation
export const listPosts = defineQuery({
  name: 'posts.list', source: 'posts',
  input: Schema.Struct({}), output: Schema.Array(Post),
})

// In any component — one hook, live forever
const { data } = useSubscription('app', 'posts.list')
// A write on ANY replica pushes a delta here.
// No polling, no refetch, no useEffect.

Wie Live-Daten wirklich reisen.

Snapshot- + Delta-Wire-Protokoll

Ein WebSocket trägt jede Subscription. Das erste Event ist ein voller Snapshot; der Rest sind id-basierte Delta-Patches im RFC-6902-Stil mit einem order-Array. Reine Reorders emittieren null Ops, No-Ops werden unterdrückt, und Fehler pro Subscription sind isoliert, sodass eine schlechte Query ihre Geschwister nicht blockiert. Ein langsamer Consumer lässt den Server nie wachsen: Während sein Socket blockiert ist, koaleszieren Updates auf den neuesten Stand (begrenzter Speicher pro Subscription), und ein dauerhaft blockierter wird laut mit einem typisierten Overrun-Fehler geschlossen — nie still verworfen.

Natives Change-Data-Capture

Änderungen kommen aus der Datenbank selbst: Postgres LISTEN/NOTIFY, MySQL/MariaDB Binlog, MSSQL Change Tracking oder ein In-Process-Bus für SQLite — sodass ein Write von überall (sogar ein roher SQL-Client) die richtigen Subscriptions weckt.

Cross-Replica-Fan-out

@voltro/plugin-broadcast fächert App-Mutationen an jede Instanz hinter einem Load Balancer über Redis, NATS oder Memory. Es ist additiv zum Inline-Emit, sodass lokale Reaktivität einen Broker-Ausfall überlebt — und es schließt die Single-Instance-Lücke für SQLite und MSSQL.

Presence & Typing

@voltro/plugin-presence verfolgt ephemere Presence pro Channel mit Heartbeat-Roster und Typing-Indikatoren (usePresence / useTyping), im Speicher gehalten und owner-partitioniert — es wird keine Tabelle geschrieben. Mit plugin-broadcast sieht jede Replica den ganzen Raum; ohne Broker sieht jede Replica nur die mit ihr verbundenen Clients, wovor der Boot warnt, weil ein korrektes Roster eines Bruchteils des Raums genau wie Erfolg aussieht.

Auto-Optimistic, gratis

Optimistic-Cache-Patches werden aus dem target-Metadata einer Mutation abgeleitet (sein table + op) — überschreib mit .withOptimistic() oder schalt es mit .withoutOptimistic() aus. Die Liste aktualisiert sich vor dem Round-Trip und reconciliert dann.

One-Shot-Reads teilen den Pfad

Dasselbe JSON-Envelope treibt einen POST /rpc-One-Shot-Pfad, der eine Query bis zum ersten Snapshot leert — genau das, was SSR-Loader für den First Paint nutzen, mit derselben Auth-Middleware. Und für Fremdprotokolle — ein Yjs-Provider, eine Legacy-Geräteflotte — mountet ein Raw-WebSocket-Gateway (defineWebSocket in einer *.ws.ts-Datei) seinen eigenen Upgrade-Pfad neben dem rpc-Socket: subject-authentifiziert vor dem Upgrade, origin-geprüft gegen Cross-Site-Hijacking, an die Credential-Expiry gebunden.

Wenn kein Tab offen ist: Web Push

Der Socket erreicht nur einen offenen Tab. Für den Rest ergänzt plugin-notifications einen First-Party-Web-Push-Kanal: Payloads nach RFC 8291 verschlüsselt, VAPID-Auth nach RFC 8292, Subscriptions pro Subject UND pro Browser-Endpoint gehalten, und ein Endpoint, den der Push-Dienst mit 404/410 beantwortet, wird automatisch entfernt. Ein Secret — VOLTRO_VAPID_PRIVATE_KEY, von voltro dev pro Projekt gemintet — und der Service Worker liegt dem Paket bei.

Reaktivität, die skaliert.

Ein Write überall, überall gesehen

Auf Postgres und MySQL/MariaDB ist der Fan-out nativ — ein Write auf einer Replica erscheint auf jeder Replica. Für Dialekte ohne natives Cross-Instance-CDC oder jedes Deployment hinter einem Load Balancer ergänzt plugin-broadcast einen Redis- oder NATS-Bus über dem Inline-Emit, sodass das Skalieren von einer Instanz zu einer Flotte keine Zeile deines Anwendungscodes ändert.

Wie eine Änderung hundert Browser erreicht.

  1. 1

    Ein Write landet in der Datenbank

    Es spielt keine Rolle, welche Replica ihn entgegennahm, und ebenso wenig, ob ein Mensch, ein Workflow oder ein geplanter Job ihn ausgelöst hat.

  2. 2

    Die Datenbank meldet ihn

    Postgres und MySQL veröffentlichen ihren eigenen Change-Feed — keine Polling-Schleife und keine zweite Wahrheitsquelle, die der ersten hinterherhinken kann.

  3. 3

    Jeder Prozess hört ihn

    Das Fan-out ist flottenweit statt pro Prozess — ein Browser an Replica drei sieht einen Write, der auf Replica eins gelandet ist.

  4. 4

    Nur die betroffenen Screens rendern neu

    Eine Subscription hat die Tabelle deklariert, aus der sie liest — die Zuordnung ist also eine Deklaration und kein Topic-String, den zwei Stellen gleich schreiben müssen.

Realtime-Fan-out im Detail.

Warum brechen die meisten Realtime-Features beim zweiten Server?

Weil sie auf In-Process-Events gebaut sind. Eine von Instanz A behandelte Mutation feuert ein Event, das nur die Sockets von Instanz A hören — ein Nutzer an Instanz B sieht nichts, bis er neu lädt. In der Entwicklung, wo es einen Prozess gibt, funktioniert das perfekt; es scheitert an dem Tag, an dem ein Load Balancer auftaucht.

Voltro nimmt die Änderung aus der Datenbank statt aus dem Prozess, der sie gemacht hat. Postgres LISTEN/NOTIFY, das Binlog von MySQL und MariaDB und Change Tracking bei SQL Server sind von Natur aus flottenweit: jede Instanz erfährt von jedem committeten Schreibvorgang, egal welche ihn ausgeführt hat.

Das deckt auch die Schreibvorgänge ab, die deine Anwendung nicht gemacht hat. Eine Migration, ein Admin mit handgeschriebenem SQL, ein Nachtjob in einem anderen Dienst — alle erzeugen dieselben Change-Events. Die UI ist damit korrekt statt nur-korrekt-wenn-die-Änderung-durch-die-App-kam.

Was läuft eigentlich über den Socket?

Ein WebSocket pro Client trägt alle seine Subscriptions. Die erste Nachricht einer Subscription ist ein vollständiger Snapshot; danach schickt der Server id-geschlüsselte Delta-Patches plus ein Ordnungs-Array — die Kosten einer Änderung sind die geänderten Zeilen, nicht die ganze Ergebnismenge.

An den Details entscheidet sich, ob eine Live-UI gewonnen oder verloren ist: eine reine Umsortierung erzeugt gar keine Operationen, ein errechnetes No-op wird unterdrückt statt gesendet, und ein Fehler in einer Subscription ist isoliert, damit eine einzelne scheiternde Query die anderen auf demselben Socket nicht blockiert.

Presence reitet auf derselben Idee, rührt die Datenbank aber bewusst nicht an. Ein Heartbeat-Roster pro Channel lebt im Speicher, owner-partitioniert — „wer schaut gerade auf diesen Datensatz“ kostet also keine Schreibvorgänge. Mit dem Broadcast-Plugin sieht jede Replica den ganzen Raum statt nur ihrer eigenen Clients.

Was ergänze ich, wenn mein Dialekt keinen flottenweiten Feed hat?

Ein Broadcast-Plugin, gerichtet auf Redis oder NATS. Es fächert Change-Events der Anwendung an jede Instanz auf und schließt damit die Lücke für SQLite — und für jedes Deployment, in dem du lieber einen expliziten Bus hast, als dich auf Datenbank-Benachrichtigungen zu verlassen.

Es ist additiv zum In-Process-Emit, kein Ersatz: lokale Reaktivität läuft weiter, wenn der Broker kurz nicht erreichbar ist — ein Broker-Ausfall verschlechtert also die instanzübergreifende Frische, statt die Seite kaputtzumachen.

An deinem Anwendungscode ändert sich nichts, wenn du es hinzunimmst. Subscription-API, Hooks und Query-Deklarationen sind identisch; von einem Container auf zwanzig zu skalieren ist ein Plugin und eine Umgebungsvariable.

Wie greift das mit Autorisierung und Mandanten zusammen?

Das Fan-out ist mandantengescoped: ein Subscriber wacht nur für Schreibvorgänge im eigenen Mandanten auf — ein lauter Mandant drückt also keine Arbeit auf die Sockets aller anderen und kann die Existenz seiner Zeilen nicht über Timing verraten.

Subscriptions tragen dieselbe Zugriffsentscheidung wie jede andere Procedure. Eine Query muss deklarieren, wer sie aufrufen darf, und das wird beim Öffnen der Subscription durchgesetzt statt nur beim ersten HTTP-Fetch — genau die Lücke, die eine nachgerüstete Socket-Schicht üblicherweise lässt.

Zeilen-Sichtbarkeit wird aufgelöst, bevor der Executor läuft — eine Änderung an einer Zeile, die ein Subscriber nicht sehen darf, erzeugt für ihn also kein Delta. Nachträglich zu filtern hieße, der Server hätte sich bereits entschieden, sie zu senden.

Die Teile, die es skalieren lassen.

Realtime-Komponenten und wofür jede zuständig ist
KomponenteZuständigkeit
Change-CaptureNativ pro Dialekt — LISTEN/NOTIFY, Binlog, Change Tracking oder ein In-Process-Bus für SQLite.
Delta-ProtokollSnapshot, dann id-geschlüsselte Patches mit Ordnungs-Array; No-ops unterdrückt, reine Umsortierungen gratis.
Cross-Replica-BusRedis oder NATS über das Broadcast-Plugin — additiv, ein Broker-Ausfall verschlechtert statt zu brechen.
PresenceIn-Memory-Heartbeat-Roster pro Channel; keine Tabelle geschrieben, instanzübergreifend mit dem Bus.
MandantentrennungFan-out pro Mandant gescoped; Zeilen-Sichtbarkeit aufgelöst, bevor ein Delta entsteht.
Erster PaintEin One-Shot-Pfad leert eine Subscription bis zum ersten Snapshot für server-gerenderte Loader.
Kommentar-Threadsplugin-comments reitet auf demselben reaktiven Kanal — ein neuer Kommentar erreicht jeden offenen Thread als Delta, ohne zweiten Push-Mechanismus.

Häufige Fragen

Brauche ich Redis für Realtime?

Nicht bei Postgres oder MySQL/MariaDB, wo der Change-Feed der Datenbank ohnehin flottenweit ist. Redis oder NATS nimmst du dazu, wenn dein Dialekt keinen instanzübergreifenden Feed hat — SQLite — oder wenn du einen expliziten Bus willst, statt dich auf Datenbank-Benachrichtigungen zu verlassen.

Erreicht ein Schreibvorgang von außerhalb der App die Subscriber?

Ja. Weil Änderungen aus der Datenbank kommen und nicht aus Anwendungs-Events, wecken eine Migration, ein manuelles SQL-Statement oder ein anderer Dienst, der dieselben Tabellen schreibt, gleichermaßen die richtigen Subscriptions.

Was kostet eine große Live-Liste?

Proportional zum Geänderten, nicht zur Liste. Updates sind id-geschlüsselte Deltas, reine Umsortierungen erzeugen nichts, und für Listen, bei denen du nur einen Ausschnitt im Speicher willst, gibt es eine gefensterte Subscription.

Wird Presence in der Datenbank gespeichert?

Nein, bewusst nicht. Das Roster liegt im Speicher und ist owner-partitioniert, ephemerer Zustand wird also nicht zu Schreiblast. Ohne Broker sieht jede Replica nur ihre eigenen Clients — worauf der Boot hinweist, denn ein korrektes Roster eines Bruchteils des Raums sieht exakt wie Erfolg aus.

Wie werden Subscriptions autorisiert?

Über dieselbe Deklaration, die jede Procedure trägt, durchgesetzt beim Öffnen der Subscription statt nur bei einem ersten Fetch. Zeilen-Sichtbarkeit wird aufgelöst, bevor Deltas entstehen — ein Subscriber bekommt also nie eine Änderung an einer Zeile, die er nicht sehen darf.

Realtime baut auf der Datenschicht auf.

Öffne das Framework. Schau es dir selbst an.

Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.