OdoKeep est sur l’App Store. Téléchargez-la gratuitement.

Tous les articles

Synchronisation hors ligne

Protéger les modifications hors ligne avec React Native, MMKV et Supabase

Comment une file MMKV gère suppressions en cours, réponses perdues et modifications obsolètes dans React Native avec Supabase.

Mis à jour le

Dans cet article

Un enregistrement qui revient après sa suppression suffit à faire perdre confiance dans un carnet de bord.

C'était un des cas que je devais résoudre dans OdoKeep, mon application de suivi des véhicules. On pouvait créer une entrée, la supprimer pendant que l'insertion voyageait encore, puis la retrouver à la synchronisation suivante. La suppression locale fonctionnait. L'insertion sur le serveur aussi. C'était l'explication conservée par la file qui était fausse.

OdoKeep utilise React Native et Expo, MMKV pour la persistance locale et Supabase pour la copie serveur. Les dépôts écrivent localement et placent les opérations dans une file. Les écrans utilisent ces données pendant que le gestionnaire de synchronisation s'occupe du réseau.

On peut ainsi enregistrer un plein sans connexion. Mais la file doit décrire davantage qu'une liste de requêtes à réessayer.

Une création en attente n'est pas une création en cours

La première distinction utile sépare l'opération jamais envoyée de celle envoyée dont la réponse n'est pas encore arrivée.

Prenons une session hors ligne ordinaire :

Créer l'enregistrement local
Mettre INSERT en attente
Supprimer l'enregistrement local
Annuler INSERT dans la file

Inutile de contacter le serveur : l'enregistrement n'y a jamais existé. Réduire cette création et cette suppression à aucune opération est correct.

Ajoutons maintenant une connexion lente :

Créer l'enregistrement local
Envoyer INSERT
Supprimer l'enregistrement local
Annuler INSERT dans la file
Le serveur termine INSERT
La prochaine lecture retrouve l'enregistrement

Retirer une opération d'un tableau ne rappelle pas une requête déjà partie. La file qualifiait toujours la création de « pending », et la suppression y voyait la preuve que le serveur ne l'avait jamais reçue.

La correction consiste à suivre en mémoire les identifiants des opérations en cours. Dans lib/sync-queue.ts, la recherche d'une création annulable inclut cette condition :

const pendingCreate = pending.find(
  (op) => op.type === "create" && !this.inFlight.has(op.id),
);

Si la création est déjà partie, supprimer l'entrée doit ajouter une suppression serveur à la file. On ne peut plus annuler les deux opérations localement.

Cet ensemble reste volontairement en mémoire. Après un redémarrage, le processus n'a plus de requête active à suivre. Persister inFlight ferait croire au prochain lancement qu'une opération est toujours en cours, sans personne pour effacer cet état. La file durable survit, mais pas la description des requêtes actives du processus précédent.

Cet ensemble empêche aussi une seconde exécution d'envoyer une opération dont la première requête attend encore. Une reconnexion et un geste d'actualisation ne doivent pas déclencher la même insertion simultanément. Le gestionnaire protège l'exécution, et la file exclut elle-même les opérations actives de sa liste disponible. Même une demande manuelle qui ignore le délai entre tentatives respecte cette exclusion.

Retrouver une insertion dont la réponse a disparu

Le serveur peut valider une ligne alors que le téléphone perd la réponse.

La tentative suivante risque alors d'échouer parce que l'identifiant existe déjà. Traiter cela comme un rejet définitif annulerait localement un enregistrement correctement sauvegardé.

Pour une création ordinaire, le gestionnaire demande à PostgREST d'ignorer un identifiant dupliqué sans écraser la ligne. Si aucune ligne n'est renvoyée, il relit cet identifiant. Une ligne accessible confirme l'insertion, et l'appareil l'adopte. Si la vérification échoue, l'opération reste réessayable. Une collision illisible n'est pas assimilée à un succès.

Ce n'est pas une garantie de livraison exactement une fois. C'est une façon de retrouver la preuve d'une insertion sans remplacer les données d'une ligne existante.

Modifier la version réellement consultée

