Live-Daten aus der Datenbank.
Ganz ohne Verkabelung.

Das Schwierigste an einer Echtzeit-App ist, den Client mit der Datenbank synchron zu halten. Voltro macht genau das zum einfachen Teil: Query deklarieren, mit einem Hook subscriben — und jeder Schreibvorgang fließt per WebSocket zu jedem verbundenen Client. Kein Polling. Kein Refetch. Keine Cache-Invalidierung, die man vergessen könnte.

posts.list.query.ts
TypeScript
// queries/posts.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'

export const listPosts = defineQuery({
  name:   'posts.list',
  source: 'posts',                 // ← drives auto-invalidation
  input:  Schema.Struct({}),
  output: Schema.Array(Post),
})

// queries/posts.list.query.server.ts
import { database } from '../database/schema'

export default (_input, _ctx) =>
  database.posts.orderBy('createdAt', 'desc').limit(100)

Reaktivität ist ein Runtime-Feature, kein Plugin.

Jede Voltro-App bekommt diese Primitive standardmäßig. Kein zusätzliches Paket, kein Opt-in-Flag — schalte `tenant()` an einer Tabelle ab, und sie pusht trotzdem; entferne die source:-Deklaration, und nur der manuelle Cache-Bust-Pfad entfällt.

Ein Hook, Live-Daten

useSubscription(api, tag) öffnet eine WebSocket-Subscription, gecacht nach (api, tag, input). Zwei Komponenten, die dieselben Daten anfragen, teilen sich eine Upstream-Verbindung.

Postgres LISTEN/NOTIFY nativ

Auf Postgres laufen Change-Events über das eingebaute Pub/Sub der Datenbank. Kein Redis nötig. Andere Dialekte nutzen Binlog-CDC (MariaDB) oder den @voltro/plugin-broadcast-Bus (MySQL, MSSQL).

Auto-optimistische Patches

Deklariere `target` an einer Mutation; der Client wendet den Patch vorab auf jede passende aktive Subscription an. Das Server-Delta ersetzt die optimistische Zeile, sobald es eintrifft. Fehler werden automatisch zurückgerollt.

Tenant-scoped by default

Trägt eine Tabelle den tenant()-Mixin, merged die Runtime das Tenant-Prädikat per AND in jede Subscription. Cross-Tenant-Änderungen wecken nie den falschen Subscriber.

Feld-genauer Relevanzfilter

Der Dispatcher verfolgt, von welchen Spalten jede Subscription abhängt (Projektion + Prädikat + Order + jeder Join-Key). Ein Write auf eine unbeteiligte Spalte überspringt die Re-Query komplett — kein SQL-Roundtrip, kein Delta.

Computed + eager-loaded

Gib einen geformten Wert mit Relationen zurück: useSubscription erhält DASSELBE Objekt, das der Server liefert — voll typisiert, mit eager-geladenen Kindern. Die Reaktivität berechnet die ganze Form bei jeder Änderung an der Quell-Tabellenmenge neu.

Ein Feature, drei Dateien.

database/schema.ts
ts
// database/schema.ts
import { table, text, timestamp } from '@voltro/database'

export const posts = table('posts', {
  body:      text(),
  createdAt: timestamp().defaultNow(),
})

Das ist die ganze Schleife. Die Query nennt die Tabelle, aus der sie liest, der Screen nennt die Query, und die Runtime verbindet beides — ein Write an beliebiger Stelle erreicht diese Komponente also, ohne dass dazwischen etwas läge, das du pflegen musst.

Was derselbe Screen anderswo kostet.

Eine Query-Bibliothek plus ein Socket
Zusammengesetzt
// The shortest CORRECT version elsewhere.
const KEY = ['posts']                       // 1. a cache key

function usePosts() {
  const qc = useQueryClient()
  const q = useQuery({ queryKey: KEY, queryFn: fetchPosts })

  useEffect(() => {                          // 2. a socket
    const ws = new WebSocket(WS_URL)
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data)
      // 3. an invalidation rule that must name the SAME key
      if (msg.table === 'posts') qc.invalidateQueries({ queryKey: KEY })
    }
    return () => ws.close()
  }, [qc])

  return q
}
posts.list, abonniert
Eingebaut
const { data: posts } = useSubscription('app', 'posts.list')

Die linke Spalte ist kein Strohmann, sondern die kürzeste korrekte Fassung: ein Cache-Key, ein Socket, ein Effect, der beide verbindet, und eine Invalidierungsregel, die denselben Key nennen muss wie die Query. Jede dieser Zeilen ist eine Stelle, an der die beiden sich uneinig werden können.

