Maîtriser les Applications Web Offline-First : Robustesse et Performance Hors Ligne
Maîtriser les Applications Web Offline-First : Robustesse et Performance Hors Ligne

Stratégies de Synchronisation Intelligente et Résolution des Conflits

Introduction au Monde Offline-First et ses Défis

Bienvenue dans cette leçon dédiée à l'un des piliers des applications web offline-first : la synchronisation intelligente et la résolution des conflits. Dans le cours 'Maîtriser les Applications Web Offline-First : Robustesse et Performance Hors Ligne', nous avons exploré les raisons et les technologies qui rendent les applications accessibles et performantes même sans connexion internet. Cependant, une fois que les utilisateurs travaillent hors ligne, les données locales commencent à diverger de l'état du serveur. C'est là que la synchronisation entre en jeu.

Le défi fondamental d'une application offline-first est de garantir que les données restent cohérentes et intègres lorsque la connectivité est rétablie, tout en offrant une expérience utilisateur fluide et sans interruption. Cela implique non seulement de savoir quand et comment envoyer les changements locaux au serveur, mais aussi de gérer les situations complexes où les mêmes données ont été modifiées simultanément par différentes sources – c'est la résolution des conflits.

Dans cette leçon, nous allons déconstruire ces concepts, explorer les différentes stratégies disponibles et voir comment les mettre en œuvre pour bâtir des applications robustes et fiables.

1. Comprendre le Défi de la Synchronisation dans les Applications Offline-First

La synchronisation est le processus par lequel les données entre un client (application web locale) et un serveur sont mises à jour pour refléter l'état le plus récent et le plus cohérent. Dans un contexte offline-first, cela devient particulièrement complexe en raison de plusieurs facteurs :

  • Latence et Fiabilité du Réseau : La connexion peut être lente, intermittente, ou complètement absente. La synchronisation doit être capable de reprendre là où elle s'est arrêtée et de gérer les échecs de manière gracieuse.
  • Modifications Concurrentes :
    • Un utilisateur peut modifier des données hors ligne, tandis qu'un autre utilisateur modifie les mêmes données en ligne.
    • Un même utilisateur peut modifier les données sur plusieurs appareils différents, chacun avec sa propre version locale.
  • Intégrité des Données : Comment s'assurer que les données ne sont pas corrompues ou perdues lors des transferts et des fusions ?
  • Expérience Utilisateur : La synchronisation doit être la plus transparente possible, sans bloquer l'interface utilisateur ou entraîner des pertes de données frustrantes.
  • Performance : Transférer uniquement ce qui est nécessaire pour minimiser la bande passante et la consommation d'énergie.

L'objectif d'une stratégie de synchronisation intelligente est d'atteindre un équilibre entre :

  • La minimalisation de la perte de données.
  • La performance (synchronisations efficaces et rapides).
  • La cohérence des données à travers tous les points de contact.
  • Une expérience utilisateur sans friction.

2. Modèles et Stratégies de Synchronisation

Il existe différentes approches pour initier et gérer le flux de données entre le client et le serveur.

