Kollaboratives Editieren
ohne zweites Backend.

Editieren im Google-Docs-Stil bedeutet normalerweise, einen komplett eigenen Sync-Dienst einzuführen. Hier ist es ein Spaltentyp. Deklariere crdtText(), binde ein Feld — und gleichzeitige Edits konvergieren: autoritativ auf dem Server gemerged, offline gepuffert und bei Reconnect abgeglichen. Über dieselbe Leitung, die deine App ohnehin nutzt.

documents.schema.ts
TypeScript
// documents.schema.ts — a CRDT field is just a column
export const documents = table('documents', {
  id:    id({ prefix: 'doc' }),
  title: text(),
  body:  crdtText(),        // ← conflict-free, merged on write
})

// In a component — two people type, both edits survive
const { text, insert } = useCrdtText({
  table: 'documents', id: docId, column: 'body',
})
const { members } = usePresence('doc:' + docId)   // live cursors

Was du out of the box bekommst.

Ein CRDT ist eine Spalte, kein Dienst

crdtText() deklariert ein konfliktfreies Textfeld in deinem Schema wie jede andere Spalte. Der Server faltet jedes eingehende Update auf dem Schreibpfad in den gespeicherten Zustand — zwei Clients, die dieselbe Basis editiert haben, konvergieren also, statt dass einer den anderen überschreibt. Kein Last-Write-Wins-Verlierer, kein separater Sync-Server im Betrieb.

Der Merge ist server-autoritativ

Konvergenz wird nicht den Clients zum Aushandeln überlassen. Die Runtime merged beim Schreiben, reihenfolgeunabhängig und idempotent, und broadcastet die gemergte Zeile über den normalen reaktiven Pfad. Ein fehlerhaftes Update scheitert an dem Schreibvorgang, der es verursacht hat, und nennt die Spalte — statt still einen späteren Read zu vergiften.

Offline ist eine durable Outbox, kein Fehler

Offline entstandene Schreibvorgänge landen in einer Outbox, die einen Reload überlebt, beim Reconnect der Reihe nach abspielt und beim ersten Konflikt hält, statt darüber hinwegzugehen. Der Konflikt wird über die von dir deklarierte Policy aufgelöst: crdtText()-Spalten mergen, schlichte Skalare folgen conflictPolicy(). Ein Web Lock pro Partition sorgt dafür, dass pro Gerät genau einer drainiert, egal wie viele Tabs offen sind.

Deine letzte Antwort ist offline noch da

Jede Basisbewegung jeder abonnierten Query landet in einem Query-Mirror über IndexedDB, partitioniert nach Subject UND Mandant — ein Kaltstart rendert also die zuletzt materialisierten Zeilen über denselben useSubscription-Aufruf, und der nächste Connect präsentiert die gespiegelte Revision und macht mit Deltas weiter statt mit einem frischen Snapshot. Verschlüsselte Spalten werden vor jedem Speichern entfernt, und eine Zeile, die der Server nicht mehr schickt, fällt by construction heraus statt per Sweep.

Presence und Awareness

usePresence liefert dir das Roster, wer sonst im Channel ist, mit Metadaten pro Mitglied, die deine App füllt — ein Status, ein Avatar, eine aktuelle Ansicht. Hochfrequente Editor-Carets reiten stattdessen auf einem delivery: latest-Event (ein zurückfallendes Caret springt zur neuesten Position). @voltro/plugin-presence hält Presence ephemer und im Speicher — keine Tabelle wird geschrieben — und mit plugin-broadcast sieht jede Replica den ganzen Raum statt nur ihrer eigenen Clients.

Pur und browser-sicher by construction

Der Haupteinstieg hat keine effect- und keine node:*-Imports, eine Route oder Komponente importiert ihn also direkt; die React-Bindings liegen hinter einem Subpath mit React als optionalem Peer. Derselbe pure Merge läuft im Browser, auf dem Server und in einem Unit-Test ganz ohne Datenbank.

Der Teil, den die meisten Teams unterschätzen.

Konvergenz ist eine Eigenschaft des Schreibpfads

Die meisten nachgerüsteten Kollaborationslösungen legen den Merge in den Client und hoffen, dass alle Peers sich einig werden. Voltro faltet das Update auf dem Server in den gespeicherten Zustand — auf demselben Schreibpfad wie jede andere Mutation. Die gemergte Zeile reitet also auf dem bestehenden reaktiven Fan-out, respektiert denselben Tenant-Scope und dieselben Guards und ist das, was ein später hinzukommender Client liest. Kollaboration erbt deine Zugriffsregeln, statt sie zu umgehen.

