Chapitre 1 : Mode offline

Ce chapitre traite d'un cas précis : rejouer plus tard une action métier qu'un utilisateur a déclenchée sans connexion. Clôturer une intervention, ajouter une pièce consommée, faire remonter une photo annotée et signée.

Ce n'est pas le seul dispositif offline de SmartMaker, et souvent ce n'est pas le bon. Lisez Synchronisation offline avant de vous engager.

Choisir, d'abord

Ce que vous voulez faire Ce qu'il faut utiliser
Créer et modifier des objets Dolibarr hors connexion, avec gestion de conflits useSyncClient, rien à écrire
Embarquer un référentiel consultable hors connexion, avec ses images useReferenceSync, rien à écrire
Rejouer une action métier soumise à des règles serveur Ce chapitre

Le critère est simple. Si l'action se réduit à "ces champs valent maintenant ces valeurs", useSyncClient la traite déjà, et mieux que du code écrit à la main. Si l'action est un geste que le serveur doit valider, séquencer ou refuser, aucun moteur générique ne peut la porter : il faut une file d'attente dans le module.

N'écrivez pas une file maison qui se contente de rejouer des POST et des PUT de champs. C'est exactement ce que useSyncClient fait, avec en plus les tombstones, la détection de conflits et la reprise sur coupure.

Le principe

1. L'utilisateur déclenche l'action, avec ou sans réseau
2. L'action est écrite en local comme une ligne de file d'attente
3. L'interface affiche la ligne comme "en attente de synchronisation"
4. Au retour du réseau, la file est vidée en appelant les endpoints métier
5. Le serveur reconnaît un éventuel rejeu et ne duplique rien
6. Une ligne en échec reste visible, avec son message, pour un nouvel essai

Tout repose sur le point 5. Sans lui, une coupure au mauvais moment crée des doublons, et le pattern ne tient pas.

Le contrat d'idempotence

C'est la partie à concevoir en premier, avant toute ligne de code React.

Création : un identifiant fabriqué par le client

Le client génère un client_uuid au moment où il met l'action en file. Cet identifiant sert à la fois de clé primaire locale et de clé d'idempotence côté serveur.

// src/lib/taskSync.js
import { db } from 'src/db';
import { generateUuidV4 } from './clientUuid';

export const enqueueTask = async (payload, projectId) => {
    const clientUuid = payload.client_uuid || generateUuidV4();

    const row = {
        id: clientUuid,
        payload: { ...payload, client_uuid: clientUuid, fk_project: projectId },
        projectId,
        op: 'create',
        status: 'pending',
        attempts: 0,
        lastAttemptAt: null,
        lastError: null,
        serverId: null,
        updatedAt: Date.now()
    };

    await db.taskQueue.put(row);
    notifyChange();

    return clientUuid;
};

Le put sur l'identifiant rend l'enfilement lui-même idempotent : ré-enfiler la même action écrase la ligne au lieu d'en créer une deuxième.

Côté serveur, le contrôleur cherche d'abord si le client_uuid existe déjà :

$existing = \MyModuleTask::fetchByClientUuid($db, $entity, $clientUuid);
if ($existing !== null) {
    // Rejeu : on renvoie l'objet existant, sans rien créer
    return [$mapper->exportMappedData($existing), 200];
}

La convention est de répondre 201 pour une création réelle et 200 pour un rejeu reconnu. Le client traite les deux comme un succès.

Prévoyez la colonne dès le départ, avec un index unique :

-- sql/llx_mymodule_task.key.sql
CREATE UNIQUE INDEX uk_mymodule_task_client_uuid
    ON llx_mymodule_task (entity, client_uuid);

Un en-tête pour signaler le rejeu

Un POST arrivant d'une file différée n'a pas la même confiance qu'un POST arrivant d'un écran en ligne. Le prix d'une pièce calculé il y a trois heures sur un appareil hors connexion, par exemple, ne doit pas faire autorité.

La convention SmartMaker est un en-tête X-Offline-Sync: 1, que le contrôleur utilise pour recalculer côté serveur ce qui doit l'être et ignorer les valeurs sensibles du corps de la requête.

await api.private.post('task', {
    json: item.payload,
    headers: { 'X-Offline-Sync': '1' }
});

Modification : attention aux transitions non rejouables

Un PUT /task/{id} qui écrit des champs se rejoue sans dommage. Une transition d'état ne se rejoue pas : clôturer une fiche déjà close renvoie une erreur, typiquement un 409.