2.1. Pull vs. Push

  • Synchronisation Pull (Le client tire) :

    • Le client initie la demande de données auprès du serveur.
    • Avantages : Simple à mettre en œuvre, contrôle par le client.
    • Inconvénients : Peut introduire un délai si le client ne vérifie pas assez souvent, nécessite des requêtes périodiques ou déclenchées par un événement (comme la reconnexion).
    • Exemple : Une application client interroge un endpoint API /api/data?since=<timestamp> pour obtenir les dernières modifications.
  • Synchronisation Push (Le serveur pousse) :

    • Le serveur notifie ou envoie des données au client dès qu'un changement se produit.
    • Avantages : Mises à jour en temps réel, réactivité.
    • Inconvénients : Plus complexe à mettre en œuvre (nécessite des technologies comme WebSockets, Server-Sent Events, ou l'API Push pour les notifications), peut être consommateur de ressources si mal géré.
    • Exemple : Un serveur utilise WebSockets pour envoyer des mises à jour à tous les clients connectés lorsqu'une donnée est modifiée.
  • Approches Hybrides : La plupart des applications modernes utilisent une combinaison. Le client pousse ses modifications au serveur, puis tire les modifications du serveur pour rester à jour. Les Service Workers peuvent jouer un rôle clé en gérant ces opérations en arrière-plan.

2.2. Types de Synchronisation

  • Synchronisation Complète : Transfert de toutes les données. Principalement utilisée lors de la première installation de l'application ou en cas de réinitialisation complète. Peu efficace pour les mises à jour régulières.
  • Synchronisation Incrémentale / Différentielle : Le Saint Graal pour la performance. Seules les modifications (ajouts, mises à jour, suppressions) survenues depuis la dernière synchronisation sont transférées. C'est la stratégie de choix pour les applications offline-first.

2.3. Stratégies de Traçabilité des Changements

Pour effectuer une synchronisation incrémentale, il faut un moyen fiable de savoir ce qui a changé.

  • Horodatages (Timestamps) :

    • Chaque enregistrement possède un champ last_updated_at.
    • Le client mémorise le timestamp de sa dernière synchronisation réussie.
    • Lors de la prochaine synchronisation, le client demande au serveur tous les enregistrements où last_updated_at > [last_sync_timestamp_du_client].
    • Avantages : Simple à implémenter.
    • Inconvénients : Sensible aux décalages d'horloge entre le client et le serveur. Ne gère pas toujours bien les suppressions (le serveur devrait maintenir un journal de suppressions).
  • Numéros de Version Monotoniques :

    • Chaque enregistrement a un numéro de version (version: 1, 2, 3...). À chaque modification, la version est incrémentée.
    • Le client et le serveur comparent leurs numéros de version pour un enregistrement donné.
    • Avantages : Évite les problèmes de décalage d'horloge.
    • Inconvénients : Plus difficile à gérer dans un environnement distribué avec plusieurs clients modifiant simultanément.
  • Vecteurs de Version (Vector Clocks) :

    • Une structure de données qui permet de déterminer l'ordre partiel des événements dans un système distribué. Chaque nœud (client, serveur) maintient un "horloge vectorielle" qui est un tableau d'entiers, un pour chaque nœud.
    • Avantages : Résout les problèmes de décalage d'horloge et permet de détecter les conflits de manière précise.
    • Inconvénients : Plus complexe à implémenter et à gérer, notamment la taille des vecteurs.
  • Log d'Opérations (Operation Log / Journal de Modifications) :

    • Au lieu de ne stocker que l'état final, on stocke toutes les opérations (création, mise à jour, suppression) dans un journal immuable.
    • Le client peut alors envoyer son journal d'opérations non synchronisées, et le serveur peut les rejouer ou les fusionner.
    • Avantages : Extrêmement robuste, base pour les types de données répliquées sans conflit (CRDTs).
    • Inconvénients : Peut être gourmand en stockage et en complexité.

3. Résolution des Conflits : Quand le Réel et le Virtuel Divergent

Un conflit se produit lorsque la même donnée est modifiée de manière incompatible par différentes sources avant que ces modifications ne soient synchronisées. La résolution des conflits est l'art de décider quelle version des données doit prévaloir, ou comment les différentes versions peuvent être fusionnées.

3.1. Types de Conflits

  • Conflit d'Écriture-Écriture : Le plus courant. Deux entités (utilisateurs, appareils) modifient la même propriété d'un enregistrement.
    • Exemple : Utilisateur A modifie le titre d'un article hors ligne. Utilisateur B modifie le même titre en ligne.
  • Conflit de Suppression :
    • Suppression-Modification : Un utilisateur supprime un enregistrement, l'autre le modifie.
    • Suppression-Ajout : Un utilisateur supprime un enregistrement, un autre recrée un enregistrement avec le même ID (rare mais possible).

3.2. Stratégies de Résolution Automatique

Ces stratégies sont implémentées côté serveur et/ou client pour résoudre les conflits sans intervention humaine.

  • Last Write Wins (LWW - La dernière écriture gagne) :

    • La version de l'enregistrement avec l'horodatage le plus récent ou le numéro de version le plus élevé est retenue.
    • Avantages : Simple à implémenter.
    • Inconvénients : Peut entraîner une perte de données si l'écriture la plus récente n'est pas la plus "pertinente" d'un point de vue métier, ou si les horodatages sont imprécis. C'est la stratégie par défaut de nombreux systèmes distribués (ex: certains stores NoSQL).
  • First Write Wins (FWW - La première écriture gagne) :

    • L'inverse de LWW. La première version reçue par le serveur est conservée.
    • Avantages : Simple.
    • Inconvénients : Peut aussi entraîner une perte de données et n'est généralement pas le comportement souhaité.
  • Fusion Sémantique (Merge Semantics) :

    • Applique une logique métier spécifique pour fusionner les changements.
    • Exemples :
      • Pour un champ numérique, additionner les changements (ex: un compteur de likes).
      • Pour un champ de texte, utiliser un algorithme de fusion de texte (comme celui de Git).
      • Pour un tableau, fusionner les éléments uniques.
    • Avantages : Minimise la perte de données, préserve l'intention des utilisateurs.
    • Inconvénients : Requiert une logique métier complexe et spécifique à chaque type de données/champ.
  • Types de Données Répliquées Sans Conflit (CRDTs - Conflict-free Replicated Data Types) :

    • Ce sont des structures de données conçues pour pouvoir être répliquées sur plusieurs nœuds et fusionnées automatiquement sans nécessiter de logique de résolution de conflits explicite. Les opérations sont commutatives et associatives.
    • Exemples : Compteurs incrémentaux, ensembles de données (ajouter/supprimer), documents texte (éditeurs collaboratifs).
    • Avantages : Résolution automatique des conflits garantie, forte cohérence éventuelle.
    • Inconvénients : Plus complexes à concevoir et à implémenter, ne conviennent pas à tous les types de données (ex: opérations nécessitant un ordre strict).

3.3. Stratégies de Résolution Manuelle (Assistée par l'Utilisateur)

Dans certains cas, une résolution automatique n'est pas possible ou n'est pas souhaitable car elle pourrait masquer une intention importante. La meilleure approche est alors de présenter le conflit à l'utilisateur.

  • L'application affiche les différentes versions de la donnée en conflit (ex: "Votre version", "Version serveur").
  • L'utilisateur choisit la version à conserver ou fusionne manuellement les modifications via une interface dédiée.
  • Avantages : Aucune perte de données, l'utilisateur a le contrôle total.
  • Inconvénients : Interrompt le flux de travail de l'utilisateur, nécessite une interface utilisateur bien conçue pour être intuitive.

4. Mise en Œuvre Pratique et Technologies

Voyons comment implémenter un système de synchronisation basique avec résolution de conflits LWW.

4.1. Architecture Client-Serveur pour la Synchronisation

  • Côté Client :

    • Stockage Local : IndexedDB est le choix privilégié pour stocker de grandes quantités de données structurées hors ligne.
    • Service Workers : Gèrent l'interception des requêtes réseau, la mise en cache, et surtout la synchronisation en arrière-plan (Background Sync API) qui permet de reporter l'envoi des données tant que le réseau n'est pas stable.
    • Log des Modifications : Le client doit maintenir un journal des modifications locales en attente d'être synchronisées avec le serveur.
  • Côté Serveur :

    • API : Une API RESTful ou GraphQL pour recevoir et envoyer les données.
    • Base de Données : Doit supporter des champs d'horodatage ou de versioning pour chaque enregistrement.
    • Log des Modifications (optionnel) : Pour des systèmes plus avancés (CRDTs, synchronisation événementielle).

4.2. Exemple de Synchronisation Incrémentale et Résolution LWW

Nous allons simuler un scénario où un client modifie un item localement. Ces modifications sont ensuite envoyées au serveur. Le serveur applique une logique LWW pour décider si la modification du client prévaut sur sa propre version. Le client pourra ensuite pull les dernières données du serveur.

4.2.1. Côté Client : Suivi des Changements avec IndexedDB

Le code JavaScript suivant montre comment une application web pourrait stocker des items dans IndexedDB et suivre les modifications en ajoutant un timestamp last_updated_at et en les plaçant dans une "file d'attente de synchronisation" locale.

// Assurez-vous d'avoir une bibliothèque pour IndexedDB Promise, comme 'idb' de Jake Archibald.
// npm install idb ou l'inclure via CDN
// import { openDB } from 'idb'; // Si vous utilisez les modules ES6

class ItemStore {
    constructor() {
        this.dbPromise = idb.openDB('my-offline-app', 1, {
            upgrade(db) {
                // Crée un Object Store pour les items principaux
                db.createObjectStore('items', { keyPath: 'id' });
                // Crée un Object Store pour stocker les modifications en attente de synchronisation
                // Chaque entrée ici pourrait être un objet représentant la modification,
                // ou simplement l'item complet modifié pour la simplicité de l'exemple LWW.
                db.createObjectStore('sync_queue', { keyPath: 'queueId', autoIncrement: true });
            },
        });
    }

    /**
     * Récupère un item de la base de données locale.
     * @param {string} id - L'ID de l'item.
     * @returns {Promise<Object>} L'item.
     */
    async getItem(id) {
        const db = await this.dbPromise;
        return db.get('items', id);
    }

    /**
     * Sauvegarde un item localement et le marque pour synchronisation.
     * Ajoute un timestamp 'last_updated_at' et un statut '_status'.
     * @param {Object} item - L'item à sauvegarder (doit avoir un 'id').
     * @returns {Promise<Object>} L'item sauvegardé avec les métadonnées de sync.
     */
    async saveItem(item) {
        const db = await this.dbPromise;
        const now = new Date().toISOString();
        // Cloner l'item pour ne pas modifier l'objet original si besoin ailleurs
        const itemToSave = { ...item, last_updated_at: now, _status: 'pending_sync' };

        // Stocke l'item mis à jour dans l'Object Store principal
        await db.put('items', itemToSave);

        // Ajoute l'item modifié à la file d'attente de synchronisation.
        // Pour un système plus robuste, on ne stockerait pas l'item entier,
        // mais une description de la modification (ex: { id: 'item-123', changes: { name: 'new name' }, type: 'update' }).
        // Pour LWW avec timestamp, l'item complet avec son last_updated_at est suffisant.
        await db.add('sync_queue', {
            // Un identifiant unique pour cette entrée dans la queue
            // (utilisé pour supprimer l'entrée après synchronisation)
            data: itemToSave,
            timestamp: now, // Le timestamp de quand cette opération a été ajoutée à la queue
        });

        console.log(`Item "${item.id}" saved locally and added to sync queue.`);
        return itemToSave;
    }

    /**
     * Récupère tous les items en attente de synchronisation.
     * @returns {Promise<Array<Object>>} Tableau d'objets de la queue.
     */
    async getPendingSyncItems() {
        const db = await this.dbPromise;
        return db.getAll('sync_queue');
    }

    /**
     * Supprime un item de la file d'attente de synchronisation après une synchronisation réussie.
     * @param {number} queueId - L'ID de l'entrée dans la file d'attente.
     */
    async clearSyncedItem(queueId) {
        const db = await this.dbPromise;
        await db.delete('sync_queue', queueId);
        console.log(`Item with queueId ${queueId} removed from sync queue.`);
    }

    /**
     * Simule la synchronisation vers le serveur et la mise à jour locale.
     */
    async synchronize() {
        console.log('Initiating synchronization...');
        const pendingItems = await this.getPendingSyncItems();
        if (pendingItems.length === 0) {
            console.log('No pending items to sync.');
            return;
        }

        try {
            // 1. PUSH : Envoyer les changements locaux au serveur
            const response = await fetch('/sync/items', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ changes: pendingItems }),
            });
            if (!response.ok) throw new Error('Failed to push changes to server.');
            const pushResult = await response.json();
            console.log('Push result:', pushResult);

            // Supprimer les items de la queue locale qui ont été acceptés par le serveur
            for (const syncedId of pushResult.syncedQueueIds) { // Supposons que le serveur renvoie les queueIds réussis
                await this.clearSyncedItem(syncedId);
            }

            // 2. PULL : Récupérer les changements du serveur
            // On a besoin du timestamp de la dernière sync globale réussie, stockée localement
            const lastSyncTimestamp = localStorage.getItem('last_sync_timestamp') || new Date(0).toISOString();
            const pullResponse = await fetch(`/sync/items?since=${lastSyncTimestamp}`);
            if (!pullResponse.ok) throw new Error('Failed to pull changes from server.');
            const pullResult = await pullResponse.json();
            console.log('Pull result:', pullResult);

            // Appliquer les changements du serveur localement
            for (const serverItem of pullResult.changes) {
                // Pour LWW simple, on suppose que la version du serveur est la "définitive" après la résolution LWW côté serveur
                // ou bien qu'elle est plus récente si le client n'avait pas de modification locale.
                await this.dbPromise.then(db => db.put('items', { ...serverItem, _status: 'synced' }));
                console.log(`Updated local item "${serverItem.id}" from server.`);
            }

            // Mettre à jour le timestamp de la dernière synchronisation globale
            localStorage.setItem('last_sync_timestamp', pullResult.server_last_sync_timestamp || new Date().toISOString());

            console.log('Synchronization complete!');
        } catch (error) {
            console.error('Synchronization failed:', error);
            // Gérer les erreurs (ex: afficher un message à l'utilisateur, retenter plus tard)
        }
    }
}

