Zum Inhalt springen

Offline & Local-First-Sync

Die Offline-Unterstützung macht aus der Datenschicht des SDK eine Local-First-Sync-Engine. Statt eines Caches, der sich Antworten merkt, hält der Client eine kleine lokale Datenbank aus Zeilen vor, beantwortet Abfragen daraus und behandelt das Netzwerk als etwas, das sie befüllt und Ihre Schreibvorgänge irgendwann annimmt.

Daraus ergeben sich drei Dinge:

  • Lesevorgänge überstehen den Ausfall des Netzwerks. Eine Abfrage, die der Client lokal auswerten kann, wird lokal ausgewertet — samt Filtern, Sortierung und Paginierung —, sodass eine Liste auch bei toter Verbindung weiter gerendert wird.
  • Über Schreibvorgänge wird lokal entschieden. Ein offline getätigter Schreibvorgang wird sofort angewendet, in die Warteschlange gestellt und der Reihe nach wiederholt, sobald die Verbindung zurückkehrt. Lehnt der Server ihn ab, wird die lokale Änderung zurückgerollt.
  • Lesevorgänge sind reaktiv. observe() gibt zuerst aus der lokalen Datenbank aus und gibt erneut aus, sobald irgendetwas die abgedeckten Zeilen ändert — Ihre eigenen Schreibvorgänge, ein eintreffender Schreibvorgang aus der Warteschlange, ein Rollback, ein anderer Browser-Tab oder ein Echtzeit-Ereignis.

Standardmäßig ist sie deaktiviert. Schalten Sie sie mit einer einzigen Option ein:

const client = createRebaseClient({
baseUrl: "https://api.example.com",
offline: true
});

Im Browser wird alles in IndexedDB persistiert, sodass ein Neuladen sowohl die lokalen Zeilen als auch die nicht gesendeten Schreibvorgänge behält. Anderswo (Node, Tests) wird auf den Arbeitsspeicher zurückgegriffen; andere Laufzeitumgebungen können ihren eigenen Store bereitstellen.

An der API, die Sie bereits verwenden, ändert sich nichts an der Form. find(), findById(), create(), update(), delete() und der Fluent-Builder behalten ihre Signaturen und ihre Rückgabetypen — sie scheitern nur nicht mehr, wenn das Netzwerk es tut.

Ein erfolgreicher Lesevorgang führt seine Zeilen in die lokale Datenbank zusammen und merkt sich, welche IDs der Server für diese Abfrage zurückgegeben hat. Wenn ein Lesevorgang den Server nicht erreichen kann, wird er lokal beantwortet:

const drafts = await client.data.posts
.where("status", "==", "draft")
.orderBy("updatedAt", "desc")
.find();

Offline filtert und sortiert dies die Zeilen, die der Client vorhält. Dazu gehören Zeilen, die von anderen Abfragen geholt wurden — die Datenbank ist normalisiert, eine Zeile wird also nur einmal gespeichert, in wie vielen Listen sie auch immer auftauchte — sowie Zeilen, die Sie offline erstellt haben.

Wenn es wirklich nichts gibt, womit sich antworten ließe (eine Collection, die die App nie gelesen hat), wirft der Lesevorgang einen erkennbaren Fehler statt eines nackten TypeError:

import { isOfflineError } from "@rebasepro/client";
try {
await client.data.posts.find();
} catch (error) {
if (isOfflineError(error)) showOfflinePlaceholder();
else throw error;
}

Solange bekannt ist, dass die Verbindung unterbrochen ist, wird ein Schreibvorgang gar nicht erst versucht — er wird lokal angewendet und eingereiht und kostet damit nichts statt eines Timeouts:

// While offline, returns at once with the locally applied row.
const post = await client.data.posts.create({ title: "Draft", status: "draft" });
// Shows up in every matching list, right away.
const drafts = await client.data.posts.where("status", "==", "draft").find();

Offline erstellte Zeilen erhalten eine vom Client generierte ID, und zwar in dem Typ, den die IDs der Sammlung bereits haben: eine negative ganze Zahl, wo sie Zahlen sind, eine UUID-Zeichenkette, wo sie Zeichenketten sind. Das Vorzeichen ist das Erkennungsmerkmal — ein echter Schlüssel ist nie negativ —, sodass eine Zeile, die den Server noch nicht erreicht hat, ohne Nachschlagen erkennbar ist und id weiterhin dem entspricht, was der generierte Row-Typ sagt. Bei einer Sammlung, die dieses Gerät noch nie gelesen hat, gibt es lokal nichts, woran sich der Typ ablesen ließe; dann ist die ID eine UUID.