Ce 409 n'est pas un échec. L'état visé est atteint. Traitez-le comme un succès terminal, sans quoi la ligne reste coincée dans la file et l'objet reste affiché comme "en attente" indéfiniment.

} catch (err) {
    const status = err?.response?.status ?? null;

    // 409 sur une clôture : la fiche est déjà close, l'objectif est atteint
    if (status === 409 && item.op === 'close') {
        await db.taskQueue.delete(item.id);
        ok += 1;
        continue;
    }

    console.error('taskSync.syncQueue: sync failed', { id: item.id, status, err });
    // ... marquage en erreur
}

Le schéma de la file

import { useDb } from '@cap-rel/smartcommon';

const db = useDb({
    name: 'monApp',
    version: 1,
    stores: {
        // Données métier lues par les écrans
        tasks: 'id, ref, label, status, updatedAt',

        // File d'attente, indexée sur le client_uuid
        taskQueue: 'id, status, projectId, updatedAt'
    }
});
Champ Rôle
id Le client_uuid, clé d'idempotence
payload Le corps prêt à être envoyé, sans champ transitoire d'interface
op create, update, close... route vers le bon endpoint
status pending, syncing, synced, error
attempts Compteur de tentatives automatiques
lastAttemptAt Horodatage, sert à détecter les lignes zombies
lastError Message d'erreur, affiché à l'utilisateur
serverId Identifiant serveur, renseigné après la première réussite

Nettoyez le payload des champs d'affichage au moment de l'enfilement, pas au moment de l'envoi : la ligne stockée doit être prête à partir telle quelle.

Vider la file

export const MAX_AUTO_ATTEMPTS = 5;

export const syncQueue = async ({ api, includeMaxedOut = false } = {}) => {
    if (!api || typeof api.post !== 'function') {
        console.error('taskSync.syncQueue: missing api.post');
        return { ok: 0, ko: 0, skipped: 0 };
    }

    // Les lignes bloquées en "syncing" viennent d'une application tuée en
    // plein vol : le rejeu étant idempotent, on les remet en "pending".
    await reviveStuckSyncing();

    const all = await db.taskQueue.toArray();
    const pending = all.filter(r => r.status === 'pending' || r.status === 'error');

    let ok = 0, ko = 0, skipped = 0;

    for (const item of pending) {
        if (!includeMaxedOut && item.attempts >= MAX_AUTO_ATTEMPTS) {
            skipped += 1;
            continue;
        }

        await db.taskQueue.update(item.id, {
            status: 'syncing',
            lastAttemptAt: Date.now(),
            updatedAt: Date.now()
        });

        try {
            const isUpdate = item.op === 'update' && Number(item.serverId) > 0;

            const res = isUpdate
                ? await api.private.put(`task/${item.serverId}`, {
                    json: item.payload,
                    headers: { 'X-Offline-Sync': '1' }
                })
                : await api.private.post('task', {
                    json: item.payload,
                    headers: { 'X-Offline-Sync': '1' }
                });

            await db.taskQueue.delete(item.id);
            await db.tasks.put({ ...item.payload, id: res.id, synced: true });
            ok += 1;
        } catch (err) {
            const message = err?.apiMessage || err?.message || String(err);
            console.error('taskSync.syncQueue: sync failed', { id: item.id, message });

            await db.taskQueue.update(item.id, {
                status: 'error',
                attempts: (item.attempts ?? 0) + 1,
                lastError: message,
                updatedAt: Date.now()
            });
            ko += 1;
        }
    }

    notifyChange();
    return { ok, ko, skipped };
};

Trois décisions méritent d'être expliquées.

Le marquage en syncing avant l'appel. Il évite qu'un second déclenchement concurrent reprenne la même ligne. En contrepartie, une application tuée pendant la requête laisse une ligne bloquée dans cet état : c'est le rôle de reviveStuckSyncing, qui repasse en pending toute ligne syncing dont le lastAttemptAt remonte à plus de quelques minutes. Le rejeu étant idempotent, cette reprise est sans risque.

Le plafond de tentatives. Au-delà de MAX_AUTO_ATTEMPTS, les passes automatiques laissent la ligne tranquille. Sans plafond, une ligne définitivement invalide, refusée par une règle métier, serait renvoyée à chaque retour de réseau jusqu'à la fin des temps. L'utilisateur garde la main avec un bouton de reprise manuelle, qui remet le compteur à zéro.

L'erreur est journalisée avant d'être stockée. Une file qui échoue en silence est impossible à diagnostiquer sur le terrain.

Prévenir l'interface

La file est modifiée depuis plusieurs endroits, y compris en arrière-plan. Les écrans qui affichent un compteur ou un badge "en attente" doivent se rafraîchir sans faire de scrutation.

export const TASK_QUEUE_EVENT = 'task-queue-changed';