// --- Utilisation Exemple ---
// const itemStore = new ItemStore();
//
// // Simuler une modification locale
// async function simulateLocalModification(id, newData) {
//     let item = await itemStore.getItem(id);
//     if (!item) {
//         item = { id: id, created_at: new Date().toISOString() };
//     }
//     Object.assign(item, newData);
//     await itemStore.saveItem(item);
// }
//
// // Exemple :
// // simulateLocalModification('item-123', { name: 'Nouveau Nom Local', description: 'Modifié hors ligne' });
// // simulateLocalModification('item-456', { name: 'Nouvel Item Local', value: 100 });
//
// // Pour déclencher la synchronisation (par exemple, quand l'application détecte une connexion)
// // itemStore.synchronize();

Explication du code client : Ce code JavaScript utilise IndexedDB pour :

  1. Stocker les items : L'ObjectStore items contient la version actuelle des données sur le client.
  2. Suivre les changements : Chaque fois qu'un item est modifié localement via saveItem, un last_updated_at est ajouté/mis à jour, et l'item (ou sa modification) est enregistré dans l'ObjectStore sync_queue. Chaque entrée dans sync_queue reçoit un queueId auto-incrémenté.
  3. Synchroniser : La méthode synchronize gère le processus :
    • Elle récupère toutes les modifications en attente (getPendingSyncItems).
    • Elle les envoie au serveur (PUSH via fetch POST).
    • Après un push réussi, elle nettoie la sync_queue (clearSyncedItem).
    • Elle demande ensuite au serveur toutes les modifications survenues depuis la dernière synchronisation réussie du client (PULL via fetch GET, en utilisant un last_sync_timestamp stocké dans localStorage).
    • Enfin, elle applique les changements reçus du serveur à sa base de données locale.