Zwei Menschen, ein Absatz, kein Lock.

database/schema.ts
ts
// database/schema.ts
export const documents = table('documents', {
  title: text(),
  body:  crdtText(),        // ← conflict-free, merged on the server
}).with(tenant())

Gleichzeitige Edits konvergieren, statt sich zu überschreiben, und der Merge ist auf dem Server autoritativ statt beim Client, der zuletzt gespeichert hat. Offline ist derselbe Pfad mit größerer Lücke: die Queue leert sich beim Reconnect, und derselbe Merge läuft.

Local-first und CRDTs im Detail.

Was ist ein CRDT, und warum gehört es ins Schema?

Ein konfliktfreier replizierter Datentyp ist ein Wert, der an mehreren Stellen unabhängig bearbeitet und ohne Verlierer zusammengeführt werden kann. Für Text heißt das: zwei Leute, die im selben Absatz tippen, erzeugen ein Dokument mit beiden Änderungen, statt dass eine die andere beim Speichern überschreibt.

Es als Spaltentyp zu deklarieren, statt einen Sync-Dienst einzuführen, ist die Designentscheidung. `crdtText()` sitzt im selben Schema wie alles andere, migriert mit allem anderen und wird über dieselbe Query gelesen — Kollaboration ist damit eine Eigenschaft eines Feldes statt eines Parallelsystems mit eigener Speicherung, eigener Auth und eigener Betriebsgeschichte.

Der gespeicherte Wert ist ein kodierter CRDT-Zustand. Deshalb schickt ein Client ein Update und keinen neuen String: der Server faltet das Update in das, was er hat — und genau das lässt zwei gleichzeitige Bearbeitungen konvergieren statt um die Wette laufen.

Wo passiert der Merge, und warum zählt das?

Auf dem Server, auf dem Schreibpfad, bevor die Zeile gespeichert wird. Die Runtime merged das eingehende Update in den gespeicherten Zustand — reihenfolgeunabhängig und idempotent — und broadcastet dann die gemergte Zeile über den normalen reaktiven Pfad.

Die meisten nachgerüsteten Kollaborationslösungen legen den Merge in den Client und hoffen, dass alle Peers sich einig werden. Ihn serverseitig zu falten heißt: die gemergte Zeile ist das, was ein später hinzukommender Client liest, sie reitet auf dem bestehenden Fan-out, und sie respektiert denselben Tenant-Scope und dieselben Zugriffsentscheidungen wie jeder andere Schreibvorgang. Kollaboration erbt deine Regeln, statt sie zu umgehen.

Sie scheitert außerdem brauchbar. Ein fehlerhaftes Update wird an dem Schreibvorgang abgelehnt, der es verursacht hat, mit benannter Spalte — statt roh gespeichert zu werden und bei irgendeinem späteren Read zu fliegen, dem Fehler, den man kaum zurückverfolgen kann.

Wie funktioniert offline?

Offline entstandene Schreibvorgänge gehen in eine durable Outbox. Die Queue darunter ist ein reiner Reducer, ihr Verhalten ist also testbar statt emergent, und ihre Einträge sind persistent — den Tab mitten im Edit zu schließen verwirft sie nicht. Kommt die Verbindung zurück, spielen sie der Reihe nach ab, und ein Web Lock pro Partition sorgt dafür, dass pro Gerät genau einer drainiert, egal wie viele Tabs offen sind.

Ein Replay hält beim ersten Konflikt, statt darüber hinwegzugehen, und der Konflikt wird über die von dir deklarierte Policy aufgelöst. Eine crdtText()-Spalte merged — die CRDT-Eigenschaft macht das verlustfrei —, ein schlichter Skalar folgt conflictPolicy(), deren Default Last-Write-Wins mit symmetrischem Gleichstands-Bruch ist: beide Peers wählen denselben Gewinner, unabhängig davon, welche Seite jeder „lokal“ nennt.

