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
POSTet desPUTde champs. C'est exactement ce queuseSyncClientfait, 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
- Choisir le bon dispositif avant d'écrire une file : dans la plupart des
cas,
useSyncClientrépond déjà au besoin - L'idempotence d'abord : un
client_uuidfabriqué par le client, un index unique côté serveur, un rejeu qui répond 200 sans dupliquer - Signaler le rejeu au serveur avec
X-Offline-Sync, pour qu'il recalcule ce qui ne doit pas venir du client - Traiter les transitions non rejouables : un 409 sur une clôture déjà faite est un succès
- Plafonner les tentatives et offrir une reprise manuelle
- Journaliser chaque échec avant de le stocker
- Prévenir l'interface par événement, sans scrutation
<- Retour au module | Chapitre suivant : Internationalisation ->