4.2.2. Côté Serveur : Logique de Synchronisation et Résolution LWW (Pseudo-code Node.js/Express)

Voici un pseudo-code pour une API serveur qui gère le PUSH des clients et le PULL de leurs changements, en utilisant une stratégie LWW pour la résolution des conflits.

// Pseudo-code for a server-side API endpoint using Node.js with Express
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const PORT = 3000;

// Une base de données "en mémoire" pour la démonstration.
// En production, ce serait une BDD réelle (PostgreSQL, MongoDB, etc.)
const serverDb = {};
// Un timestamp global pour la dernière modification sur le serveur,
// utile pour que les clients puissent "pull" toutes les nouveautés.
serverDb.global_last_sync_timestamp = new Date(0).toISOString();

app.use(bodyParser.json());

// --- Initialisation de données pour la démo ---
serverDb['item-123'] = {
    id: 'item-123',
    name: 'Original Item Name on Server',
    description: 'Initial state on server',
    last_updated_at: new Date(new Date().getTime() - 1000 * 60 * 5).toISOString(), // Il y a 5 minutes
    version: 1
};

// -----------------------------------------------------
// Endpoint pour les clients qui PUSHENT leurs changements (POST)
// Le client envoie un tableau de ses items modifiés localement.
// -----------------------------------------------------
app.post('/sync/items', (req, res) => {
    const clientPendingChanges = req.body.changes; // Tableau des objets de la sync_queue du client
    const syncedQueueIds = []; // IDs des items de la queue client qui ont été synchronisés avec succès
    const conflicts = [];      // Conflits détectés (si la version serveur est plus récente)

    clientPendingChanges.forEach(clientQueueEntry => {
        const clientItem = clientQueueEntry.data;
        const serverItem = serverDb[clientItem.id];

        if (!serverItem) {
            // Si l'item n'existe pas sur le serveur, il est créé.
            serverDb[clientItem.id] = clientItem;
            serverDb[clientItem.id].last_updated_at = clientItem.last_updated_at; // Server adopte le timestamp client
            syncedQueueIds.push(clientQueueEntry.queueId);
            console.log(`[SERVER] Created new item: ${clientItem.id}`);
        } else {
            // L'item existe sur le serveur, résolution de conflit avec LWW.
            const clientTimestamp = new Date(clientItem.last_updated_at).getTime();
            const serverTimestamp = new Date(serverItem.last_updated_at).getTime();

            if (clientTimestamp > serverTimestamp) {
                // Client's version is newer: Last Write Wins.
                // On fusionne les champs (ou remplace l'objet entier si la structure le permet)
                Object.assign(serverItem, clientItem);
                // Le timestamp du serveur doit refléter la dernière modification acceptée
                serverItem.last_updated_at = clientItem.last_updated_at;
                serverItem.version = (serverItem.version || 0) + 1; // Incrémenter la version
                syncedQueueIds.push(clientQueueEntry.queueId);
                console.log(`[SERVER] Updated item ${clientItem.id}: Client's version (${clientTimestamp}) won over server's (${serverTimestamp}).`);
            } else if (clientTimestamp < serverTimestamp) {
                // Server's version is newer: client's change is outdated. Server version prevails.
                // Le client devra récupérer la version du serveur lors du PULL.
                console.log(`[SERVER] Conflict detected for ${clientItem.id}: Server's version (${serverTimestamp}) is newer than client's (${clientTimestamp}). Client's change ignored.`);
                // On peut potentiellement stocker le clientQueueEntry.queueId ici dans `conflicts`
                // et l'envoyer au client pour qu'il puisse gérer le "conflit résolu par le serveur"
                // (ex: marquer l'entrée comme "non synchronisée et nécessitant un rebasage").
                conflicts.push({
                    queueId: clientQueueEntry.queueId,
                    reason: 'SERVER_VERSION_NEWER',
                    serverVersion: serverItem
                });
            } else {
                // Timestamps are identical. Assume they are the same change or server wins by default.
                console.log(`[SERVER] Timestamps identical for ${clientItem.id}. No conflict detected, server version kept.`);
                // Ici, on pourrait comparer les objets pour voir s'il y a de réelles différences,
                // ou laisser le serveur prévaloir pour éviter la perte de données si la modification est insignifiante.
            }
        }
    });

    // Mettre à jour le timestamp global du serveur après toutes les modifications
    serverDb.global_last_sync_timestamp = new Date().toISOString();

    res.json({ status: 'success', syncedQueueIds, conflicts, server_last_sync_timestamp: serverDb.global_last_sync_timestamp });
});