Vergibt der Server beim Wiederholen seine eigene, werden die lokale Zeile und alle eingereihten Schreibvorgänge, die noch auf die temporäre ID zeigen, auf die echte umgezogen, in jeder Collection: Ein Post, der mit einer author_id eingereiht wurde, die auf einen offline erstellten Autor zeigt, wird mit der ID gesendet, die der Server dem Autor gegeben hat. Lehnt der Server stattdessen den Autor ab, werden Schreibvorgänge, die auf ihn verweisen, mit ihm verworfen und als DEPENDENCY_REJECTED gemeldet (siehe Wenn der Server ablehnt). Bis dahin sollten Sie eine temporäre ID nirgends außerhalb der Offline-Datenbank speichern — ein Fremdschlüssel oder eine gespeicherte URL mit einer solchen ID zeigt auf eine Zeile, die gleich umnummeriert wird.

Schreibvorgänge werden in der Reihenfolge wiederholt, in der Sie sie getätigt haben, collection-übergreifend — ein Create in einer Collection landet also weiterhin vor der Zeile in einer anderen, die darauf verweist.

Jeder eingereihte Schreibvorgang ist standardmäßig eine Anfrage, sodass 500 offline getippte Zeilen 500 Roundtrips zum Abarbeiten brauchen. Ist replayBatchSize gesetzt (bis zu 1 000), wird eine Folge aufeinanderfolgender Offline-Creates in einer Collection stattdessen als ein createMany wiederholt. Nur Creates mit einer vom SDK erzeugten ID als String werden zusammengefasst, und nie eines, das auf eine andere Zeile desselben Durchlaufs verweist. Lehnt der Server den Durchlauf aus einem anderen Grund als einer verlorenen Verbindung ab oder verschiebt er ihn, wird nichts geschrieben (ein Bulk-Schreibvorgang ist eine Transaktion), und der Durchlauf wird Schreibvorgang für Schreibvorgang wiederholt, wie es standardmäßig geschähe.

observe() ist der reaktive Lesevorgang und derjenige, zu dem man in einer UI greifen sollte:

const unsubscribe = client.data.posts.observe(
{ where: { status: ["==", "draft"] }, orderBy: ["updatedAt", "desc"] },
(result) => {
render(result.data);
setBadge(result.hasPendingWrites ? "saving…" : null);
}
);

Die erste Emission kommt ohne dazwischenliegende Anfrage aus der lokalen Datenbank; eine Revalidierung folgt im Hintergrund. Danach wird bei jeder Änderung an den abgedeckten Zeilen erneut ausgegeben. Emissionen werden dedupliziert — eine Aktualisierung, die nichts ändert, ruft den Callback nicht auf —, sodass Sie gefahrlos direkt daraus rendern können.

Jedes Ergebnis trägt, was eine UI braucht, um sich selbst zu beschreiben:

Feld Bedeutung
data, meta Dieselbe Form, die find() zurückgibt
fromCache Die Zeilen stammen aus der lokalen Datenbank, nicht aus einer abgeschlossenen Anfrage
hasPendingWrites Mindestens eine Zeile hier trägt einen Schreibvorgang, den der Server noch nicht angenommen hat
partial Die lokale Datenbank hält möglicherweise nicht jede passende Zeile — als bestmögliche Näherung behandeln
error Die letzte Revalidierung ist fehlgeschlagen

observeById() tut dasselbe für eine einzelne Zeile und übergibt undefined, wenn sie gelöscht wird. Es akzeptiert include wie findById(), und jede Emission enthält diese Relationen:

const stop = client.data.posts.observeById(postId, (post) => render(post), undefined, {
include: ["tags"]
});

Beide binden das Echtzeit-Abonnement ein, wenn der Client eines hat, sodass auch Änderungen anderer Benutzer hereinströmen. Übergeben Sie { realtime: false } für ein Abonnement, das nur den lokalen Zustand und explizite Aktualisierungen widerspiegelt.

Ohne aktiviertes offline gibt es observe() weiterhin: Es lädt einmal und bleibt über Echtzeit live, wobei alle drei Flags false sind.

client.offline legt die Engine offen — daraus wird eine Sync-Anzeige gebaut:

const unsubscribe = client.offline!.onStatusChange((status) => {
setOnline(status.online);
setPending(status.pending);
setSyncing(status.syncing);
});
// Or read it once
const { online, pending, syncing, lastSyncedAt, lastError } = client.offline!.status();
Methode Zweck
status() Aktuelle Verbindung, Warteschlangentiefe, Sync-Aktivität, letzter Fehler und persisted: ob der Browser zugestimmt hat, die Offline-Datenbank zu behalten (wird gefragt, wenn der erste Schreibvorgang eingereiht wird; false heißt, sie kann bei Speicherdruck verdrängt werden, nicht gesendete Schreibvorgänge eingeschlossen). unverifiedUser ist true, solange ein Offline-Neuladen die Daten des letzten Benutzers ohne bestätigte Sitzung zeigt (siehe keepLastUser)
onStatusChange(fn) Das Obige abonnieren
onQueueChange(fn) Nur die Anzahl der nicht gesendeten Schreibvorgänge, für ein Badge
pending() Die eingereihten Mutationen selbst, älteste zuerst. pending({ orphaned: true }): die, die ohne Anmeldung eingereiht wurden — siehe Benutzer
sync() Jetzt wiederholen — löst mit { flushed, remaining } auf
clear() Die eingereihten Schreibvorgänge und lokalen Zeilen des aktuellen Benutzers verwerfen. clear({ orphaned: true }): stattdessen die, die ohne Anmeldung eingereiht wurden

Das Wiederholen geschieht von selbst: wenn der Browser online auslöst, wenn sich der Benutzer anmeldet, und mit exponentiellem Backoff (eine Sekunde, verdoppelnd bis zu einer Minute), solange etwas in der Warteschlange steht. sync() ist für einen «Jetzt erneut versuchen»-Button gedacht.

Ein eingereihter Schreibvorgang kann abgelehnt werden — Validierung, Row-Level Security, eine Zeile, die jemand anders gelöscht hat. Solche Fälle lösen sich nie von selbst auf, deshalb rollt die Engine die lokalen Zeilen auf den Stand vor dem Schreibvorgang zurück, verwirft die eingereihten Änderungen, die darauf aufbauten, und teilt es Ihnen mit:

const client = createRebaseClient({
baseUrl: API_URL,
offline: {
onSyncError: (error, mutation) => {
toast(`Couldn't save your change to ${mutation.collection}: ${error.message}`);
}
}
});

Eine Zeile, die jemand anders gelöscht hat, ist der eine Fall, der nicht zurückgerollt wird. Ein eingereihtes delete darauf hat getan, wofür es gedacht war, und zählt daher als synchronisiert und wird nicht gemeldet. Eine eingereihte Bearbeitung davon wird gemeldet, und die Zeile wird aus der lokalen Datenbank entfernt, statt wiederhergestellt zu werden, weil der Server sie nicht mehr hat.

Die Kaskade ist eng gefasst: Ein update wird zusammen mit dem Schreibvorgang verworfen, den es bearbeitet hat, weil es nur auf dieselbe Weise scheitern kann. Ein späteres create oder delete für dieselbe Zeile steht für sich und bleibt erhalten. Ein abgelehntes create einer offline erstellten Zeile nimmt außerdem jeden eingereihten Schreibvorgang in jeder Collection mit, dessen Werte auf die temporäre ID der Zeile verweisen. Ein verworfener Schreibvorgang wurde nie gesendet. Er wird mit einem RebaseApiError gemeldet, dessen code DEPENDENCY_REJECTED ist und dessen cause die Ablehnung ist, die ihn zu Fall brachte — so lässt er sich von dem Schreibvorgang unterscheiden, den der Server tatsächlich abgelehnt hat. Unter 0.23 trägt er stattdessen den eigenen Fehler des abgelehnten Schreibvorgangs.

Aufeinanderfolgende Bearbeitungen einer Zeile im Offline-Zustand werden zu einem einzigen eingereihten Schreibvorgang zusammengeführt, sodass ein Formular, in das gerade getippt wird, die Warteschlange nicht wachsen lässt. In einen Schreibvorgang, den die Warteschlange bereits zu senden begonnen hat, wird nicht hineingeführt: Eine Bearbeitung nach einem Wiederholungsversuch, dessen Antwort verloren ging, wird als eigenes Update eingereiht, und ein Löschen einer offline erstellten Zeile wird gesendet, statt gegen ihr Erstellen aufgerechnet zu werden, da der Server die Zeile möglicherweise bereits hält. Lehnt der Server den zusammengeführten Schreibvorgang wegen seines Inhalts ab (ein 400, 403 oder 422 — etwa ein Feld, das durch eine Schema-Änderung entfernt wurde, während der Benutzer offline war), teilt die Engine ihn wieder in die einzelnen Bearbeitungen auf und spielt jede erneut ab. Nur die Bearbeitung, die der Server ablehnt, wird zurückgerollt und gemeldet; die anderen bleiben erhalten.

