Offline-first n'est pas un cache
Dans Grimoire Culinaire, la base locale est la source de vérité et Supabase n'est qu'un miroir qu'on réconcilie. Ce que cette inversion change concrètement dans le code, et ce qu'elle coûte.
La cuisine n'a pas de wifi
Grimoire Culinaire est une app de recettes. Son terrain d'usage, c'est la cuisine : les mains dans la farine, le téléphone calé contre le pot à ustensiles, et un wifi qui passe une fois sur deux selon l'épaisseur des murs. Avant d'écrire la première ligne de code, j'ai posé une décision produit : l'app fonctionne à 100 % sans réseau. Pas de mode dégradé, pas de bandeau « certaines fonctions nécessitent une connexion ». Créer, modifier, cuisiner, supprimer : tout marche en avion.
Ce n'est pas une case de checklist technique. C'est un problème d'usage réel qui précède l'architecture. Et cette décision a une conséquence brutale : elle interdit le raccourci du cache, où le serveur détient la vérité et où le téléphone garde une copie qui peut mentir.
Inverser la hiérarchie
Concrètement, la base locale Drift (du SQLite typé, côté Flutter) est la source de vérité. Supabase n'est qu'un miroir qu'on réconcilie quand le réseau veut bien. Pour que cette inversion tienne, chaque recette porte trois colonnes de sync directement dans le schéma local :
/// Synchronisée avec le serveur (false en local).
BoolColumn get isSynced =>
boolean().withDefault(const Constant(false))();
/// Date de dernière modification (ISO 8601).
DateTimeColumn get updatedAt => dateTime()();
/// Date de suppression soft (null = non supprimée). Utilisé pour la sync.
DateTimeColumn get deletedAt => dateTime().nullable()();Trois colonnes qui portent tout le modèle
isSynced dit si le serveur est au courant de la dernière version. updatedAt départage les conflits. deletedAt matérialise le soft delete : supprimer une recette hors ligne ne l'efface pas, ça la marque comme supprimée avec isSynced repassé à false. La suppression sera propagée au serveur à la prochaine sync, et la ligne locale n'est définitivement effacée qu'après confirmation. Une suppression, dans ce modèle, est une donnée comme une autre : elle doit survivre au redémarrage et voyager jusqu'au serveur.
La sync comme routine, pas comme événement
Il n'y a ni bouton « sauvegarder dans le cloud » ni spinner qui bloque l'écran. La sync est une routine de fond avec deux rythmes. Le premier : fullSync au démarrage et à chaque reconnexion, push d'abord, pull ensuite.
/// Sync complète : push local → pull remote. Appelé au startup et reconnect.
Future<void> fullSync() async {
if (_isSyncing) return;
_isSyncing = true;
_setStatus(SyncStatus.syncing);
try {
await _stampUserId();
await pushLocalChanges();
await pullRemoteChanges();
// Sync stats, wallet, achievements.
// ...
_setStatus(SyncStatus.idle);
} catch (e) {
_setStatus(SyncStatus.error);
} finally {
_isSyncing = false;
}
}Le deuxième rythme : fire-and-forget
Le second rythme : pushSingleRecipe, déclenché en fire-and-forget après chaque création ou modification. Si l'appel passe, tant mieux. S'il échoue, rien ne casse : la recette garde son isSynced=false et sera reprise au prochain fullSync. L'utilisateur ne voit jamais un échec de sauvegarde, parce qu'il n'y a rien à voir : sa donnée est déjà en sécurité chez lui, en local. Un petit service de connectivité expose un flux booléen qui déclenche la reprise dès que le réseau revient.
Les conflits qu'on assume
Réconcilier deux bases, c'est accepter des conflits. Ma règle tient dans le commentaire en tête du service de sync : « Conflits : last-write-wins sur updatedAt ». Quand le pull rencontre une recette modifiée localement et modifiée côté serveur, la plus récente gagne, point.
} else {
// Last-write-wins : la plus récente gagne.
if (remoteUpdatedAt.isAfter(localRecipe.updatedAt)) {
await repository.upsertFromRemote(remoteRecipe);
}
// Si local est plus récent, elle sera pushée au prochain cycle.
}Ce que ce choix perd, et pourquoi c'est acceptable
Ce choix perd des données dans un cas précis : deux appareils modifient la même recette hors ligne, et le plus récent écrase l'autre sans fusionner les champs. J'assume, parce que Grimoire est mono-utilisateur. Le scénario perdant exige que la même personne édite la même recette sur deux téléphones, tous les deux hors ligne, avant que l'un des deux ne se synchronise. Face à ce risque, je préfère un modèle que je comprends entièrement à une machinerie de merge que je débuggerais à deux heures du matin. Le code garde d'ailleurs un bloc volontairement commenté, annoté « à activer quand le multi-device sera supporté » : la limite est documentée, pas ignorée. Last-write-wins est un arbitrage local, pas une recommandation universelle.
Ce que ça coûte
L'offline-first n'est pas gratuit, et le repo en porte les cicatrices. Le schéma local en est à sa version 12, avec une MigrationStrategy qui rejoue chaque étape de l'histoire : les tables recettes en v2, les colonnes de présentation en v3, la gamification en v4, et ainsi de suite. Côté serveur, les colonnes de sync ont été ajoutées après coup par migration SQL : deleted_at n'existait pas dans Supabase, il a fallu aligner le miroir sur le schéma Drift qui avait pris de l'avance.
Et chaque nouvelle table re-paye le péage. Quand j'ai ajouté les stats, le wallet et les achievements, chacun a dû écrire son push, son pull et sa résolution de conflit. Le fullSync qui faisait deux appels en fait aujourd'hui huit. C'est le prix structurel de l'inversion : dans un modèle cache, on invalide et on re-télécharge ; dans un modèle offline-first, chaque donnée doit savoir voyager dans les deux sens.
Ce que je retiens
- Un cache répond à la question « que faire si le réseau tombe ». L'offline-first répond à « que faire quand le réseau revient ». La deuxième question est de loin la plus dure.
- Trois colonnes suffisent à porter tout le modèle (isSynced, updatedAt, deletedAt), à condition de les poser dès le schéma et de traiter la suppression comme une donnée qui voyage.
- Last-write-wins est un choix acceptable en mono-utilisateur, pas une solution universelle. Ce qui compte, c'est d'écrire noir sur blanc les cas où il perd.
- La sync doit être une routine invisible, jamais un événement que l'utilisateur attend. S'il y a un spinner de sauvegarde, la hiérarchie n'a pas vraiment été inversée.
- Chaque table synchronisée re-paye le péage push/pull. L'offline-first se décide avant la première ligne, parce qu'il se paye ensuite partout.