// -----------------------------------------------------
// Endpoint pour les clients qui PULLENT les changements (GET)
// Le client demande toutes les modifications depuis son dernier timestamp de sync.
// -----------------------------------------------------
app.get('/sync/items', (req, res) => {
    const clientLastSyncTimestamp = req.query.since || new Date(0).toISOString();
    let changesForClient = [];

    for (const id in serverDb) {
        if (id === 'global_last_sync_timestamp') continue; // Ignorer le timestamp global

        const item = serverDb[id];
        // Vérifier si l'item a été modifié sur le serveur après le dernier sync du client
        if (item.last_updated_at && new Date(item.last_updated_at).getTime() > new Date(clientLastSyncTimestamp).getTime()) {
            changesForClient.push(item);
        }
    }

    res.json({
        changes: changesForClient,
        server_last_sync_timestamp: serverDb.global_last_sync_timestamp // Le timestamp global actuel du serveur
    });
});

app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
    console.log('Initial server data:', serverDb);
});

// Pour tester, vous pouvez lancer ce script Node.js et utiliser le code client dans votre navigateur,
// ou utiliser un outil comme Postman/Insomnia.

Explication du code serveur : Ce pseudo-code Express simule un serveur de synchronisation :

  • POST /sync/items (Push) :

    • Reçoit un tableau de clientPendingChanges (les items de la sync_queue du client).
    • Pour chaque item :
      • Si l'item n'existe pas sur le serveur, il est créé.
      • S'il existe, il compare le last_updated_at de l'item client avec celui de l'item serveur.
      • Logique LWW : Si le timestamp du client est plus récent que celui du serveur, la version du client est acceptée, écrasant (ou fusionnant avec) la version du serveur.
      • Si le timestamp du serveur est plus récent, la version du client est ignorée (un conflit est détecté et la version du serveur prévaut). Le client devra alors mettre à jour sa propre copie avec la version du serveur.
    • Met à jour un global_last_sync_timestamp pour suivre le moment de la dernière modification sur le serveur.
    • Renvoie la liste des queueId des items du client qui ont été synchronisés avec succès et la liste des conflits.
  • GET /sync/items?since=<timestamp> (Pull) :

    • Reçoit un timestamp (clientLastSyncTimestamp) du client, indiquant la date de sa dernière synchronisation réussie.
    • Parcourt sa base de données (serverDb) et identifie tous les items dont le last_updated_at est plus récent que clientLastSyncTimestamp.
    • Renvoie ces items au client, ainsi que le global_last_sync_timestamp actuel du serveur.