Standardmäßig überschreibt eine offline eingereihte Bearbeitung, was der Server für die Felder hat, die sie ändert, auch wenn jemand anders sie inzwischen geändert hat. Um es stattdessen zu erfahren, übergeben Sie onConflict. Ein eingereihtes update wird dann mit If-Match wiederholt, der Version der Zeile, gegen die es gemacht wurde, und hat sich die Zeile seither geändert, lehnt der Server es mit 412 ab, und onConflict entscheidet:

const client = createRebaseClient({
baseUrl: API_URL,
offline: {
onConflict: async (error, mutation) =>
(await askUser(`${mutation.collection} changed while you were offline. Keep your edit?`))
? "overwrite"
: "discard"
}
});
  • "overwrite" sendet die Bearbeitung erneut ohne die Vorbedingung: Der letzte Schreiber gewinnt, wie ohne onConflict.
  • "discard" verwirft die Bearbeitung. Die Zeile kehrt zu dem zurück, was sie vorher war, wie bei einer Ablehnung, und wird erneut vom Server gelesen. Es wird nicht an onSyncError gemeldet; Schreibvorgänge, die darauf eingereiht wurden, schon, als DEPENDENCY_REJECTED.

Die Warteschlange wartet, während onConflict läuft, sodass es den Benutzer fragen kann. Wirft es, wird die Bearbeitung überschrieben.

Die Version, gegen die eine Bearbeitung gemacht wird, ist die, die der letzte Lesezugriff der Zeile meldete: findById(), ein listenById()-Frame oder die Antwort eines früheren update(). Eine Zeile, die zuletzt in einer find()- oder listen()-Liste gelesen wurde, hat keine Version, ebenso wenig eine Bearbeitung, die auf einen anderen eingereihten Schreibvorgang derselben Zeile aufsetzt. Diese werden ohne Vorbedingung wiederholt. Ein ifMatch, das Sie selbst an update() oder delete() übergeben, bleibt erhalten, wenn der Schreibvorgang eingereiht wird, mit oder ohne onConflict, und findById() auf einem Offline-fähigen Client gibt die Zeile mit ihrer Version zurück, sodass etagOf(row) funktioniert wie online. Ohne onConflict wird ein eingereihter Schreibvorgang, der wegen Ihres eigenen ifMatch abgelehnt wurde, zurückgerollt und mit einem 412 PRECONDITION_FAILED an onSyncError gemeldet.

Ein Fehlschlag, der lediglich vorübergehend ist — ein 429, ein 503, eine abgebrochene Verbindung —, ist keine Ablehnung. Solche bleiben in der Warteschlange und werden erneut versucht; erst nach maxRetries Zurückstellungen wird ein Schreibvorgang zurückgerollt.

Tabs derselben App teilen sich eine IndexedDB-Datenbank und damit die lokalen Zeilen und die Outbox. Ein Schreibvorgang in einem Tab erscheint in den anderen, und immer nur ein Tab zugleich wiederholt die Warteschlange. Es gibt nichts zu konfigurieren.

Diese letzte Garantie stammt von der Web Locks API des Browsers, die nur in einem sicheren Kontext (HTTPS oder localhost) existiert und nicht in Safari vor 15.4 oder in einigen eingebetteten Webviews. Ohne sie wiederholt jeder offene Tab die Warteschlange, wenn er synchronisiert. Creates, Bulk-Schreibvorgänge, Updates und Deletes werden alle mit einem Idempotenzschlüssel wiederholt, sodass ein Server, der Schlüssel speichert, jeden davon einmal anwendet; sichtbar ist dann derselbe Schreibvorgang, aus zwei Tabs gesendet.