Was zwischen einem Write und einem Re-Render passiert.

  1. 1

    Eine Mutation committet

    Der Write geht durch den Store, innerhalb einer Transaktion, und beim Landen werden das handelnde Subjekt und die berührten Tabellen festgehalten.

  2. 2

    Die Datenbank meldet die Änderung

    Auf Postgres und MySQL ist der Change-Feed der der Datenbank selbst — keine Polling-Schleife und keine zweite Wahrheitsquelle, die hinter der ersten zurückfallen kann.

  3. 3

    Die Runtime ordnet sie offenen Queries zu

    Jede laufende Subscription hat die Tabelle deklariert, aus der sie liest. Diese Deklaration ist die Zuordnung — deshalb gibt es keinen Cache-Key, den man mit irgendetwas im Gleichschritt halten müsste.

  4. 4

    Jeder betroffene Screen rendert neu

    Das Delta fährt über den Socket, den dieser Screen ohnehin offen hält. Über Replicas hinweg ist das Fan-out flottenweit — welcher Prozess den Write entgegennahm, spielt also keine Rolle.

Reaktive Queries im Detail.

Was ersetzt eine reaktive Query eigentlich?

Den üblichen Stapel, um einen Screen frisch zu halten: ein Fetch, ein Cache-Key, eine Invalidierungsregel, ein Refetch-Trigger, ein Polling-Intervall und ein Effekt, der alles zusammenbindet. Eine reaktive Query ersetzt das durch eine offen bleibende Subscription — der Server pusht bei Änderungen, die Komponente rendert neu.

Die Deklaration ist ein Query-Descriptor mit Input-Schema, Output-Schema und den Tabellen, die er liest. Der Client ruft ihn mit einem Hook auf. Nichts dazwischen musst du pflegen: keine eindeutig zu haltenden Query-Keys, keine manuelle Invalidierung nach einer Mutation, keine Stale-Time zum Justieren.

Das zählt am meisten dort, wo der übliche Ansatz still versagt. Ein zweiter Nutzer bearbeitet eine Zeile, ein Hintergrundjob wird fertig, ein anderer Dienst schreibt in die Datenbank — nach keinem dieser Ereignisse weiß der Browser, dass er neu laden soll. Eine Subscription auf dem Change-Feed der Datenbank sieht alle drei.

Wie kommen die Daten in den Browser?

Ein WebSocket trägt alle Subscriptions. Die erste Nachricht ist ein vollständiger Snapshot; danach schickt der Server id-geschlüsselte Delta-Patches mit einem Ordnungs-Array — eine Liste mit tausend Zeilen kostet also den Traffic einer geänderten Zeile statt tausend.

Reine Umsortierungen erzeugen keine Operationen, No-ops werden unterdrückt, und ein Fehler in einer Subscription ist isoliert, damit eine einzelne schlechte Query ihre Geschwister auf demselben Socket nicht blockiert. Das sind die Details, die entscheiden, ob eine Live-UI bei echten Datenmengen angenehm oder ruckelig ist.

Derselbe JSON-Umschlag treibt auch einen One-Shot-HTTP-Pfad, der eine Query bis zu ihrem ersten Snapshot leert — genau das nutzt der Loader einer server-gerenderten Seite für den ersten Paint, durch dieselbe Auth-Middleware. Erstes Rendern und Live-Updates lesen einen Vertrag, nicht zwei.

Was lässt Updates sofort wirken?

Optimistische Patches — abgeleitet statt geschrieben. Eine Mutation deklariert, welche Tabelle und welche Operation sie trifft, der Client kann die gecachte Liste also patchen, bevor der Roundtrip fertig ist, und abgleichen, sobald der Server antwortet. Du schreibst keinen optimistischen Updater pro Mutation und kannst keinen Rollback vergessen.

Wo der abgeleitete Patch falsch ist, überschreibst du ihn; für eine Mutation, deren Wirkung kein einfaches Insert, Update oder Delete ist, schaltest du ihn ganz ab. Der Punkt ist: der Normalfall kostet nichts, und der Sonderfall bleibt ausdrückbar.

Weil der Abgleich derselbe Delta-Pfad ist wie jede andere Änderung, korrigiert der Broadcast des Servers einen falsch geratenen optimistischen Patch — nicht eigens geschriebener Rollback-Code.

Überlebt das einen Load Balancer?

Bei Postgres und MySQL/MariaDB ja, und zwar nativ: der Change-Feed ist flottenweit, ein Schreibvorgang auf irgendeiner Replica erreicht also Subscriber auf jeder Replica. Genau diese Eigenschaft fehlt den meisten selbstgebauten Reaktiv-Schichten — und sie macht aus einer funktionierenden Demo ein kaputtes Deployment an dem Tag, an dem ein zweiter Container startet.

