WatermelonDB & l'offline-first
Toute app finit par poser la même question : où vivent les données ? Pour Halterofit, la réponse n'est pas « sur un serveur » — c'est « sur ton téléphone, d'abord ». Ce module explique pourquoi, et te donne les clés pour lire la couche de données : le schéma, les modèles, et les deux manières de lire la base.
Garde une seule phrase en tête pour tout le module : le téléphone est la source de vérité ; le serveur n'est qu'une sauvegarde synchronisée en arrière-plan. Si tu inverses ces deux rôles dans ta tête, la moitié du code de l'app va te sembler bizarre. Si tu les gardes dans le bon sens, tout devient logique.
1. Le problème concret : un sous-sol de gym sans réseau
Imagine la scène. Patrick descend dans le sous-sol de son gym, là où le béton avale le signal cellulaire. Il pose son téléphone sur le banc, attaque sa première série de développé couché, et appuie sur « valider la série ». Que doit-il se passer ? Rien à attendre. Le poids et les répétitions doivent s'enregistrer instantanément, sans roue qui tourne, sans message « pas de connexion ». Entre deux séries, on a 90 secondes de repos, pas l'envie de fixer un spinner.
C'est la contrainte fondatrice d'une app de musculation : elle doit fonctionner parfaitement hors-ligne. Pas « se débrouiller » hors-ligne — fonctionner parfaitement. L'utilisateur ne doit jamais sentir la différence entre « j'ai du réseau » et « je n'en ai pas ». Logger une série, consulter sa dernière performance, démarrer un entraînement : tout ça doit marcher au fond du sous-sol comme à la surface.
Pour qu'une app marche sans réseau, il n'y a pas trente-six solutions : les données doivent vivre d'abord sur l'appareil. On ne peut pas demander à un serveur lointain ce qu'on n'a pas le moyen de joindre. C'est tout le sens du mot-clé que tu vas croiser partout dans ce code : offline-first, « le hors-ligne d'abord ». On conçoit l'app comme si le réseau n'existait pas, puis on ajoute la synchro comme un bonus.
Toute la couche de données est rangée dans apps/mobile/src/services/database/. Tu y trouves
un dossier local/ (la base sur le téléphone : schéma, modèles) et un dossier remote/
(le serveur, la synchro). Ce simple découpage raconte déjà la philosophie : le local et le distant sont
deux mondes séparés, et le local est cité en premier.
2. Ce qu'est WatermelonDB
WatermelonDB est une base de données pour React Native. Plus précisément : c'est une surcouche au-dessus de SQLite, le moteur de base de données déjà embarqué dans chaque téléphone. SQLite, c'est une vraie base relationnelle (des tables, des lignes, des colonnes, du SQL) qui tient dans un seul fichier sur l'appareil. WatermelonDB en fait quelque chose de bien plus agréable à utiliser depuis du TypeScript, avec deux superpouvoirs.
Premier superpouvoir : c'est rapide même avec beaucoup de données. WatermelonDB est conçu pour de gros volumes grâce au chargement paresseux (« lazy loading ») : il ne charge pas toute la base en mémoire au démarrage. Il ne va chercher une donnée que lorsque tu la demandes vraiment. Affiche une liste de 20 entraînements à l'écran ? Il ne lit que ces 20-là, même si la base en contient des milliers. C'est ce qui garde l'app fluide quand l'historique grossit avec les années.
Deuxième superpouvoir : elle est réactive. Une base de données classique est passive : tu lui poses une question, elle répond une fois, point final. Si la donnée change ensuite, ta réponse est périmée et tu ne le sais pas. Une base réactive, elle, peut t'envoyer un flux : « voici la réponse maintenant — et je te préviendrai chaque fois qu'elle change ». On reviendra là-dessus en détail (section sur les Observables), parce que ça change complètement la façon dont l'interface se met à jour.
| Aspect | Base classique (ex. via un serveur) | WatermelonDB (local, réactif) |
|---|---|---|
| Où vit la donnée | Sur un serveur distant | Sur le téléphone, dans un fichier SQLite |
| Hors-ligne | Inutilisable | Pleinement utilisable |
| Vitesse d'une lecture | Aller-retour réseau (lent, variable) | Lecture disque locale (quasi instantanée) |
| Mise à jour de l'UI | Tu redemandes manuellement | L'UI peut s'abonner et se rafraîchir seule |
| Gros volumes | Pagination côté serveur | Chargement paresseux côté appareil |
WatermelonDB ne remplace pas le serveur : il vit à côté. Le serveur (ici Supabase, voir le module « Supabase : le backend ») reste utile pour sauvegarder, synchroniser entre plusieurs appareils, et un jour partager des données entre utilisateurs. Mais pour afficher quelque chose à l'écran, l'app ne parle qu'à WatermelonDB.
3. La philosophie offline-first, en profondeur
On a posé le mot ; creusons-le, parce que c'est le concept qui structure tout le module. Le principe offline-first tient en trois affirmations qu'il faut bien distinguer :
- Le local est la source de vérité pour l'interface. Quand un écran affiche tes entraînements, il les lit dans WatermelonDB, pas sur le serveur. Toujours. Même quand tu as du réseau. Le serveur n'est jamais consulté pour dessiner l'écran.
- L'écriture est locale, et immédiate. Tu valides une série → on écrit la ligne dans SQLite → c'est fini, l'UI peut déjà l'afficher. Aucune attente réseau dans ce chemin. (On verra à la section sur l'écriture comment la base note discrètement « cette ligne reste à envoyer au serveur ».)
- La synchro se fait en arrière-plan, sans bloquer. Plus tard — quand il y a du réseau, quand l'app le décide — une routine de fond envoie les nouveautés au serveur et récupère celles des autres appareils. Si ça échoue, ce n'est pas grave : les données sont déjà en sécurité sur le téléphone, et la synchro réessaiera.
Oppose ça mentalement à l'approche inverse, qu'on pourrait appeler « tout passe par le serveur ». Là, chaque action — afficher une liste, sauver une série — déclenche un appel réseau, et l'écran attend la réponse pour réagir. C'est l'architecture classique d'un site web. Elle a un avantage (le serveur a toujours la vérité) et un défaut rédhibitoire ici : sans réseau, l'app est morte, et même avec du réseau, chaque action traîne le poids d'un aller-retour. Pour un site de banque consulté au bureau, ça va. Pour logger des séries dans un sous-sol, c'est éliminatoire.
Pense à l'ancien carnet d'entraînement en papier. Tu écris tes séries dedans immédiatement, sur place, sans demander la permission à personne — le carnet est ta source de vérité. Le soir, à la maison, tu recopies peut-être tout dans un fichier Excel « pour garder une trace ». L'offline-first, c'est exactement ça : le téléphone est le carnet papier (rapide, toujours dispo, source de vérité), le serveur est le fichier Excel du soir (la copie de sauvegarde qu'on met à jour quand on peut). On n'attend jamais Excel pour écrire dans le carnet.
4. Le schéma : la liste des tables
Le schéma est le plan de la base : il déclare quelles tables existent et quelles colonnes chacune contient. C'est le document le plus important à lire en premier, parce qu'il te donne la carte du territoire. Voici la structure d'Halterofit en sept tables principales :
users— l'utilisateur (email, unité préférée kg/lbs, nom affiché…).workouts— chaque séance d'entraînement réalisée.workout_exercises— les exercices d'une séance (table de liaison, avec l'ordre).exercise_sets— les séries effectuées (poids, répétitions, RPE…).workout_plans— les plans/programmes réutilisables (modèles de séance).plan_days— les jours d'un plan (« Jour 1 : pecs & triceps »).plan_day_exercises— les exercices prévus dans un jour de plan.
La hiérarchie centrale se lit comme des poupées russes : une séance (workout) contient
plusieurs exercices (workout_exercises), chacun contenant plusieurs séries
(exercise_sets). À côté, il y a la branche des plans (le programme qu'on
prévoit de suivre) qui suit la même logique : un plan → des jours → des exercices prévus. La séance,
c'est ce qu'on a fait ; le plan, c'est ce qu'on comptait faire. (Il existe aussi une
table exercises, le catalogue des ~1500 mouvements, dont on parlera à part.)
export const schema = appSchema({
version: 12, // bumpé à chaque changement de structure (voir migrations)
tables: [
tableSchema({
name: 'workouts',
columns: [
{ name: 'user_id', type: 'string', isIndexed: true }, // à qui appartient la séance
{ name: 'started_at', type: 'number', isIndexed: true }, // date de début (timestamp ms)
{ name: 'completed_at', type: 'number', isOptional: true }, // null = séance en cours
{ name: 'duration_seconds', type: 'number', isOptional: true },
{ name: 'title', type: 'string', isOptional: true },
// ... created_at, updated_at, etc.
],
}),
// ... 6 autres tables : users, workout_exercises, exercise_sets, workout_plans...
],
});
Trois détails à lire : type dit le genre de la valeur (string, number,
boolean) ; isOptional: true autorise la colonne à être vide (null) —
ici, une séance sans completed_at est une séance en cours ; isIndexed: true
demande à SQLite un index pour accélérer les recherches sur cette colonne (ex. « toutes les séances de cet
utilisateur »).
version: 12 en haut du schéma compte vraiment. Chaque fois qu'on modifie la structure
(ajouter une colonne, une table), on monte ce numéro et on écrit une migration : un petit script
qui transforme l'ancienne base déjà installée sur le téléphone d'un utilisateur en nouvelle, sans
perdre ses données. On ne peut pas se contenter de « recréer la base » : le carnet est rempli, on
n'a pas le droit de l'effacer.
5. Les modèles et leurs décorateurs
Le schéma décrit les tables en termes « bruts » (des colonnes). Le modèle, lui, est la
classe TypeScript que ton code manipule réellement : une instance de Workout représente une
ligne de la table workouts, avec des propriétés joliment nommées et des méthodes pratiques.
Le pont entre les deux se fait avec des décorateurs — ces petites annotations préfixées
d'un @ au-dessus de chaque propriété. Lis-les comme des étiquettes qui disent « cette propriété
correspond à telle colonne, et voici comment la traiter ».
export default class Workout extends Model {
static table = 'workouts'; // ce modèle = la table 'workouts'
static associations = {
// un workout APPARTIENT à un user, et A PLUSIEURS workout_exercises
users: { type: 'belongs_to', key: 'user_id' },
workout_exercises: { type: 'has_many', foreignKey: 'workout_id' },
};
@field('user_id') userId!: string; // colonne texte/nombre simple
@relation('users', 'user_id') user!: User; // va chercher LE user lié
@children('workout_exercises') workoutExercises!: WorkoutExercise[]; // tous les exercices liés
@date('started_at') startedAt!: Date; // un nombre stocké, exposé en Date JS
@date('completed_at') completedAt?: Date; // optionnel (le '?')
@readonly @date('created_at') createdAt!: Date; // géré par la base, jamais modifié à la main
// Propriété CALCULÉE : pas une colonne, déduite à la lecture
get isActive(): boolean {
return !this.completedAt; // pas de date de fin = séance encore active
}
}
Remarque isActive : ce n'est pas une colonne en base, c'est un getter qui déduit
l'info à la volée (« pas de completedAt ⇒ séance en cours »). Les modèles ne font pas que
refléter des colonnes ; ils ajoutent de la logique métier au-dessus.
Voici le petit dictionnaire des décorateurs que tu croiseras partout. Apprends ces cinq-là et tu sais lire n'importe quel modèle :
| Décorateur | Ce qu'il fait |
|---|---|
@field('col') | Lie la propriété à une colonne simple (texte, nombre, booléen). La forme la plus courante. |
@date('col') | Comme @field, mais convertit le nombre stocké (un timestamp) en objet Date JavaScript, plus pratique à manipuler. |
@relation('table', 'clé') | Pointe vers un seul enregistrement lié (le « belongs_to » : un workout appartient à un user). Va le chercher à la demande. |
@children('table') | Donne tous les enregistrements liés (le « has_many » : un workout a plusieurs workout_exercises). Renvoie une requête, pas un tableau figé. |
@readonly | Marque la propriété en lecture seule : la base la gère elle-même (typiquement created_at/updated_at). On ne l'écrit jamais à la main. |
@children
Le modèle WorkoutExercise a lui aussi @children('exercise_sets'). Combiné à
celui de Workout, ça reconstitue toute la hiérarchie en objets :
workout.workoutExercises → pour chacun, .exerciseSets. C'est la traduction
directe, en code, des « poupées russes » séance → exercices → séries vues plus haut. Il existe aussi un
décorateur @json(...) (sur le modèle Exercise) qui range un tableau dans une
seule colonne texte au format JSON — pratique pour les listes de muscles.
6. Deux façons de lire : Promesses vs Observables
Maintenant que tu sais à quoi ressemblent les données, comment les lit-on ? Le fichier des requêtes d'Halterofit est explicite : son en-tête annonce une « Dual API — Promise + Observable ». Deux façons de lire la même base, pour deux besoins différents. C'est un point clé : ne les confonds pas.
A. La lecture ponctuelle — une Promesse
Une Promesse, c'est « va me chercher la donnée maintenant, une seule fois, et
rends-la moi ». Tu await le résultat, tu l'utilises, c'est terminé. Si la donnée change après,
tu ne le sauras pas — ta réponse est une photo prise à l'instant T. C'est parfait quand tu lis une donnée
que tu n'as pas besoin de surveiller : un export, un calcul ponctuel, un pré-remplissage.
// Lecture PONCTUELLE : on récupère une séance complète une bonne fois.
// (déjà croisée dans le module « Traçage de bout en bout »)
export async function getWorkoutWithDetails(workoutId: string) {
const workout = await database.get('workouts').find(workoutId);
// On reconstruit la hiérarchie à la main, en lisant les exercices...
const workoutExercises = await database
.get('workout_exercises')
.query(Q.where('workout_id', workoutId), Q.sortBy('order_index', Q.asc))
.fetch(); // .fetch() = "donne-moi le tableau MAINTENANT" → une Promesse
// ... puis, pour chaque exercice, ses séries. (résumé)
return { ...workout, exercises: /* ...exercices + séries... */ };
}
Le marqueur à repérer : .fetch() et le mot-clé await. Ça veut dire « lecture
unique, ici et maintenant ». La fonction est async et renvoie une Promise.
B. Le flux réactif — un Observable
Un Observable, c'est tout autre chose : c'est un abonnement. Tu ne demandes pas « la valeur maintenant », tu demandes « tiens-moi au courant de cette valeur, pour toujours ». Il t'envoie la valeur actuelle, puis te renvoie une nouvelle valeur chaque fois que la base change. Couplé à un écran, ça donne de la magie : tu logges une série dans un sous-sol, et la liste à l'écran se réordonne toute seule, sans qu'aucun code n'aille « rafraîchir » quoi que ce soit.
// FLUX RÉACTIF : la MÊME requête, mais avec .observe() au lieu de .fetch()
export function observeUserWorkouts(userId: string) {
return database
.get('workouts')
.query(Q.where('user_id', userId), Q.sortBy('started_at', Q.desc))
.observe() // ◀── "abonne-moi", au lieu de "donne-moi"
.pipe(map((rows) => rows.map(workoutToPlain)));
}
La requête est identique à la version ponctuelle ; seul change le verbe final :
.observe() au lieu de .fetch(). Et la fonction n'est pas async : elle
ne renvoie pas une valeur, mais un flux auquel un écran va s'abonner. Note d'ailleurs que
beaucoup de fonctions existent en double dans le fichier — getUserWorkouts (Promesse) ET
observeUserWorkouts (Observable) — pour offrir les deux styles selon le besoin.
L'écran doit refléter la base en continu ? → Observable (.observe()). C'est
le cas de presque tous les écrans : liste de séances, séance en cours, historique. Tu lis une
donnée juste une fois, pour un traitement ? → Promesse (.fetch() + await).
Le détail du comment on branche un Observable sur un composant React est approfondi dans le
module « Pour aller plus loin » ; ici, retiens surtout la différence de nature : une photo
(Promesse) versus une vidéo en direct (Observable).
7. L'écriture et le suivi de synchro
Côté écriture, la règle offline-first se traduit par une discipline stricte : tout est écrit en local d'abord. On ne demande jamais au serveur la permission d'écrire. On écrit dans SQLite, l'opération réussit, l'UI réagit — et le problème « comment cette ligne arrivera-t-elle au serveur ? » est traité séparément, plus tard. Le fichier des écritures d'Halterofit l'annonce noir sur blanc en en-tête : « All operations are LOCAL FIRST (instant) ; sync happens separately. »
export async function createWorkout(data: CreateWorkout) {
// database.write() = une transaction d'écriture LOCALE
const workout = await database.write(async () => {
return await database.get('workouts').create((w) => {
w.userId = data.user_id; // on remplit les champs...
w.startedAt = new Date(data.started_at);
// ...puis WatermelonDB stocke la ligne dans SQLite. C'est fait.
});
});
return workoutToPlain(workout); // déjà disponible, AUCUN appel réseau ici
}
Cherche un appel au serveur dans cette fonction : il n'y en a pas. L'écriture est purement locale. La remontée vers Supabase se fera dans un tout autre fichier, à un tout autre moment.
Mais alors, comment la synchro sait-elle quoi envoyer la prochaine fois qu'il y a du réseau ? C'est là qu'interviennent des colonnes techniques que WatermelonDB gère lui-même, en coulisses, sur chaque ligne — tu ne les déclares pas dans le schéma, mais elles existent :
-
_status— l'état de synchro de la ligne :created(créée localement, jamais envoyée),updated(modifiée depuis le dernier envoi),synced(à jour avec le serveur). La synchro n'envoie que ce qui n'est passynced. -
_changed— la liste des champs précis qui ont changé depuis le dernier envoi. Ça permet d'envoyer juste la différence, pas toute la ligne.
Ces deux colonnes forment un journal des choses à synchroniser : la base coche toute
seule « ceci reste à envoyer ». La routine de synchro n'a plus qu'à demander « qu'est-ce qui n'est pas
synced ? », pousser ça au serveur, et remettre les lignes à synced. Tu verras
d'ailleurs l'app utiliser hasUnsyncedChanges() pour afficher un petit indicateur « il reste
des choses à sauvegarder ».
Un piège classique de la synchro : que se passe-t-il quand tu supprimes une ligne ? Si on
l'effaçait vraiment du téléphone, la synchro n'aurait plus rien à montrer au serveur — elle ne saurait pas
qu'il faut aussi la supprimer là-bas. La solution est la suppression douce (« soft
delete ») : au lieu d'effacer la ligne, on la marque comme supprimée (_status = 'deleted').
La ligne devient une « pierre tombale » (tombstone) : invisible pour l'app, mais encore
présente, juste le temps de dire au serveur « supprime-la aussi ». Une fois la synchro faite, la pierre
tombale est définitivement retirée. Une suppression est donc, elle aussi, d'abord locale —
exactement comme une création.
8. Les ~1500 exercices : le « seed »
Reste un cas à part : le catalogue d'exercices. Halterofit embarque environ 1500 exercices
(issus d'un jeu de données public, avec noms, muscles ciblés, GIF animés) dans un fichier
assets/data/exercises.json livré avec l'app. Ces données ne sont pas créées par
l'utilisateur ; ce sont des données de référence, en lecture seule. Il faut les charger dans la base au
tout premier lancement. Cette opération porte un nom : le seed (« semer »).
Le seed, c'est peupler la base avec un jeu de données initial. L'idée importante : il ne faut le faire qu'une fois. Réimporter 1500 lignes à chaque démarrage serait lent et créerait des doublons. Halterofit résout ça avec un numéro de version de seed rangé dans un petit stockage rapide : si la version déjà importée correspond à la version actuelle, on ne refait rien.
const SEED_VERSION = 4; // monte ce numéro pour forcer un nouvel import
export async function needsExerciseSeeding(): Promise<boolean> {
// Version déjà importée ≠ version courante ⇒ il faut (re)semer.
const seeded = mmkvStorage.getNumber('exercise_seed_version');
if (seeded !== SEED_VERSION) return true;
// Sécurité : version OK mais table vide (base effacée) ⇒ resemer quand même.
const count = await database.get('exercises').query().fetchCount();
return count === 0;
}
Deux verrous : le numéro de version et un comptage de sécurité. C'est exactement ce qui évite de réimporter 1500 exercices à chaque ouverture de l'app.
Après le seed, le code force _status = 'synced' sur tous les exercices. Pourquoi ? Parce que
ces lignes sont des données statiques locales : on ne veut surtout pas que la
synchro essaie de les pousser vers le serveur (elles y sont déjà, ou n'ont rien à y faire). En les
marquant synced dès le départ, on les exclut du journal de synchro vu à la section
précédente. Tu vois comme _status revient ? C'est le même mécanisme partout.
9. Le lien avec Supabase
On a beaucoup parlé du local ; refermons la boucle sur le distant. Supabase est le serveur d'Halterofit (une base PostgreSQL dans le cloud). La synchro local ↔ serveur est ce qui transforme le carnet du téléphone en données sauvegardées et partageables entre les appareils d'un même utilisateur.
WatermelonDB fournit une fonction synchronize() qui orchestre tout selon son protocole
officiel : un pull (télécharger ce qui a changé sur le serveur depuis la dernière fois)
suivi d'un push (envoyer les changements locaux — précisément ceux que les colonnes
_status/_changed ont marqués). Halterofit déclenche cette synchro automatiquement
(de façon « débouncée », après 2 secondes de calme) dès qu'une table change ou que le réseau revient — d'où
ce détail offline-first que tu peux maintenant lire dans le code : l'app écoute le retour du réseau
pour rattraper ce qu'elle n'a pas pu envoyer hors-ligne.
Tout le détail de Supabase — comment le serveur est structuré, ce qu'est une fonction RPC
(pull_changes, push_changes), la résolution de conflits — fait l'objet du module
« Supabase : le backend ». Garde juste l'image d'ensemble : WatermelonDB et Supabase
sont les deux moitiés d'un même système, reliées par la synchro, le local restant toujours la
source de vérité pour ce que tu vois à l'écran.
Voici deux fonctions du fichier des requêtes. Lis-les attentivement.
export async function getActiveWorkout(userId: string) {
const workouts = await database.get('workouts')
.query(Q.where('user_id', userId), Q.where('completed_at', null), Q.take(1))
.fetch();
return workouts[0] ? workoutToPlain(workouts[0]) : null;
}
export function observeActiveWorkout(userId: string) {
return database.get('workouts')
.query(Q.where('user_id', userId), Q.where('completed_at', null), Q.take(1))
.observe()
.pipe(map((rows) => rows[0] ? workoutToPlain(rows[0]) : null));
}
Questions : (1) Les deux ramènent « la séance active de l'utilisateur ». Quelle est la
seule différence de comportement ? (2) Pour un écran qui doit afficher en permanence un bandeau
« entraînement en cours » qui apparaît/disparaît tout seul, laquelle choisis-tu ? (3) Pourquoi le filtre
Q.where('completed_at', null) isole-t-il justement la séance active ?
Voir le corrigé
(1) La requête est rigoureusement la même ; seul change le verbe final.
getActiveWorkout finit par .fetch() → une Promesse : une
lecture unique, une photo de l'instant. observeActiveWorkout finit par
.observe() → un Observable : un flux qui ré-émet à chaque fois que la
base change.
(2) L'Observable (observeActiveWorkout). Comme l'écran doit refléter la
base en continu — le bandeau doit apparaître dès qu'on démarre une séance et disparaître dès
qu'on la termine — il faut un abonnement, pas une photo figée. Avec la Promesse, il faudrait relire
manuellement à chaque changement, ce qui rate justement l'intérêt du réactif.
(3) Parce que, comme vu dans le modèle Workout (le getter
isActive), une séance est « active » tant qu'elle n'a pas de date de fin. La colonne
completed_at est isOptional : null = pas encore terminée = en
cours. Filtrer sur completed_at === null, c'est donc demander « la séance non terminée ».
1. En une phrase : qu'est-ce que l'offline-first ?
Une conception où les données vivent d'abord en local sur l'appareil (qui devient la source de vérité pour l'interface), la synchro avec le serveur se faisant en arrière-plan. L'app reste pleinement utilisable hors-ligne.
2. Quelle est la différence entre lire avec .fetch() et avec .observe() ?
.fetch() renvoie une Promesse : une lecture unique, une photo de la donnée à l'instant T. .observe() renvoie un Observable : un flux qui ré-émet une nouvelle valeur à chaque changement de la base — l'UI peut s'y abonner et se rafraîchir seule.
3. À quoi servent les colonnes _status et _changed ?
Elles tiennent le journal de ce qui reste à synchroniser. _status dit si une ligne est created/updated/synced ; _changed liste les champs modifiés. La synchro n'envoie au serveur que ce qui n'est pas synced.
4. Qu'est-ce qu'une « tombstone » (suppression douce) ?
Plutôt que d'effacer vraiment une ligne, on la marque comme supprimée et on la garde le temps de dire au serveur « supprime-la aussi ». Sans ça, la synchro ne saurait pas qu'une suppression a eu lieu. La ligne est définitivement retirée une fois la synchro passée.
5. Pourquoi le « seed » des ~1500 exercices utilise-t-il un numéro de version ?
Pour ne l'importer qu'une seule fois. Si la version de seed déjà enregistrée correspond à la version courante, on ne réimporte pas — ça évite la lenteur et les doublons à chaque démarrage.
WatermelonDB est une base SQLite locale et réactive : c'est elle, pas le serveur, qui
alimente l'écran. Le schéma liste les tables (séance → exercices → séries), les
modèles les exposent en classes via les décorateurs (@field, @date,
@relation, @children, @readonly). On lit de deux façons :
Promesse (.fetch(), ponctuel) ou Observable
(.observe(), réactif). On écrit toujours en local d'abord ; les colonnes
_status/_changed et les tombstones notent quoi synchroniser ; le seed peuple le
catalogue une fois. La synchro avec Supabase tourne en fond.
Croire que le serveur est la source de vérité de l'interface. En offline-first, c'est
faux : l'écran lit le local. Si tu cherches « où l'app va-t-elle chercher la donnée à afficher ? »
et que tu pars vers Supabase, tu te trompes de fichier — c'est WatermelonDB qu'il faut lire. Corollaire
tout aussi piégeux : oublier que l'écriture est locale d'abord. Une fonction comme
createWorkout ne contacte aucun serveur ; ne t'attends pas à y voir un appel réseau, il
n'y en a pas. Le réseau, c'est le travail séparé de la synchro.
L'offline-first (ou « local-first ») n'est pas une bizarrerie d'Halterofit : c'est une tendance forte du développement moderne, bien au-delà de cette app. Notion, Linear, Figma, les apps de notes et de tâches récentes — toutes adoptent ce modèle « le local d'abord, la synchro en fond » parce qu'il rend les interfaces instantanées et résistantes au réseau. Comprendre ici les trois piliers (local source de vérité, écriture locale immédiate, synchro en arrière-plan) te donne une grille de lecture qui resservira pour quantité d'autres outils — et l'idée des flux réactifs (Observables) dépasse elle aussi WatermelonDB : c'est tout l'univers de la programmation réactive.