Die lokale Datenbank und die Outbox sind pro angemeldetem Benutzer partitioniert. Zwischengespeicherte Zeilen sind das, was Row-Level Security diesen Benutzer sehen ließ, und ein eingereihter Schreibvorgang muss als sein Urheber wiederholt werden — sich abzumelden und als jemand anders wieder anzumelden vermischt die beiden also nie. Beim Abmelden muss nichts geleert werden.

Solange der Client nicht weiß, wer angemeldet ist, weiß er nicht, welche lokale Datenbank und welche Outbox er verwenden soll — Offline-Lese- und -Schreibvorgänge warten daher. Das ist die Zeit, die auth.isInitialized() braucht: keine für eine gespeicherte Sitzung, die noch gültig ist, und eine Refresh-Anfrage, wenn die Sitzung vom Server zurückkommen muss — im Cookie-Modus, in dem nichts auf dem Gerät gespeichert wird, ist das bei jedem Seitenaufruf der Fall. Ein in diesem Zeitraum gemachter Schreibvorgang wird für den Benutzer eingereiht, der sich als Inhaber der Sitzung herausstellt, und ein Lesevorgang antwortet aus dessen lokaler Datenbank. Ein Realtime-Ereignis, das in der Zwischenzeit eintrifft, wird so an Ihren Callback übergeben, wie der Server es gesendet hat, und nirgends gespeichert.

Lässt sich die Sitzung nicht wiederherstellen — der Refresh wird abgelehnt oder erreicht im Cookie-Modus den Server nicht —, ist der Client abgemeldet, und Offline-Lese- und -Schreibvorgänge gehören dem abgemeldeten Benutzer. Unter 0.23 behandelte der Cookie-Modus den ganzen Zeitraum als abgemeldet, sodass ein darin gemachter Schreibvorgang für den abgemeldeten Benutzer eingereiht wurde.

Schreibvorgänge, die eingereiht wurden, während niemand angemeldet war, werden nicht wiederholt, wenn sich jemand anmeldet. Dieser Benutzer hat sie nicht gemacht, und sie mit seinen Anmeldedaten zu wiederholen, würde die Änderungen einer Person unter dem Namen einer anderen ablegen — auf einem gemeinsam genutzten Gerät die Schreibvorgänge eines Besuchers im Konto des nächsten Benutzers. Sie werden auch nicht in die Outbox des angemeldeten Benutzers verschoben. Sie bleiben erhalten und werden getrennt aufgelistet, damit die App entscheiden kann, was mit ihnen geschieht:

client.auth.onAuthStateChange(async (event) => {
if (event !== "SIGNED_IN") return;
const orphaned = await client.offline!.pending({ orphaned: true });
if (orphaned.length === 0) return;
if (await askToKeep(orphaned.length)) {
// Re-issue each one yourself, now as the signed-in user.
for (const write of orphaned) await reissue(write);
}
await client.offline!.clear({ orphaned: true });
});

clear({ orphaned: true }) verwirft sie, zusammen mit den lokalen Zeilen, auf die sie angewendet wurden; die eigene Outbox des angemeldeten Benutzers bleibt unberührt. Lässt man sie liegen, werden sie wiederholt, sobald wieder niemand angemeldet ist. Solange niemand angemeldet ist, sind sie einfach die aktuelle Outbox, und pending({ orphaned: true }) listet nichts.

Im Cookie-Modus wird nichts über die Sitzung auf dem Gerät gespeichert, sodass ein Neuladen, das den Server nicht erreicht, keinen Benutzer hat. Standardmäßig wird es als abgemeldet behandelt: Die lokalen Zeilen des Benutzers sind verborgen, und dann getätigte Schreibvorgänge werden als die des abgemeldeten Benutzers eingereiht.

keepLastUser: true ändert das. Der Client merkt sich den letzten Benutzer, der sich auf dem Gerät angemeldet hat (nur seine ID), und ein Offline-Neuladen öffnet stattdessen die lokale Datenbank dieses Benutzers. Dann getätigte Schreibvorgänge werden als seine eingereiht, aber keiner wird wiederholt, bevor die Sitzung bestätigt ist: Ist die Verbindung zurück, erneuert der Client die Sitzung, und auch eine Anmeldung bestätigt sie. Lehnt der Server die Sitzung ab oder meldet sich jemand anders an, wechselt das Gerät den Benutzer, und die eingereihten Schreibvorgänge bleiben bei ihrem Urheber, bis er sich erneut anmeldet. Währenddessen ist status().unverifiedUser true, und das gilt auch online: Bis die Erneuerung antwortet, werden Schreibvorgänge eingereiht, statt ohne Anmeldedaten hinauszugehen, und Lesezugriffe werden aus den lokalen Zeilen des Benutzers beantwortet. Was der Server einer Anfrage ohne Sitzung sendet, ist das, was ein abgemeldeter Aufrufer sehen darf, und wird deshalb nicht aufbewahrt.

