Synchronisation offline
SmartMaker ne propose pas un mécanisme de synchronisation, mais trois, qui ne servent pas au même usage. Choisir le mauvais est la principale cause de travail jeté dans un projet offline.
Cette page sert d'aiguillage. Chaque dispositif est ensuite détaillé dans sa propre page.
Choisir son dispositif
| Votre besoin | Dispositif | Page |
|---|---|---|
| Embarquer un référentiel consultable hors connexion (produits, catégories, tiers, contacts) avec leurs images et PDF | useReferenceSync |
Synchronisation - catalogue de référence |
| Créer, modifier et supprimer des objets Dolibarr hors connexion, avec détection et résolution de conflits | useSyncClient |
ci-dessous |
| Rejouer plus tard une action métier complète (clôturer une fiche, valider un document, envoyer une photo signée) | File d'attente métier, écrite dans le module | Mode offline |
Dans le doute, commencez par useReferenceSync : la plupart des écrans
hors connexion consultent des données sans les modifier, et il écrit dans les
stores Dexie de votre module, que vos écrans interrogent directement.
useSyncClient ne se justifie que si l'application modifie elle-même des
objets Dolibarr hors connexion.
Les trois se combinent. CapFullPOS, par exemple, tire son catalogue avec
useReferenceSync et pousse ses ventes avec useSyncClient.
1. useSyncClient : moteur transactionnel générique
useSyncClient synchronise des objets Dolibarr champ par champ, dans les
deux sens, avec détection de conflits. C'est le dispositif à préférer quand vos
écrans modifient directement des objets du modèle Dolibarr.
Architecture
| Élément | Type | Rôle |
|---|---|---|
useSyncClient |
Hook React | Interface principale |
SyncEngine |
Classe | Moteur push / pull / conflits |
SyncStorage |
Classe | Couche IndexedDB (Dexie), base dédiée smartauth_sync |
SyncApi |
Classe | Client HTTP des routes sync/*, via useApi().private |
ConflictResolver |
Composant React | Interface de résolution de conflits |
Le hook parle aux routes sync/* exposées par le SyncController de
SmartAuth. Rien n'est à écrire côté serveur pour les types d'objets déjà
enregistrés (thirdparty, contact, product, category, order,
invoice...).
Prérequis
- Le hook passe par
useApi: il doit être monté sous<Provider>. Le token, l'en-têteX-DeviceIdet le rafraîchissement du token sont gérés paruseApi, aucun composant n'a à fournir d'URL ni de token. - L'appareil doit être identifié (
<DeviceIdentificationComponent>) :sync/registerlit l'appareil dans le token et répond 400 sinon. scopeliste des types d'objets SmartAuth, parents d'abord : les créations partent dans cet ordre, les suppressions dans l'ordre inverse.
Mise en route
import { useSyncClient } from '@cap-rel/smartcommon';
import { APP_VERSION } from 'src/utils/constants/vite';
const SyncStatus = () => {
const { isOnline, pendingCount } = useSyncClient({
scope: ['thirdparty', 'contact', 'product'],
appVersion: APP_VERSION,
autoSync: true,
syncInterval: 300000
});
return (
<span>
{isOnline ? 'En ligne' : 'Hors ligne'} - {pendingCount} en attente
</span>
);
};
L'enregistrement du client est automatique : au premier sync(), push()
ou pull(), le hook génère un client_uuid, le garde dans sa base et
l'envoie à sync/register. Si le serveur ne connaît plus ce client (base
réinitialisée), il se réenregistre avec le même identifiant et rejoue la
requête. register() reste disponible pour s'enregistrer avant la première
synchronisation.
Options
| Option | Type | Défaut | Description |
|---|---|---|---|
scope |
string[] | - | Types d'objets SmartAuth à synchroniser, parents d'abord |
appVersion |
string | null |
Version envoyée à sync/register, utile au diagnostic |
healthCheckUrl |
string/null | route health |
Sonde de joignabilité de useOnlineStatus, null pour l'état réseau seul |
autoSync |
boolean | true |
Synchronise au retour en ligne, après 2 s de stabilité, seulement s'il y a des changements en attente |
syncInterval |
number | null |
Synchronisation périodique, en millisecondes |
onConflict |
function | null |
Appelée avec la liste des conflits détectés |
onSyncStart |
function | null |
Début de synchronisation |
onSyncComplete |
function | null |
Fin de synchronisation, reçoit le résultat |
onSyncError |
function | null |
Échec de synchronisation |
dbName |
string | smartauth_sync |
Nom de la base IndexedDB locale |
apiUrl et getAccessToken appartenaient à l'ancien contrat : ils sont
ignorés, avec un avertissement journalisé une fois.
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
isOnline |
boolean | Navigateur en ligne |
isServerReachable |
boolean/null | Serveur joignable, via la route publique health (null si la sonde est désactivée) |
checkNow |
function | Force une vérification de connectivité |
isInitialized |
boolean | Stockage local et moteur prêts |
isRegistered |
boolean | Client enregistré auprès du serveur |
isSyncing |
boolean | Synchronisation en cours |
lastSyncTime |
string | Heure serveur de la dernière synchronisation complète |
pendingCount |
number | Changements locaux en attente de push |
conflictsCount |
number | Conflits non résolus |
syncError |
Error | Dernière erreur de synchronisation |
register |
function | register(), enregistre le client sans attendre la première synchronisation |
sync |
function | Push puis pull |
push |
function | Push seul |
pull |
function | Pull seul |
create |
function | create(type, data), retourne un id temporaire local_... |
update |
function | update(type, id, data) |
remove |
function | remove(type, id) |
upsert |
function | upsert(type, id, data, queueChange = false) |
getEntity |
function | getEntity(type, id), lecture locale |
queryEntities |
function | queryEntities(type, filter), lecture locale filtrée |
getConflicts |
function | Liste les conflits |
resolveConflict |
function | resolveConflict(conflictId, resolution, data) |
getStatus |
function | État local du moteur |
getServerStatus |
function | État du client côté serveur (GET sync/status) |
reset |
function | Efface toutes les données de synchronisation locales |
upsertavecqueueChangeàfalseécrit en local sans rien pousser. C'est la bonne méthode pour mettre en cache une réponse serveur. AvecqueueChangeàtrue, la modification rejoint la file de push.
Flux de synchronisation
Push, du local vers le serveur :
1. L'utilisateur modifie des données localement (create / update / remove)
2. Les changements sont stockés dans IndexedDB (pending_changes)
3. sync() ou push() regroupe les changements d'une même entité (une création
suivie de modifications part en une seule création)
4. Un appel sync/push par type d'objet, 50 changements au plus par appel :
créations des parents d'abord, suppressions des enfants d'abord
5. Le serveur confirme, refuse ou signale un conflit
6. Les id temporaires sont remplacés par les id serveur, dans l'entité comme
dans les clés étrangères des autres changements
Un enfant dont le parent n'est pas encore créé reste en file
(WAITING_FOR_PARENT). Un objet absent côté serveur ("Object not found") est
retiré de la file ; les autres refus restent en file pour le prochain push.
Pull, du serveur vers le local :
1. sync() ou pull() interroge sync/pull type par type
2. Le serveur renvoie les entités modifiées, page par page (next_cursor)
3. Les entités locales sont mises à jour, sauf celles modifiées localement
4. Chaque type garde son propre point de reprise, avancé une fois toutes ses
pages traitées
Ce dernier point est volontaire : une coupure en cours de pull laisse le
point de reprise intact, donc la passe suivante reprend tout plutôt que de
sauter silencieusement des enregistrements. Un type en erreur n'arrête pas les
autres. Un 403 arrête tout (ForbiddenSyncError), pour ne pas se faire
bloquer par le pare-feu.
Gestion des conflits
Un conflit est créé quand une entité a été modifiée localement et sur le
serveur depuis que le client l'a reçue. La détection s'appuie sur le tms de
l'objet Dolibarr, comparé champ par champ.
- Modification : SmartAuth enregistre le conflit.
getConflicts()renvoie sa liste (identifiant numérique,client_data,server_data,field_conflictssous forme de liste de champs) etresolveConflict()passe parsync/conflicts/{id}/resolve. - Suppression : SmartAuth ne l'enregistre pas. Le conflit reste local
(identifiant
local:<type>:<id>) :'client'remet la suppression en file contre la version serveur actuelle,'server'y renonce et récupère à nouveau l'objet au prochain pull. La fusion est refusée. - Hors ligne,
getConflicts()renvoie les conflits locaux uniquement.
Les résolutions sont 'client', 'server' et 'merged', cette dernière avec
l'objet fusionné en troisième argument.
const conflicts = await sync.getConflicts();
for (const conflict of conflicts) {
// Garder la version client
await sync.resolveConflict(conflict.conflict_id, 'client');
// Garder la version serveur
await sync.resolveConflict(conflict.conflict_id, 'server');
// Fusionner champ par champ
await sync.resolveConflict(conflict.conflict_id, 'merged', {
...conflict.server_data,
label: conflict.client_data.label
});
}
Le composant ConflictResolver fournit l'interface correspondante :
import { useState, useEffect } from 'react';
import { ConflictResolver, useSyncClient } from '@cap-rel/smartcommon';
const ConflictsPage = () => {
const sync = useSyncClient({ scope: ['thirdparty', 'contact'] });
const [conflicts, setConflicts] = useState([]);
useEffect(() => {
sync.getConflicts().then(setConflicts);
}, []);
if (conflicts.length === 0) return null;
return (
<ConflictResolver
conflicts={conflicts}
onResolve={async (conflictId, resolution, data) => {
await sync.resolveConflict(conflictId, resolution, data);
setConflicts(prev => prev.filter(c => c.conflict_id !== conflictId));
}}
onCancel={() => setConflicts([])}
/>
);
};
Il affiche une comparaison côte à côte, marque les champs en conflit, propose de garder le client, le serveur ou de fusionner champ par champ, et navigue entre les conflits multiples.
| Prop | Type | Description |
|---|---|---|
conflicts |
array | Conflits (conflict_id, table, object_id, client_data, server_data, field_conflicts) |
onResolve |
function | (conflictId, resolution, data) |
onCancel |
function | Ferme le résolveur |
renderField |
function | Rendu personnalisé d'un champ (optionnel) |
labels |
object | Libellés de l'interface (optionnel, français par défaut) |
Schéma IndexedDB
useSyncClient gère sa propre base, distincte de celle de votre module.
| Store | Description |
|---|---|
entities |
Données synchronisées |
pending_changes |
Changements locaux en attente de push |
pending_conflicts |
Conflits non résolus |
sync_meta |
Métadonnées (client_uuid, points de reprise par type, sync_scope) |
local_tombstones |
Entités supprimées localement |
Conséquence pratique : vos écrans lisent les données par getEntity et
queryEntities, pas directement dans les stores Dexie du module. Si vous voulez
garder la main sur vos propres stores, c'est useReferenceSync qu'il vous faut.
Ce que useSyncClient ne fait pas
- Il ne synchronise pas des actions métier, seulement des états d'objets. Une clôture de fiche, un changement de statut soumis à des règles serveur ou un envoi de document ne se modélisent pas comme une mise à jour de champs.
- Il ne gère pas les objets composites (documents avec leurs lignes). C'est prévu, ce n'est pas implémenté.
- Il n'embarque pas les fichiers joints. Voir Synchronisation - catalogue de référence pour les blobs.
2. useReferenceSync : catalogue de référence, pull seul
Quand l'application a besoin de consulter hors connexion un référentiel
volumineux, sans jamais le modifier, useSyncClient est surdimensionné et sa
base séparée devient une gêne.
useReferenceSync tire les données dans vos propres stores Dexie, avec les
images et PDF associés téléchargés en lots ZIP, et vos écrans interrogent
directement ces stores.
Voir Synchronisation - catalogue de référence.
3. File d'attente d'actions métier
Certains modules ne synchronisent pas des lignes de table mais des gestes métier : clôturer une intervention, ajouter une pièce consommée, faire remonter une photo annotée et signée. La sémantique, les règles de validation et l'idempotence vivent alors dans les contrôleurs du module, pas dans un moteur générique.
C'est le choix de smartInterventions. Le principe :
- chaque geste est écrit en local comme une ligne de file d'attente, avec un
client_uuidgénéré côté client qui sert de clé d'idempotence ; - la file est vidée au retour en ligne, en appelant les endpoints métier du module ;
- le serveur reconnaît un rejeu grâce au
client_uuidet répond sans dupliquer ; - une ligne en échec reste visible, avec son message d'erreur, pour un nouvel essai manuel.
Ce n'est pas une solution de repli faute de moteur générique : c'est le seul
modèle correct quand l'action à rejouer n'est pas réductible à un UPDATE de
champs.
Voir Mode offline pour le pattern complet.
Ce qui est commun aux trois
Détection de connectivité
useOnlineStatus est utilisé par les trois, directement ou indirectement. Il
combine l'état du navigateur et une sonde serveur, avec un délai de stabilité
avant de déclarer le retour en ligne.
Par défaut, la sonde est une requête HEAD sans token sur la route publique
health de SmartAuth, sous le prefixUrl de l'API (par exemple
/mymodule/pwa/api.php/health). Elle répond 204 sans toucher à la base. Les
instances montées partagent une seule requête par URL.
// Sonde par défaut : <prefixUrl>/health
const { isOnline, isServerReachable, checkNow } = useOnlineStatus({
healthCheckInterval: 60000,
stabilityDelay: 2000
});
// État réseau seul, sans requête
const { isOnline: online } = useOnlineStatus({ healthCheckUrl: null });
N'utilisez pas sync/status comme sonde : la route exige un token et un
client_uuid, une requête HEAD sans token y échoue toujours et le serveur
paraît injoignable. Sans sonde, seul navigator.onLine est consulté, ce qui ne
détecte ni un portail captif ni un serveur tombé.
Enregistrement du client : device_uuid et client_uuid
useSyncClient et useReferenceSync appellent tous deux POST sync/register
et manipulent deux identifiants à ne pas confondre :
| Identifiant | Fourni par | Porté par | Rôle |
|---|---|---|---|
device_uuid |
SmartAuth, à l'identification de l'appareil | le JWT | identifie l'appareil, le serveur le lit dans le token |
client_uuid |
le client, généré une fois et stocké localement | la query (GET) ou le body (POST) de chaque appel sync/* |
identifie le client de synchronisation, ses points de reprise et ses conflits |
Aucun des deux n'est à transmettre à la main : les deux hooks génèrent et
stockent leur client_uuid (useSyncClient dans sa base, useReferenceSync
dans votre store de métadonnées) et s'enregistrent d'eux-mêmes.
Mise à jour de l'application
Activer une nouvelle version du Service Worker ne détruit pas les files
d'attente : elles vivent dans IndexedDB, pas dans le cache du Service Worker.
Voir PWA pour usePWAUpdate, UpdatePrompt et la prop
pwaUpdate du Provider.
Le rôle des classes Dm
Côté serveur, ce sont les mêmes mappers Dm<Entity> qui servent la façade REST
et la synchronisation. En particulier, l'allowlist d'écriture d'un objet
synchronisé est le $writableFields de son mapper : un champ qui n'y figure
pas est rejeté silencieusement au push. Voir
Mapping Dolibarr - React.
Voir aussi
- Synchronisation - catalogue de référence -
useReferenceSyncen détail - Hooks SmartCommon - tous les hooks
- Stockage de données -
useDb,useCachedQuery - PWA - Service Worker et mises à jour
- Mapping Dolibarr - React - les classes
Dm