Le partage d'un véhicule ajoute un autre problème. Deux personnes peuvent modifier une entrée à partir de la même version serveur, éventuellement toutes deux hors ligne.

Une mise à jour filtrée uniquement par identifiant autorise la dernière synchronisation à effacer la modification précédente. Les deux requêtes réussissent, sans que personne apprenne que son travail a disparu.

Les mises à jour en file portent donc baseUpdatedAt, l'horodatage serveur connu avant la modification locale. Voici la condition essentielle, simplifiée depuis lib/sync-manager.ts :

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);

La mise à jour ne passe que si la ligne conserve la version sur laquelle reposait la modification. L'API de mise à jour Supabase peut renvoyer les lignes affectées avec .select(), rendant une absence de correspondance observable.

Il est facile de mal capturer cette base. Après une première modification, la ligne locale peut contenir un horodatage produit par le téléphone. Une seconde modification ne doit pas remplacer la base en file par cette valeur que le serveur n'a jamais émise. La fusion des opérations conserve la base serveur initiale et combine leurs données.

Une ligne nouvellement créée est un autre cas particulier. Tant que le serveur ne confirme pas sa création, son horodatage local ne constitue pas une précondition serveur. C'est pourquoi la file distingue une création encore enregistrée d'une création encore annulable.

Un résultat vide demande aussi une interprétation. Un autre auteur a peut-être modifié la ligne. Elle a pu être supprimée. Sur un véhicule partagé, l'accès ou le rôle de l'utilisateur a pu changer.

La branche de conflit vérifie qu'une ligne accessible existe avec un horodatage serveur plus récent. Seule cette preuve déclenche le message de modification obsolète. L'opération est définitivement refusée, l'utilisateur est informé et l'appareil se réconcilie avec le serveur. Réessayer sans la précondition réintroduirait précisément l'écrasement que l'on cherche à éviter.

La politique actuelle donne priorité à la copie serveur lors d'un conflit confirmé. Il n'y a ni fusion champ par champ ni éditeur de conflits. Une opération d'une ancienne version sans base ne peut pas recevoir cette protection rétroactivement. Ces limites font partie du contrat réel.

Avancer avec un repère fourni par le serveur

La récupération incrémentale exige la même prudence. Le téléphone peut avancer de deux minutes. S'il enregistre son heure comme repère de synchronisation, la prochaine lecture omettra les changements jusqu'à un instant encore futur pour le serveur.

lib/delta-watermark.ts avance donc le repère à partir des horodatages des lignes reçues. Une réponse vide conserve le précédent. La chaîne serveur choisie reste intacte pour la prochaine comparaison exclusive, sans être reformattée par le sérialiseur de dates du téléphone.

L'horloge du téléphone convient aux délais de nouvelle tentative. Elle ne peut pas certifier les changements serveur déjà observés.

Distinguer refus et indisponibilité

La file distingue le nombre total de tentatives échouées des échecs qui consomment sa limite. Les erreurs de transport et les erreurs serveur temporaires connues provoquent une attente exponentielle plafonnée sans épuiser le quota d'échecs définitifs. Une brève panne ne devrait pas supprimer un véhicule local et tous ses enregistrements en attente.

Les tests de régression vérifient directement l'annulation d'une création non envoyée, la conservation d'une suppression après envoi, l'exclusion d'une seconde exécution, la réponse perdue, la base initiale et la distinction entre conflit et accès manquant. Le serveur et le stockage sont simulés. Ces tests valident les décisions du client, pas les politiques déployées ni un réseau mobile réel.

Aucune de ces corrections n'a changé le formulaire de carburant. Elles ont changé les preuves exigées avant de déclarer une écriture annulée, confirmée ou définitivement refusée.

Pour tester une file hors ligne, garder la réponse d'insertion ouverte puis supprimer la ligne locale est un bon point de départ. Inspectez ce qui reste en file. Ce scénario révèle une hypothèse qu'un simple test en mode avion ne touche jamais.

Je construis OdoKeep pour conserver l'historique d'un véhicule sans exiger une connexion à chaque saisie. Voilà quelques décisions peu visibles derrière cette promesse. L'application est disponible sur l'App Store.