Offline-Synchronisierung
Offline-Änderungen in React Native mit MMKV und Supabase schützen
Wie eine MMKV-Warteschlange laufende Löschvorgänge, verlorene Antworten und veraltete Änderungen in React Native mit Supabase behandelt.
Aktualisiert am
In diesem Artikel
Ein Eintrag, der nach dem Löschen wieder auftaucht, ist ein guter Weg, das Vertrauen in ein Fahrtenbuch zu verlieren.
Diesen Fehler musste ich in OdoKeep beheben, meiner App zur Fahrzeugverwaltung. Man konnte einen Eintrag erstellen, ihn während der laufenden Serveranfrage löschen und bei der nächsten Synchronisierung wiedersehen. Das lokale Löschen funktionierte. Das Einfügen auf dem Server ebenfalls. Falsch war die Beschreibung des Ablaufs in der Warteschlange.
OdoKeep verwendet React Native und Expo, MMKV für lokale Speicherung und Supabase für die Serverkopie. Die Repositories schreiben lokal und reihen die Operation ein. Die Oberfläche nutzt lokale Daten, während sich der Sync-Manager um das Netzwerk kümmert.
So lässt sich ein Tankvorgang ohne Verbindung speichern. Dafür muss die Warteschlange mehr abbilden als eine Liste später auszuführender Anfragen.
Wartend ist nicht dasselbe wie bereits gesendet
Die erste wichtige Unterscheidung betrifft Operationen, die noch nie gesendet wurden, und solche, deren Antwort noch aussteht.
Eine gewöhnliche Offline-Sitzung sieht so aus:
Eintrag lokal erstellen
INSERT einreihen
Eintrag lokal löschen
Wartendes INSERT abbrechen
Der Server muss nichts erfahren. Dort hat der Eintrag nie existiert. Erstellen und Löschen vollständig zusammenzufassen ist hier richtig.
Mit einer langsamen Verbindung ändert sich der Ablauf:
Eintrag lokal erstellen
INSERT senden
Eintrag lokal löschen
Wartendes INSERT abbrechen
Server beendet INSERT
Nächster Abruf liefert den Eintrag zurück
Eine Operation aus einem Array zu entfernen holt keine bereits gesendete Anfrage zurück. Die Warteschlange bezeichnete die Erstellung weiterhin als „pending“. Der Löschvorgang verstand das als Beweis, dass der Server sie noch nicht gesehen hatte.
Die Lösung ist eine Menge der aktuell laufenden Operations-IDs. In lib/sync-queue.ts enthält die Suche nach einer abbrechbaren Erstellung deshalb diese Bedingung:
const pendingCreate = pending.find(
(op) => op.type === "create" && !this.inFlight.has(op.id),
);
Ist die Erstellung bereits gesendet, muss das Löschen einen Löschauftrag für den Server einreihen. Das Paar lässt sich nicht mehr rein lokal aufheben.
Die Menge bleibt absichtlich im Arbeitsspeicher. Nach einem Neustart hat der Prozess keine laufende Anfrage mehr zu verfolgen. Ein gespeichertes inFlight würde den nächsten Start glauben lassen, eine Anfrage laufe noch, ohne dass jemand den Zustand auflösen könnte. Die dauerhafte Warteschlange überlebt; die Beschreibung aktiver Anfragen des vorherigen Prozesses nicht.
Die Menge verhindert auch, dass ein zweiter Durchlauf dieselbe Operation sendet, während der erste wartet. Wiederverbindung und manuelle Aktualisierung dürfen keine parallelen identischen Inserts auslösen. Der Manager schützt den Durchlauf, und die Warteschlange schließt laufende Operationen unabhängig davon aus ihrer verfügbaren Liste aus. Auch eine manuelle Anfrage, die Wartezeiten überspringt, respektiert diesen Ausschluss.
Ein Insert trotz verlorener Antwort bestätigen
Der Server kann eine Zeile speichern, während das Telefon die Antwort verliert.
Beim nächsten Versuch kann ein normales Insert scheitern, weil die ID schon existiert. Ein endgültiger Fehler würde den lokalen Eintrag zurücknehmen, obwohl der Server ihn erfolgreich gespeichert hat.
Im gewöhnlichen Erstellungspfad soll PostgREST eine doppelte ID ignorieren, statt die vorhandene Zeile zu überschreiben. Kommt keine Zeile zurück, liest der Manager die ID erneut. Eine lesbare Zeile bestätigt das Insert und wird vom Gerät übernommen. Scheitert diese Prüfung, bleibt die Operation wiederholbar. Eine nicht lesbare Kollision gilt nicht als Erfolg.
Das ist keine Garantie für genau einmalige Zustellung. Es ist ein Wiederholungsweg, der den Nachweis eines Inserts wiederfinden kann, ohne bestehende Inhalte zu ersetzen.
Die tatsächlich bearbeitete Version aktualisieren
Geteilte Fahrzeuge bringen ein weiteres Problem mit. Zwei Personen können denselben Eintrag ausgehend von derselben Serverversion bearbeiten, während eine oder beide offline sind.
Ein Update nur nach ID erlaubt der späteren Synchronisierung, die frühere Änderung zu überschreiben. Beide Anfragen können erfolgreich sein, ohne dass jemand vom Verlust erfährt.
Updates in der Warteschlange tragen deshalb baseUpdatedAt: den Serverzeitstempel, den das Gerät vor der lokalen Änderung kannte. Die zentrale Schreibbedingung sieht, vereinfacht aus lib/sync-manager.ts, so aus:
const write = client
.from(tableName)
.update(operation.data)
.eq("id", operation.data.id);
if (operation.baseUpdatedAt) {
write.eq("updated_at", operation.baseUpdatedAt);
}
const result = await write.select(returningColumns);
Das Update greift nur, solange die Zeile noch die bearbeitete Version hat. Die Supabase-Update-API liefert betroffene Zeilen mit .select() zurück. Der Client kann dadurch eine leere Treffermenge erkennen.
Die Basis richtig zu erfassen ist überraschend leicht zu verfehlen. Nach der ersten lokalen Bearbeitung kann die Gerätezeile einen lokal erzeugten Zeitstempel enthalten. Eine zweite Bearbeitung darf diesen nicht als neue Basis verwenden: Der Server hat ihn nie vergeben. Beim Zusammenfassen bleibt die ursprüngliche Serverbasis erhalten, während die Nutzdaten kombiniert werden.
Eine neu erstellte Zeile ist ein weiterer Sonderfall. Vor der Serverbestätigung eignet sich ihr lokaler Zeitstempel nicht als Servervorbedingung. Deshalb unterscheidet die Warteschlange zwischen einer noch vorhandenen Erstellungsoperation und einer noch abbrechbaren Erstellung.
Auch ein leeres Update-Ergebnis braucht Interpretation. Jemand kann die Zeile geändert oder gelöscht haben. Bei einem geteilten Fahrzeug können Zugriff oder Rolle entzogen beziehungsweise geändert worden sein.
Der Konfliktpfad prüft, ob eine lesbare Zeile mit neuerem Serverzeitstempel existiert. Nur dieser Nachweis erzeugt die Meldung über eine veraltete Änderung. Die Operation wird dauerhaft abgelehnt, die Person informiert und das Gerät mit dem Server abgeglichen. Ohne ursprüngliche Vorbedingung erneut zu schreiben würde genau das Überschreiben zurückbringen, das die Prüfung verhindern soll.
Bei bestätigten Konflikten gewinnt derzeit die Serverkopie. Es gibt weder feldweise Zusammenführung noch einen Konflikteditor. Alte Operationen ohne Basis können den Schutz nicht nachträglich bekommen. Diese Grenzen gehören zur tatsächlichen Zusage.
Inkrementell mit einem Serverzeitstempel abrufen
Beim Abrufen ist dieselbe Vorsicht nötig. Eine Telefonuhr kann zwei Minuten vorgehen. Speichert sie ihre eigene Uhrzeit als Abrufgrenze, überspringt die nächste Abfrage Änderungen bis zu einem Zeitpunkt, der für den Server noch in der Zukunft liegt.
lib/delta-watermark.ts verschiebt die Grenze deshalb anhand der Zeitstempel empfangener Zeilen. Eine leere Antwort behält den bisherigen Wert. Die ausgewählte Zeichenfolge bleibt für den nächsten exklusiven Vergleich unverändert, statt durch den Datumsserializer des Telefons zu laufen.
Die Telefonuhr eignet sich für Wiederholungsabstände. Sie kann nicht bestätigen, welche Serveränderungen das Gerät gesehen hat.
Wiederholungsgrenzen und Regressionstests
Fehler müssen „nein“ von „jetzt nicht“ unterscheiden. Die Warteschlange zählt alle fehlgeschlagenen Versuche getrennt von denen, die das Fehlerlimit verbrauchen. Transportfehler und bekannte vorübergehende Serverprobleme erzeugen begrenztes exponentielles Backoff, ohne das Kontingent dauerhafter Fehler zu verbrauchen. Ein kurzer Ausfall sollte kein lokal erstelltes Fahrzeug samt eingereihten Einträgen entfernen.
Die Tests prüfen das Abbrechen ungesendeter Erstellungen, Löschen nach bereits gesendetem Insert, Ausschluss aus einem zweiten Durchlauf, verlorene Antworten, ursprüngliche Update-Basis und die Unterscheidung von Konflikt und fehlendem Zugriff. Server und Speicher sind simuliert. Geprüft werden Cliententscheidungen, nicht produktive Zugriffsregeln oder ein reales Mobilfunknetz.
Keine dieser Korrekturen änderte das Tankformular. Sie änderten die Nachweise, die nötig sind, bevor die Synchronisierung einen Schreibvorgang als abgebrochen, bestätigt oder endgültig abgelehnt bezeichnet.
Wer eine Offline-Warteschlange baut, sollte früh Erstellen und anschließendes Löschen testen: Insert-Antwort offenhalten, lokale Zeile löschen und die verbleibende Warteschlange prüfen. Das deckt eine Annahme auf, die ein gewöhnlicher Flugmodustest nicht erreicht.
Ich entwickle OdoKeep, damit Fahrzeughistorien nicht für jede Eingabe eine Verbindung brauchen. Das sind einige der unsichtbaren Entscheidungen dahinter. Die App ist im App Store verfügbar.