export const notifyChange = () => {
    if (typeof window === 'undefined') return;
    try {
        window.dispatchEvent(new Event(TASK_QUEUE_EVENT));
    } catch (err) {
        console.error('taskSync.notifyChange: dispatch failed', err);
    }
};

Côté composant :

import { useEffect } from 'react';
import { useStates } from '@cap-rel/smartcommon';
import { db } from 'src/db';
import { TASK_QUEUE_EVENT } from 'src/lib/taskSync';

export const PendingBadge = () => {
    const st = useStates({ initialStates: { count: 0 } });

    useEffect(() => {
        const refresh = async () => {
            const rows = await db.taskQueue.toArray();
            st.set('count', rows.filter(r => r.status !== 'synced').length);
        };

        refresh();
        window.addEventListener(TASK_QUEUE_EVENT, refresh);

        return () => window.removeEventListener(TASK_QUEUE_EVENT, refresh);
    }, []);

    if (st.get('count') === 0) return null;

    return <Tag color="orange">{st.get('count')} en attente</Tag>;
};

Déclencher la vidange

Trois déclencheurs, cumulables :

import { useEffect } from 'react';
import { useApi, useOnlineStatus } from '@cap-rel/smartcommon';
import { syncQueue } from 'src/lib/taskSync';

export const useTaskQueueDrain = () => {
    const api = useApi();
    const { isOnline, isServerReachable } = useOnlineStatus({
        healthCheckUrl: '/api/health'
    });

    // 1. Au retour du réseau
    useEffect(() => {
        if (isOnline && isServerReachable) {
            syncQueue({ api }).catch(err =>
                console.error('useTaskQueueDrain: drain failed', err)
            );
        }
    }, [isOnline, isServerReachable]);

    // 2. Au geste de rafraîchissement de l'utilisateur, et 3. sur un bouton
    //    de reprise manuelle, en passant includeMaxedOut à true
    return {
        drain: () => syncQueue({ api }),
        retryAll: () => syncQueue({ api, includeMaxedOut: true })
    };
};

Préférez useOnlineStatus à l'événement online du navigateur. Ce dernier ne dit rien de la joignabilité réelle du serveur : un portail captif d'hôtel le déclenche, alors qu'aucune requête ne passera.

Files dépendantes : le cas des fichiers

Un geste métier embarque souvent une photo ou une signature, dont l'envoi est lui-même différé. L'action ne peut alors partir qu'une fois ses fichiers remontés et leurs identifiants connus.

La solution est de garder dans la ligne la liste des envois en attente :

const row = {
    id: clientUuid,
    payload: body,
    // Champs dont l'envoi est différé : ils portent un identifiant local
    // mais pas encore d'identifiant serveur
    pendingUploads: ['signature', 'photo_before'],
    status: 'pending'
};

La vidange saute les lignes dont pendingUploads n'est pas vide. À chaque envoi de fichier réussi, le champ correspondant du payload est complété et l'entrée retirée de la liste. Quand elle se vide, la ligne devient éligible et peut partir immédiatement.

Pièges classiques

Écraser les modifications locales au rafraîchissement. Un db.tasks.clear() suivi d'un bulkAdd des données serveur détruit tout ce que l'utilisateur a saisi hors connexion et qui n'est pas encore parti. Fusionnez au lieu de remplacer, ou ne rafraîchissez que les objets absents de la file.

Verrouiller ou non l'objet en attente. Un objet dont l'action est en file peut être ré-édité par l'utilisateur, ce qui produit deux actions concurrentes sur le même objet. Le plus simple est de le passer en lecture seule tant que sa ligne est en file, et de l'annoncer clairement dans l'interface.

Faire confiance à navigator.onLine. Il ne reflète que l'état de l'interface réseau, pas la joignabilité du serveur.

Oublier que la file survit à une mise à jour de l'application. Elle vit dans IndexedDB, pas dans le cache du Service Worker. C'est une bonne nouvelle, mais cela veut dire qu'un changement du format de payload doit prévoir une migration pour les lignes déjà en attente sur les appareils.

Points clés à retenir

  1. Choisir le bon dispositif avant d'écrire une file : dans la plupart des cas, useSyncClient répond déjà au besoin
  2. L'idempotence d'abord : un client_uuid fabriqué par le client, un index unique côté serveur, un rejeu qui répond 200 sans dupliquer
  3. Signaler le rejeu au serveur avec X-Offline-Sync, pour qu'il recalcule ce qui ne doit pas venir du client
  4. Traiter les transitions non rejouables : un 409 sur une clôture déjà faite est un succès
  5. Plafonner les tentatives et offrir une reprise manuelle
  6. Journaliser chaque échec avant de le stocker
  7. Prévenir l'interface par événement, sans scrutation

<- Retour au module | Chapitre suivant : Internationalisation ->