Hors ligne et synchronisation local-first
Vue d’ensemble
Section intitulée « Vue d’ensemble »La prise en charge du hors ligne transforme la couche de données du SDK en un moteur de synchronisation local-first. Au lieu d’un cache qui mémorise des réponses, le client conserve une petite base de données locale de lignes, y répond aux requêtes, et considère le réseau comme ce qui la remplit et finit par accepter vos écritures.
Trois conséquences en découlent :
- Les lectures survivent à la disparition du réseau. Une requête que le client peut évaluer localement est évaluée localement — filtres, tri et pagination compris — de sorte qu’une liste continue de s’afficher avec une connexion morte.
- Les écritures sont décidées localement. Une écriture effectuée hors ligne s’applique immédiatement, est mise en file d’attente, puis rejouée dans l’ordre au retour de la connexion. Si le serveur la rejette, la modification locale est annulée.
- Les lectures sont réactives.
observe()émet d’abord depuis la base de données locale, puis réémet dès que quoi que ce soit modifie les lignes qu’elle couvre — vos propres écritures, une écriture en file d’attente qui aboutit, une annulation, un autre onglet du navigateur ou un événement temps réel.
C’est désactivé par défaut. Activez-le avec une seule option :
const client = createRebaseClient({ baseUrl: "https://api.example.com", offline: true});Dans le navigateur, tout est persisté dans IndexedDB : un rechargement conserve à la fois les lignes locales et les écritures non envoyées. Ailleurs (Node, tests), le SDK se rabat sur la mémoire ; d’autres environnements d’exécution peuvent fournir leur propre store.
Ce qui change
Section intitulée « Ce qui change »Rien dans l’API que vous utilisez déjà ne change de forme. find(), findById(), create(), update(), delete() et le constructeur de requêtes fluide conservent leurs signatures et leurs types de retour — ils cessent simplement d’échouer quand le réseau échoue.
Lectures
Section intitulée « Lectures »Une lecture réussie fusionne ses lignes dans la base de données locale et retient quels ids le serveur a renvoyés pour cette requête. Lorsqu’une lecture ne peut pas joindre le serveur, elle reçoit une réponse locale :
const drafts = await client.data.posts .where("status", "==", "draft") .orderBy("updatedAt", "desc") .find();Hors ligne, cela filtre et trie les lignes que le client détient. Cela inclut les lignes récupérées par d’autres requêtes — la base de données est normalisée, donc une ligne n’est stockée qu’une seule fois, quel que soit le nombre de listes où elle est apparue — ainsi que les lignes que vous avez créées hors ligne.
S’il n’y a véritablement rien pour répondre (une collection que l’application n’a jamais lue), la lecture lève une erreur reconnaissable plutôt qu’un simple TypeError :
import { isOfflineError } from "@rebasepro/client";
try { await client.data.posts.find();} catch (error) { if (isOfflineError(error)) showOfflinePlaceholder(); else throw error;}Écritures
Section intitulée « Écritures »Tant que la connexion est connue comme coupée, une écriture n’est même pas tentée — elle s’applique localement et se met en file d’attente, ce qui ne coûte rien au lieu d’un délai d’attente expiré :
// 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();Les lignes créées hors ligne reçoivent un id généré par le client, dans le type que les ids de la collection ont déjà : un entier négatif là où ce sont des nombres, une chaîne UUID là où ce sont des chaînes. Le signe est l’indice — une vraie clé n’est jamais négative —, si bien qu’une ligne qui n’a pas encore atteint le serveur se reconnaît sans consultation, et id reste ce que le type Row généré annonce. Sur une collection que cet appareil n’a jamais lue, rien en local ne permet de déduire le type, et l’id est un UUID.
Si le serveur attribue le sien au moment du rejeu, la ligne locale et toutes les écritures en file d’attente qui pointent encore vers l’id temporaire sont basculées vers l’id réel, dans chaque collection : un article en file d’attente dont author_id désigne un auteur créé hors ligne est envoyé avec l’id que le serveur a donné à cet auteur. Si le serveur refuse l’auteur à la place, les écritures qui le référencent sont écartées avec lui et signalées comme DEPENDENCY_REJECTED (voir Quand le serveur refuse). D’ici là, ne conservez pas un id temporaire hors de la base hors ligne : une clé étrangère ou une URL mise en signet qui en contient un désigne une ligne sur le point d’être renumérotée.
Les écritures sont rejouées dans l’ordre où vous les avez faites, toutes collections confondues — ainsi une création dans une collection arrive toujours avant la ligne d’une autre collection qui la référence.
Chaque écriture en file d’attente est une requête par défaut : 500 lignes saisies hors ligne demandent donc 500 allers-retours pour se vider. Avec replayBatchSize défini (jusqu’à 1 000), une suite de créations consécutives hors ligne dans une même collection est rejouée en un seul createMany. Seules les créations dont l’id est une chaîne générée par le SDK sont regroupées, et jamais une qui référence une autre ligne du même lot. Si le serveur refuse ou reporte le lot pour une raison autre qu’une connexion perdue, rien n’est écrit (une écriture groupée est une seule transaction), et le lot est rejoué écriture par écriture, comme par défaut.
Requêtes en direct
Section intitulée « Requêtes en direct »observe() est la lecture réactive, et celle vers laquelle se tourner dans une interface :
const unsubscribe = client.data.posts.observe( { where: { status: ["==", "draft"] }, orderBy: ["updatedAt", "desc"] }, (result) => { render(result.data); setBadge(result.hasPendingWrites ? "saving…" : null); });La première émission provient de la base de données locale, sans requête intermédiaire ; une revalidation suit en arrière-plan. Ensuite, elle réémet à chaque changement des lignes qu’elle couvre. Les émissions sont dédupliquées — un rafraîchissement qui ne change rien ne rappelle pas le callback — vous pouvez donc afficher directement à partir de là.
Chaque résultat porte ce dont une interface a besoin pour se décrire :
| Champ | Signification |
|---|---|
data, meta |
La même forme que celle renvoyée par find() |
fromCache |
Les lignes proviennent de la base de données locale, pas d’une requête aboutie |
hasPendingWrites |
Au moins une ligne ici porte une écriture que le serveur n’a pas acceptée |
partial |
La base de données locale ne détient peut-être pas toutes les lignes correspondantes — à traiter comme un résultat « au mieux » |
error |
La dernière revalidation a échoué |
observeById() fait de même pour une seule ligne, et passe undefined lorsqu’elle est supprimée. Il accepte include comme findById(), et chaque émission porte ces relations :
const stop = client.data.posts.observeById(postId, (post) => render(post), undefined, { include: ["tags"]});Les deux se lient à l’abonnement temps réel lorsque le client en a un, de sorte que les changements effectués par d’autres utilisateurs arrivent aussi en flux. Passez { realtime: false } pour un abonnement qui ne reflète que l’état local et les rafraîchissements explicites.
Sans offline activé, observe() existe quand même : il récupère les données une fois et reste en direct grâce au temps réel, les trois indicateurs restant à false.
État de la synchronisation
Section intitulée « État de la synchronisation »client.offline expose le moteur, à partir duquel se construit un indicateur de synchronisation :
const unsubscribe = client.offline!.onStatusChange((status) => { setOnline(status.online); setPending(status.pending); setSyncing(status.syncing);});
// Or read it onceconst { online, pending, syncing, lastSyncedAt, lastError } = client.offline!.status();| Méthode | Rôle |
|---|---|
status() |
Connectivité actuelle, profondeur de la file d’attente, activité de synchronisation, dernière erreur, et persisted : indique si le navigateur a accepté de conserver la base hors ligne (demandé lorsque la première écriture est mise en file ; false signifie qu’elle peut être évincée en cas de pression sur le stockage, écritures non envoyées comprises). unverifiedUser vaut true tant qu’un rechargement hors ligne montre les données du dernier utilisateur sans session confirmée (voir keepLastUser) |
onStatusChange(fn) |
S’abonner à ce qui précède |
onQueueChange(fn) |
Uniquement le nombre d’écritures non envoyées, pour un badge |
pending() |
Les mutations en file d’attente elles-mêmes, les plus anciennes d’abord. pending({ orphaned: true }) : celles mises en file d’attente sans session ouverte — voir Utilisateurs |
sync() |
Rejouer maintenant — résout avec { flushed, remaining } |
clear() |
Écarter les écritures en file d’attente et les lignes locales de l’utilisateur courant. clear({ orphaned: true }) : celles mises en file d’attente sans session ouverte, à la place |
Le rejeu se déclenche de lui-même : quand le navigateur émet online, quand l’utilisateur se connecte, et selon un backoff exponentiel (une seconde, doublant jusqu’à une minute) tant que quelque chose reste en file d’attente. sync() sert à un bouton « réessayer maintenant ».
Quand le serveur refuse
Section intitulée « Quand le serveur refuse »Une écriture en file d’attente peut être rejetée — validation, sécurité au niveau des lignes, une ligne que quelqu’un d’autre a supprimée. Ces cas ne se résolvent jamais d’eux-mêmes : le moteur ramène donc les lignes locales à leur état d’avant l’écriture, écarte les modifications en file d’attente qui s’appuyaient dessus, et vous prévient :
const client = createRebaseClient({ baseUrl: API_URL, offline: { onSyncError: (error, mutation) => { toast(`Couldn't save your change to ${mutation.collection}: ${error.message}`); } }});Une ligne supprimée par quelqu’un d’autre est le seul cas qui n’est pas annulé. Un delete en file d’attente de cette ligne a fait ce pour quoi il était fait : il compte comme synchronisé et n’est pas signalé. Une modification en file d’attente de cette ligne est signalée, et la ligne est retirée de la base locale au lieu d’être restaurée, car le serveur ne l’a plus.
La cascade est étroite : un update est écarté en même temps que l’écriture qu’il modifiait, car il ne peut échouer que de la même manière. Un create ou un delete ultérieur portant sur la même ligne se suffit à lui-même et est conservé. Un create refusé d’une ligne créée hors ligne emporte aussi avec lui toute écriture en file d’attente, dans n’importe quelle collection, dont les valeurs référencent l’id temporaire de la ligne. Une écriture écartée n’a jamais été envoyée. Elle est signalée avec une RebaseApiError dont le code est DEPENDENCY_REJECTED et dont la cause est le refus qui l’a condamnée, ce qui permet de la distinguer de l’écriture que le serveur a réellement refusée. Sur la 0.23 elle porte à la place l’erreur propre de l’écriture refusée.
Des modifications consécutives sur une même ligne pendant la déconnexion sont fusionnées en une seule écriture mise en file d’attente, de sorte qu’un formulaire en cours de saisie ne fait pas grossir la file. Une écriture que la file a commencé à envoyer n’absorbe plus rien : une modification faite après une tentative de rejeu dont la réponse s’est perdue est mise en file comme une mise à jour distincte, et la suppression d’une ligne créée hors ligne est envoyée au lieu d’être annulée contre sa création, puisque le serveur détient peut-être déjà la ligne. Si le serveur refuse l’écriture fusionnée en raison de son contenu (un 400, 403 ou 422 — par exemple un champ supprimé par un changement de schéma pendant que l’utilisateur était hors ligne), le moteur la scinde à nouveau en modifications séparées et rejoue chacune d’elles. Seule la modification que le serveur refuse est annulée et signalée ; les autres sont conservées.
Conflits
Section intitulée « Conflits »Par défaut, une modification mise en file d’attente hors ligne écrase ce que le serveur possède pour les champs qu’elle change, même si quelqu’un d’autre les a modifiés entre-temps. Pour le savoir plutôt, passez onConflict. Un update en file d’attente est alors rejoué avec If-Match, la version de la ligne sur laquelle il a été fait, et lorsque la ligne a changé depuis, le serveur le refuse avec un 412 et onConflict tranche :
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"renvoie la modification sans la précondition : le dernier écrivain gagne, comme sansonConflict."discard"abandonne la modification. La ligne revient à ce qu’elle était avant elle, comme pour un rejet, et est relue depuis le serveur. Elle n’est pas signalée àonSyncError; les écritures mises en file par-dessus le sont, sous la formeDEPENDENCY_REJECTED.
La file attend pendant que onConflict s’exécute, il peut donc interroger l’utilisateur. S’il lève une exception, la modification est écrasée.
La version sur laquelle une modification est faite est celle qu’a rapportée la dernière lecture de la ligne : findById(), une trame listenById(), ou la réponse d’un update() antérieur. Une ligne lue en dernier dans une liste find() ou listen() n’a pas de version, pas plus qu’une modification faite par-dessus une autre écriture en file sur la même ligne. Celles-ci sont rejouées sans précondition. Un ifMatch que vous passez vous-même à update() ou à delete() est conservé lorsque l’écriture est mise en file, avec ou sans onConflict, et findById() sur un client à mode hors ligne renvoie la ligne avec sa version, de sorte que etagOf(row) fonctionne comme en ligne. Sans onConflict, une écriture en file refusée à cause de votre propre ifMatch est annulée et signalée à onSyncError avec un 412 PRECONDITION_FAILED.
Un échec simplement temporaire — un 429, un 503, une connexion coupée — n’est pas un rejet. Ces écritures restent en file d’attente et sont réessayées ; ce n’est qu’après maxRetries reports qu’une écriture est annulée.
Onglets multiples
Section intitulée « Onglets multiples »Les onglets d’une même application partagent une seule base IndexedDB : ils partagent donc les lignes locales et la file d’attente d’envoi. Une écriture dans l’un apparaît dans les autres, et un seul onglet à la fois rejoue la file d’attente. Rien à configurer.
Cette dernière garantie vient de l’API Web Locks du navigateur, qui n’existe que dans un contexte sécurisé (HTTPS, ou localhost) et pas dans Safari avant la 15.4 ni dans certaines webviews intégrées. Sans elle, chaque onglet ouvert rejoue la file d’attente lorsqu’il se synchronise. Les créations, écritures en masse, mises à jour et suppressions sont toutes rejouées avec une clé d’idempotence : un serveur qui stocke les clés applique donc chacune une seule fois ; ce que vous pouvez voir, c’est la même écriture envoyée depuis deux onglets.
Utilisateurs
Section intitulée « Utilisateurs »La base de données locale et la file d’attente d’envoi sont cloisonnées par utilisateur connecté. Les lignes en cache sont celles que la sécurité au niveau des lignes a laissé voir à cet utilisateur, et une écriture en file d’attente doit être rejouée en tant que son auteur — se déconnecter puis se reconnecter sous une autre identité ne mélange donc jamais les deux. La déconnexion n’a besoin de rien effacer.
Pendant la restauration de la session
Section intitulée « Pendant la restauration de la session »Tant que le client ne sait pas qui est connecté, il ne sait pas quelle base de données locale ni quelle file d’attente d’envoi utiliser : les lectures et écritures hors ligne attendent donc. C’est le temps que prend auth.isInitialized() : aucun pour une session stockée encore valide, et une requête de rafraîchissement quand la session doit revenir du serveur — en mode cookie, où rien n’est stocké sur l’appareil, c’est le cas à chaque chargement de page. Une écriture faite pendant cet intervalle est mise en file d’attente au nom de l’utilisateur que la session se révèle être, et une lecture répond depuis sa base de données locale. Un événement temps réel qui arrive entre-temps est transmis à votre callback tel que le serveur l’a envoyé, et n’est conservé nulle part.
Si la session ne peut pas être restaurée — le rafraîchissement est refusé ou, en mode cookie, n’atteint pas le serveur —, le client est déconnecté, et les lectures et écritures hors ligne sont celles de l’utilisateur déconnecté. Sur la 0.23, le mode cookie traitait tout cet intervalle comme déconnecté, si bien qu’une écriture faite pendant celui-ci était mise en file d’attente au nom de l’utilisateur déconnecté.
Écritures faites sans session ouverte
Section intitulée « Écritures faites sans session ouverte »Les écritures mises en file d’attente alors que personne n’était connecté ne sont pas rejouées quand quelqu’un se connecte. Cet utilisateur ne les a pas faites, et les rejouer avec ses identifiants placerait les modifications d’une personne au nom d’une autre — sur un appareil partagé, les écritures d’un visiteur dans le compte de l’utilisateur suivant. Elles ne sont pas non plus déplacées dans la file d’attente d’envoi de l’utilisateur connecté. Elles sont conservées et listées à part, pour que l’application décide quoi en faire :
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 }) les écarte, avec les lignes locales auxquelles elles ont été appliquées ; la propre file d’attente d’envoi de l’utilisateur connecté n’est pas touchée. Si on les laisse, elles sont rejouées la prochaine fois que personne n’est connecté. Tant que personne n’est connecté, elles sont simplement la file d’attente d’envoi courante, et pending({ orphaned: true }) ne liste rien.
Rechargements hors ligne en mode cookie
Section intitulée « Rechargements hors ligne en mode cookie »En mode cookie, rien sur la session n’est stocké sur l’appareil : un rechargement qui ne peut pas joindre le serveur n’a donc pas d’utilisateur. Par défaut, il est traité comme déconnecté : les lignes locales de l’utilisateur sont masquées, et les écritures faites alors sont mises en file comme celles de l’utilisateur déconnecté.
keepLastUser: true change cela. Le client mémorise le dernier utilisateur connecté sur l’appareil (son id seulement), et un rechargement hors ligne ouvre à la place la base locale de cet utilisateur. Les écritures faites alors sont mises en file comme les siennes, mais aucune n’est rejouée tant que la session n’est pas confirmée : lorsque la connexion revient, le client rafraîchit la session, et une connexion la confirme aussi. Si le serveur refuse la session, ou si quelqu’un d’autre se connecte, l’appareil change d’utilisateur, et les écritures en file restent à leur auteur jusqu’à ce qu’il se reconnecte. Pendant ce temps, status().unverifiedUser vaut true, et cela vaut aussi en ligne : tant que le rafraîchissement n’a pas répondu, les écritures sont mises en file au lieu de partir sans identifiants, et les lectures sont servies depuis les lignes locales de l’utilisateur. Ce que le serveur renvoie à une requête sans session est ce qu’un appelant déconnecté peut voir, ce n’est donc pas conservé.
createRebaseClient({ baseUrl: API_URL, auth: { authFlowMode: "cookie" }, offline: { keepLastUser: true }});Il est désactivé par défaut car, sur un appareil partagé, il permet de lire hors ligne, sans session, les lignes en cache de l’utilisateur précédent. Activez-le pour les installations qui appartiennent à une seule personne. L’id est oublié à la déconnexion et lorsque le serveur refuse la session. Il n’est jamais utilisé lorsque le serveur répond qu’il n’y a pas de session.
Configuration
Section intitulée « Configuration »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) => {} }});Un store personnalisé
Section intitulée « Un store personnalisé »N’importe quel environnement peut persister la base de données locale en implémentant OfflineStore — une surface clé/valeur à espaces de noms, avec une zone de cache de lecture et une zone de file d’attente. C’est ainsi que vous l’adossez à AsyncStorage dans React Native, ou au système de fichiers dans Electron :
import type { OfflineStore } from "@rebasepro/client";
class AsyncStorageOfflineStore implements OfflineStore { // Read cache: getCache, setCache, setCacheMany, deleteCache, // listCache, listCacheEntries // Outbox: enqueue, dequeue, listQueue // Both: clear}Le seul contrat au-delà de l’évidence est que les listages par préfixe reviennent dans l’ordre lexicographique des clés — c’est ce qui fait de la file d’attente d’envoi une FIFO.
Le client n’est pas une réplique de votre base de données, et il ne prétend pas l’être :
- Seules les lignes que l’application a lues ou écrites sont locales. Une requête que le client n’a jamais envoyée peut tout de même trouver réponse dans ce qu’il détient, mais il peut manquer à cette réponse des lignes que le serveur aurait renvoyées. Les résultats en direct le signalent via
partial. findAll()etiterate()ne lisent que les pages auxquelles le serveur a répondu sur cet appareil. Un parcours se poursuit tant que chaque page indique qu’il y en a d’autres, et seul le serveur peut le dire. Hors ligne, un parcours qui a besoin d’une page que cet appareil n’a jamais récupérée échoue avec l’erreurOFFLINE(isOfflineError) au lieu de renvoyer une partie de la collection comme si c’était tout. Sur la 0.24, il s’arrêtait à cette page et renvoyait ce qu’il avait.count()hors ligne a besoin d’un décompte que le serveur a donné à cet appareil pour la même requête. Les écritures mises en file depuis le déplacent : créations, modifications qui font entrer ou sortir une ligne du filtre, et suppressions. Sans décompte, il échoue avec l’erreurOFFLINEplutôt que de compter les lignes que cet appareil détient par hasard. Sur la 0.24, il renvoyait ce nombre.searchStringest approximé par un balayage de sous-chaîne insensible à la casse sur les champs texte en cache. Le serveur, lui, exécute une vraie recherche plein texte sur les colonnes configurées de la collection.- Les relations passées à
includene peuvent pas être évaluées localement — les lignes liées vivent dans des collections que la requête n’a jamais chargées. Une telle requête est toujours marquéepartiallorsqu’elle est servie depuis le cache. - Les conflits sont résolus par le dernier écrivain gagnant, champ par champ, sauf si vous passez
onConflict. Unupdaterejoué envoie les champs que l’utilisateur a modifiés et rien d’autre : les champs qu’il n’a pas touchés gardent ce que possède le serveur. Pour un champ qu’il a bien modifié, un changement fait par quelqu’un d’autre sur le serveur pendant qu’il était hors ligne est écrasé au rejeu de la file, et aucun des deux utilisateurs n’en est informé. AveconConflict, une modification dont la version de ligne est connue est comparée à celle du serveur, et l’application tranche. Les créations, suppressions et écritures groupées ne sont jamais vérifiées. - La corbeille a besoin du serveur. La base locale ne contient que des lignes actives :
trash.find(),trash.count(),trash.restore()ettrash.purge()(voir suppression logique) ne sont donc jamais traités localement ni mis en file ; hors ligne, ils échouent avec l’erreurOFFLINE. La restauration ou la purge d’une ligne dont une écriture est encore en file synchronise d’abord la file, et est refusée avecTRASH_PENDING_WRITEsi l’écriture ne peut pas encore être envoyée. - Le rejeu est « au moins une fois ». Une écriture qui atteint le serveur mais dont la réponse se perd peut être renvoyée. Préférez des écritures idempotentes (
createManyavecupsert) là où des doublons poseraient problème. - Un upsert sur une clé naturelle a besoin du serveur. Seul le serveur peut trouver la ligne que désigne une cible
onConflict, doncupsert()sur une clé naturelle (ou sans l’identifiant de la ligne), et uncreateMany()aveconConflictjamais envoyé, sont refusés hors ligne avecOFFLINE_UPSERT_UNSUPPORTED. Un upsert sur la clé primaire, avec la clé dans chaque ligne, est mis en file d’attente. - Les lectures locales appliquent la sémantique de Postgres, pas les données de la base. Les filtres sont évalués comme SQL le ferait — les comparaisons avec
NULLsont inconnues,ORDER BYplace les valeurs nulles en dernier en ordre croissant et départage les égalités restantes par l’identifiant de ligne décroissant, exactement comme le fait le serveur — mais sur la copie des lignes détenue par le client, qui peut être obsolète. - Un tri que le client ne peut pas reproduire n’est pas appliqué. Ordonner du texte revient au serveur : PostgreSQL le trie selon la collation de la base de données, que ce processus n’a jamais reçue, et sous la collation C
Bananase classe avantapplelà où un collator JavaScript dit le contraire. Lorsqu’une colonne de tri ne peut pas être comparée numériquement, les lignes gardent l’ordre envoyé par le serveur, et le résultat est marquépartialau lieu d’être présenté comme la page triée qui avait été demandée. Chaque clé d’un tri multi-colonnes doit être reproductible, et pas seulement la première — un tri que le côté local ne peut approuver que jusqu’à sa deuxième colonne est un tri sur lequel il est en désaccord.
Recette : un indicateur hors ligne
Section intitulée « Recette : un indicateur hors ligne »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;}Voir aussi
Section intitulée « Voir aussi »- Interroger les données — la surface de requête qu’
observe()partage avecfind() - Abonnements en temps réel — les mises à jour poussées par le serveur, sur lesquelles reposent les requêtes en direct