createRebaseClient({
baseUrl: API_URL,
auth: { authFlowMode: "cookie" },
offline: { keepLastUser: true }
});

Es ist standardmäßig aus, weil es auf einem gemeinsam genutzten Gerät erlaubt, die zwischengespeicherten Zeilen des vorherigen Benutzers offline ohne Sitzung zu lesen. Schalten Sie es für Installationen ein, die einer einzelnen Person gehören. Die ID wird bei der Abmeldung vergessen und wenn der Server die Sitzung ablehnt. Sie wird nie verwendet, wenn der Server antwortet, dass es keine Sitzung gibt.

createRebaseClient({
baseUrl: API_URL,
offline: {
store: myCustomStore, // default: IndexedDB in the browser, memory elsewhere
maxCachedRowsPerCollection: 5000, // rows with unsent writes are never evicted
maxCachedQueriesPerCollection: 50, // remembered server page compositions
syncIntervalMs: 60_000, // ceiling for the retry backoff; 0 disables auto-retry
maxRetries: 5, // deferrals before a write is given up on
crossTab: true, // default: on for IndexedDB, off for memory
keepLastUser: false, // open the last user's data on an offline reload (cookie mode)
replayBatchSize: 1, // queued creates sent per request on replay (max 1000)
onConflict: (error, mutation) => "overwrite", // or "discard"; default: no conflict check
onSyncError: (error, mutation) => {}
}
});

Jede Umgebung kann die lokale Datenbank persistieren, indem sie OfflineStore implementiert — eine namensraumgetrennte Key/Value-Oberfläche mit einem Bereich für den Lese-Cache und einem für die Warteschlange. So hinterlegen Sie sie in React Native mit AsyncStorage oder in Electron mit dem Dateisystem:

import type { OfflineStore } from "@rebasepro/client";
class AsyncStorageOfflineStore implements OfflineStore {
// Read cache: getCache, setCache, setCacheMany, deleteCache,
// listCache, listCacheEntries
// Outbox: enqueue, dequeue, listQueue
// Both: clear
}

Der einzige Vertrag über das Offensichtliche hinaus ist, dass Präfix-Auflistungen in lexikographischer Schlüsselreihenfolge zurückkommen — genau das macht die Outbox zu einer FIFO-Warteschlange.