Für Dialekte ohne instanzübergreifenden Feed — SQLite, oder SQL Server, wo du einen expliziten Bus willst — fächert ein Broadcast-Plugin die Change-Events über Redis oder NATS auf. Es ist additiv zum In-Process-Emit, lokale Reaktivität läuft also weiter, wenn der Broker kurz weg ist.

Das Fan-out ist mandantengescoped: ein Subscriber wacht nur für Schreibvorgänge im eigenen Mandanten auf — ein lauter Nachbar drückt also keine Arbeit auf die Sockets aller anderen.

Was dir eine Subscription abnimmt.

Was die reaktive Query-Schicht übernimmt
AspektWie er behandelt wird
TransportEin WebSocket für alle Subscriptions; erst Snapshot, dann id-geschlüsselte Delta-Patches.
ÄnderungsquelleDer native Feed der Datenbank — ein Schreibvorgang von jedem Client oder Dienst weckt die richtigen Subscriptions.
InvalidierungAbgeleitet aus den deklarierten Quelltabellen der Query; keine Cache-Keys, keine manuelle Invalidierung.
OptimismusPatches, abgeleitet aus dem deklarierten Ziel einer Mutation; pro Mutation überschreibbar oder abschaltbar.
Multi-ReplicaNativ flottenweit bei Postgres und MySQL/MariaDB; sonst Redis oder NATS über ein Plugin.
Erster PaintEin One-Shot-Pfad leert dieselbe Query zu einem Snapshot für server-gerenderte Loader.

Häufige Fragen

Ist das nur Polling hinter einer hübscheren API?

Nein. Der Server wird vom eigenen Change-Feed der Datenbank benachrichtigt und pusht ein Delta über ein offenes WebSocket. Es gibt kein Intervall, und eine Änderung außerhalb deiner Anwendung — ein roher SQL-Client, ein anderer Dienst — erreicht Subscriber genauso wie eine App-interne Mutation.

Brauche ich trotzdem eine Client-Cache-Bibliothek?

Nein, und eine parallel zu betreiben ist meist kontraproduktiv: zwei Caches mit unterschiedlichen Invalidierungsgeschichten sind der Weg, auf dem ein Screen zwei Altersstufen derselben Zeile zeigt. Der Subscription-Cache ist der Cache, und er wird vom Change-Stream invalidiert statt von Heuristiken.

Was passiert, wenn die Verbindung abreißt?

Der Client verbindet sich mit Backoff neu und abonniert erneut — mit der letzten Revision, die er materialisiert hat. Innerhalb des Resume-Fensters spielt der Server nur die verpassten Deltas nach und der Stream läuft auf derselben Revisionslinie weiter; außerhalb — oder wann immer etwas zweifelhaft ist — öffnet der Stream mit einem frischen Snapshot. Ein Reconnect kann die UI also nie mit einer halb angewandten Historie zurücklassen. Der Verbindungsstatus ist ein Wert, den du rendern kannst, statt etwas, das du erschließen musst.

Ist das bei großen Listen effizient?

Updates sind id-geschlüsselte Deltas — die Kosten einer Änderung sind die geänderten Zeilen, nicht die Liste. Reine Umsortierungen erzeugen gar keine Operationen, und für Listen, bei denen du nur einen Ausschnitt im Speicher willst, gibt es eine gefensterte Variante.

Funktionieren reaktive Queries mit Server-Side-Rendering?

Ja. Ein Loader leert dieselbe Query über einen One-Shot-Pfad mit derselben Auth-Middleware bis zum ersten Snapshot — das server-gerenderte HTML und die Live-Subscription, die nach der Hydration übernimmt, lesen einen Vertrag.

Das, was du sonst selbst hättest bauen müssen.

Ohne reaktive Primitive ist eine Echtzeit-Listenansicht eine Vier-System-Integration: eine Datenbank, ein Change-Feed (LISTEN/NOTIFY, CDC, Polling), ein Pub/Sub-Bus (Redis, NATS) und ein Client-Cache (TanStack, SWR). Jedes System hat eigene Retry-Semantik, eigene Auth-Geschichte, eigene Observability-Lücke, eigenes Deploy-Rollback. Voltro bindet sie alle an EINE Invariante — die source:-Deklaration an der Query — und schenkt dir reaktive Listen.

Die Regelschleife, in einem Satz:

Eine Mutation landet in Postgres → die Runtime fängt die Änderung per LISTEN/NOTIFY → der Matcher geht jede offene Subscription durch, deren Quell-Set die geänderte Tabelle enthält → der Relevanz-Vorfilter vergleicht die geänderten Spalten mit dem Abhängigkeits-Set jeder Subscription → passende Subscriptions re-queryen im Batch → das Diff (Snapshot oder Delta) geht per WebSocket an den Client → das `data`-Prop von `useSubscription` aktualisiert sich und React rendert neu.

Reaktivität komponiert mit dem Rest der Runtime.

Ö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.