Die Lesehälfte ist ein Query-Mirror, keine SQL-Datenbank im Browser. Die Zeilen jeder abonnierten Query liegen pro Subject-und-Mandant-Partition in IndexedDB — die App rendert also offline über denselben Subscription-Aufruf und macht beim Reconnect mit Deltas weiter. Die bewusste Grenze: die gesamte Query-Oberfläche des Clients ist ein Tag plus ein Input — Prädikate werden auf dem Server gebaut und ausgewertet —, eine SQL-Engine im Tab würde also eine Sprache auswerten, die der Client nie zu sehen bekommt.

Was ist mit Presence und Cursorn?

`usePresence` gibt dir das Roster, wer sonst im Channel ist, mit Metadaten pro Mitglied, die deine App füllt — ein Status, ein Avatar, eine aktuelle Ansicht. Hochfrequente Editor-Carets reiten BEWUSST nicht auf dieser Leitung: sie laufen über ein delivery: latest-Event, sodass ein Caret, das zurückfällt, zur aktuellen Position springt, statt jede Bewegung nachzuspielen. Presence bleibt ephemer und im Speicher — keine Tabelle wird geschrieben.

Instanzübergreifende Presence braucht einen Broker. Ohne ihn sieht jede Replica nur ihre eigenen Clients, und der Boot sagt das: ein korrektes Roster eines Bruchteils des Raums sieht exakt wie Erfolg aus — die Art von falsch, die Tests überlebt.

Der pure Einstiegspunkt hat weder Effect- noch Node-Importe, eine Route oder Komponente importiert ihn also direkt; die React-Bindings liegen hinter einem Subpath mit React als optionalem Peer. Derselbe Merge läuft im Browser, auf dem Server und in einem Unit-Test ganz ohne Datenbank.

Was ausgeliefert wird und wofür.

Local-first-Bausteine und was jeder liefert
BausteinWas er liefert
crdtText()Eine konfliktfreie Textspalte in deinem Schema, auf dem Schreibpfad server-autoritativ gemerged.
Durable OutboxOffline-Schreibvorgänge, die einen Reload überleben, der Reihe nach abspielen, beim ersten Konflikt halten und pro Gerät einmal drainieren.
SyncClientTreibt die Queue über einen Transport, den du an die bestehende Mutations- und Subscription-Leitung bindest.
Query-MirrorAbonnierte Zeilen pro Subject+Mandant in IndexedDB — Offline-Reads, online dann ein Delta-Resume.
usePresenceEphemeres In-Memory-Roster pro Channel mit Metadaten pro Mitglied; instanzübergreifend mit Broker.
localFirst()Ein Marker-Mixin, das eine Tabelle als offline-fähig deklariert — das Schema-Opt-in, das die Sync-Maschinerie entdeckt.

Häufige Fragen

Brauche ich einen separaten Sync-Dienst?

Nein. Der Merge passiert auf deinem Server auf dem Schreibpfad, und die gemergte Zeile reist über das reaktive Fan-out, das du ohnehin hast. Kein zweiter Datenspeicher, kein separates Auth-Modell, kein zusätzlicher Dienst im Betrieb.

Was passiert, wenn zwei Leute denselben Satz bearbeiten?

Beide Änderungen überleben, und jeder Client konvergiert auf denselben Text — unabhängig von der Reihenfolge, in der der Server sie verarbeitet hat. Das ist die CRDT-Eigenschaft: der Merge ist reihenfolgeunabhängig und idempotent, ein bereits eingefaltetes Update erneut abzuspielen ändert also nichts.

Kann ich es nutzen, ohne komplett local-first zu werden?

Ja, und das ist der Normalfall: ein kollaboratives Feld auf einer ansonsten gewöhnlichen Tabelle. Weil es ein Spaltentyp ist, ist die Einführung eine Schema-Änderung und keine Architekturänderung.

Ist das Client-Bundle schwer?

Der pure Einstiegspunkt trägt weder Effect- noch Node-Importe, und React ist ein optionaler Peer hinter einem Subpath — eine Route, die nur den Merge braucht, zieht die Hooks also nicht mit, und ein serverseitiger Test braucht keine Browser-Umgebung.

Schreibt Presence in die Datenbank?

Nein. Das Roster liegt im Speicher und ist owner-partitioniert, hochfrequente Presence wird also nicht zu Schreiblast. Instanzübergreifende Presence braucht einen Broker, und ohne ihn warnt der Boot, statt ein Teil-Roster korrekt aussehen zu lassen.

Local-first baut auf dem restlichen Stack 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.