Der Client ist kein Replikat Ihrer Datenbank und gibt auch nicht vor, eines zu sein:

  • Nur Zeilen, die die App gelesen oder geschrieben hat, liegen lokal vor. Eine Abfrage, die der Client nie gesendet hat, kann trotzdem aus dem Vorhandenen beantwortet werden, aber der Antwort können Zeilen fehlen, die der Server zurückgegeben hätte. Live-Ergebnisse sagen das über partial.
  • findAll() und iterate() lesen nur Seiten, die der Server auf diesem Gerät beantwortet hat. Ein Durchlauf geht weiter, solange jede Seite sagt, dass es mehr gibt, und das kann nur der Server sagen. Offline scheitert ein Durchlauf, der eine Seite braucht, die dieses Gerät nie abgerufen hat, mit dem OFFLINE-Fehler (isOfflineError), statt einen Teil der Collection zurückzugeben, als wäre es alles. Auf 0.24 hörte er an dieser Seite auf und gab zurück, was er hatte.
  • count() offline braucht eine Zählung, die der Server diesem Gerät für dieselbe Abfrage gegeben hat. Seither eingereihte Schreibvorgänge verschieben sie: Creates, Bearbeitungen, die eine Zeile in den Filter hinein- oder aus ihm herausnehmen, und Deletes. Ohne eine scheitert er mit dem OFFLINE-Fehler, statt die Zeilen zu zählen, die dieses Gerät zufällig hält. Auf 0.24 gab er diese Zahl zurück.
  • searchString wird angenähert als Teilstringsuche über zwischengespeicherte String-Felder ohne Beachtung der Groß-/Kleinschreibung. Der Server führt eine echte Volltextsuche über die konfigurierten Spalten der Collection aus.
  • Mit include eingebundene Relationen lassen sich lokal nicht auswerten — die verwandten Zeilen liegen in Collections, die die Abfrage nie geladen hat. Eine solche Abfrage wird immer als partial markiert, wenn sie aus dem Cache beantwortet wird.
  • Konflikte sind Last-Writer-Wins, pro Feld, sofern Sie nicht onConflict übergeben. Ein wiederholtes update sendet die Felder, die der Benutzer geändert hat, und nichts sonst, sodass Felder, die er nicht angefasst hat, behalten, was der Server hat. Bei einem Feld, das er geändert hat, wird eine Änderung, die jemand anders auf dem Server vornahm, während er offline war, überschrieben, wenn die Warteschlange wiederholt, und keiner der beiden Benutzer erfährt es. Mit onConflict wird eine Bearbeitung, deren Zeilenversion bekannt ist, gegen die des Servers geprüft, und die App entscheidet. Creates, Deletes und Bulk-Schreibvorgänge werden nie geprüft.
  • Das Wiederholen erfolgt mindestens einmal. Ein Schreibvorgang, der den Server erreicht, dessen Antwort aber verloren geht, kann erneut gesendet werden. Bevorzugen Sie idempotente Schreibvorgänge (createMany mit upsert), wo Duplikate eine Rolle spielen würden.
  • Der Papierkorb braucht den Server. Die lokale Datenbank hält nur aktive Zeilen, sodass trash.find(), trash.count(), trash.restore() und trash.purge() (siehe Soft Delete) nie lokal beantwortet oder eingereiht werden: Offline schlagen sie mit dem Fehler OFFLINE fehl. Eine Wiederherstellung oder ein endgültiges Löschen einer Zeile mit noch eingereihtem Schreibvorgang synchronisiert zuerst die Warteschlange und wird mit TRASH_PENDING_WRITE abgelehnt, wenn der Schreibvorgang noch nicht gesendet werden kann.
  • Ein Upsert auf einem natürlichen Schlüssel braucht den Server. Welche Zeile ein onConflict-Ziel bezeichnet, kann nur der Server herausfinden, daher werden upsert() auf einem natürlichen Schlüssel (oder ohne die ID der Zeile) und ein nie gesendetes createMany() mit onConflict offline mit OFFLINE_UPSERT_UNSUPPORTED abgelehnt. Ein Upsert auf dem Primärschlüssel, mit dem Schlüssel in jeder Zeile, wird in die Warteschlange gestellt.
  • Lokale Lesevorgänge wenden Postgres-Semantik an, nicht die Daten der Datenbank. Filter werden so ausgewertet, wie SQL es täte — Vergleiche mit NULL sind unbekannt, ORDER BY stellt Nullwerte bei aufsteigender Sortierung ans Ende und löst die verbleibenden Gleichstände nach der Zeilen-ID absteigend auf, genau wie der Server —, aber gegen die Kopie der Zeilen im Client, die veraltet sein kann.
  • Eine Sortierung, die der Client nicht reproduzieren kann, wird nicht angewendet. Text zu ordnen ist Sache des Servers: PostgreSQL sortiert ihn nach der Kollation der Datenbank, die dieser Prozess nie erfahren hat, und unter der C-Kollation steht Banana vor apple, wo ein JavaScript-Collator etwas anderes sagt. Lässt sich eine Sortierspalte nicht numerisch vergleichen, behalten die Zeilen die Reihenfolge, in der der Server sie gesendet hat, und das Ergebnis wird als partial markiert, statt als die angeforderte sortierte Seite präsentiert zu werden. Jeder Schlüssel einer Sortierung über mehrere Spalten muss reproduzierbar sein, nicht nur der erste — eine Sortierung, in der die lokale Seite nur bis zur zweiten Spalte übereinstimmt, ist eine, in der sie abweicht.
import React from "react";
import type { CreateRebaseClientResult } from "@rebasepro/client";
export function SyncIndicator({ client }: { client: CreateRebaseClientResult }) {
const offline = client.offline!;
const [status, setStatus] = React.useState(offline.status());
React.useEffect(() => offline.onStatusChange(setStatus), [offline]);
if (!status.online) {
return <span className="badge warning">
Offline{status.pending ? ` · ${status.pending} unsaved` : ""}
</span>;
}
if (status.syncing) return <span className="badge">Syncing…</span>;
if (status.pending) return <span className="badge">{status.pending} unsaved</span>;
return null;
}