Ce modèle bidirectionnel (client push, puis client pull) est la base de la plupart des systèmes de synchronisation incrémentale.

Conclusion

Les stratégies de synchronisation intelligente et la résolution des conflits sont au cœur de la robustesse et de la performance des applications offline-first. Nous avons vu que le défi est multiple, impliquant non seulement des considérations techniques sur la traçabilité des changements, mais aussi des décisions importantes sur la gestion des divergences de données.

  • Le choix entre synchronisation complète et incrémentale est crucial pour la performance.
  • Des mécanismes comme les horodatages ou les vecteurs de version sont essentiels pour identifier les modifications.
  • La résolution des conflits peut être simple (LWW, qui peut entraîner une perte de données) ou complexe et sémantique (CRDTs, logique métier, intervention utilisateur), selon les exigences de l'application.

Il est impératif de bien comprendre le modèle de données de votre application et les attentes de vos utilisateurs pour choisir la stratégie la plus adaptée. Une synchronisation bien conçue garantit que même dans un monde déconnecté, vos utilisateurs peuvent travailler en toute confiance, sachant que leurs données seront toujours cohérentes et à jour lorsque la connexion sera rétablie. La prochaine étape serait d'explorer des solutions plus avancées comme l'utilisation de la Background Sync API des Service Workers pour automatiser ce processus en arrière-plan.