---
title: "Chapitre 1 : Mode offline"
weight: 710
---

# 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](/front/synchronisation) 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`](/training/module7-smartcommon-hooks/sync), rien à écrire |
| Embarquer un référentiel consultable hors connexion, avec ses images | [`useReferenceSync`](/front/synchronisation-reference), 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.

```javascript
// 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à :

```php
$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
-- 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.

```javascript
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.

```javascript
} 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

```javascript
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

```javascript
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.

```javascript
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 :

```javascript
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 :

```javascript
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 :

```javascript
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](/training/module10-fonctionnalites-avancees) | [Chapitre suivant : Internationalisation ->](/training/module10-fonctionnalites-avancees/i18n)
