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 |
|---|---|---|
| Créer, modifier et supprimer des objets Dolibarr hors connexion, avec détection et résolution de conflits | useSyncClient |
ci-dessous |
| Embarquer un catalogue de référence consultable hors connexion (produits, catégories, tiers, contacts) avec leurs images et PDF | useReferenceSync |
Synchronisation - catalogue de référence |
| 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 |
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 manipulent 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, auth JWT, retry |
ConflictResolver |
Composant React | Interface de résolution de conflits |
Le hook parle aux endpoints /sync/* exposés par le SyncController de
SmartAuth. Rien n'est à écrire côté serveur pour les 26 types d'objets déjà
enregistrés.
Mise en route
L'enregistrement du client est obligatoire et n'est pas automatique :
sync() ne s'enregistre pas tout seul. Sans client_uuid, aucun push ni pull
ne peut aboutir. Appelez register() une fois, typiquement juste après
l'identification de l'appareil.
import { useEffect } from 'react';
import { useSyncClient } from '@cap-rel/smartcommon';
const SyncBootstrap = ({ deviceUuid }) => {
const {
isInitialized,
isRegistered,
register,
sync,
isOnline,
pendingCount
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty', 'contact', 'product'],
autoSync: true,
syncInterval: 300000
});
useEffect(() => {
if (isInitialized && !isRegistered && deviceUuid) {
register(deviceUuid);
}
}, [isInitialized, isRegistered, deviceUuid, register]);
return (
<span>
{isOnline ? 'En ligne' : 'Hors ligne'} - {pendingCount} en attente
</span>
);
};
Le deviceUuid provient du JWT SmartAuth. Voir
SmartAuth pour l'identification d'appareil.
Options
| Option | Type | Défaut | Description |
|---|---|---|---|
apiUrl |
string | - | URL de base de l'API de synchronisation |
getAccessToken |
function | - | Fonction retournant le token JWT |
scope |
string[] | - | Types d'objets à synchroniser |
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 |
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
isOnline |
boolean | Navigateur en ligne |
isServerReachable |
boolean/null | Serveur joignable, via GET /sync/status |
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 |
number | Horodatage de la dernière synchronisation |
pendingCount |
number | Changements locaux en attente de push |
conflictsCount |
number | Conflits non résolus |
syncError |
Error | Dernière erreur de synchronisation |
register |
function | register(deviceUuid), enregistre le client |
sync |
function | Push puis pull |
push |
function | Push seul |
pull |
function | Pull seul |
create |
function | create(table, data), retourne un id temporaire |
update |
function | update(table, id, data) |
remove |
function | remove(table, id) |
upsert |
function | upsert(table, id, data, queueChange = false) |
getEntity |
function | getEntity(table, id), lecture locale |
queryEntities |
function | queryEntities(table, filter), lecture locale filtrée |
getConflicts |
function | Liste les conflits |
resolveConflict |
function | resolveConflict(conflictId, resolution, data) |
getStatus |
function | État complet du moteur |
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() les envoie par lots de 50 maximum
4. Le serveur confirme, ou signale un conflit
5. Les id temporaires locaux sont remplacés par les id serveur
Pull, du serveur vers le local :
1. sync() ou pull() demande les changements depuis lastSyncTime
2. Le serveur renvoie les entités modifiées, page par page
3. Les entités locales sont mises à jour
4. Si une entité a bougé des deux côtés, un conflit est enregistré
5. Le marqueur lastSyncTime n'avance qu'une fois toutes les pages traitées
Ce dernier point est volontaire : une coupure en cours de pull laisse le marqueur intact, donc la passe suivante reprend tout plutôt que de sauter silencieusement des enregistrements.
Gestion des conflits
Un conflit est créé quand une entité a été modifiée localement et sur le
serveur depuis la dernière synchronisation. La détection s'appuie sur le tms
de l'objet Dolibarr, comparé champ par champ.
Les résolutions sont 'client', 'server', ou un objet de fusion passé 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, 'client', {
...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({ /* ... */ });
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 (clientUuid, lastSyncTime, 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 un health check serveur optionnel, avec un délai
de stabilité avant de déclarer le retour en ligne.
const { isOnline, isServerReachable, checkNow } = useOnlineStatus({
healthCheckUrl: '/api/smartauth/sync/status',
healthCheckInterval: 60000,
stabilityDelay: 2000
});
Sans healthCheckUrl, seul navigator.onLine est consulté, ce qui ne détecte
pas un portail captif ni un serveur tombé.
Enregistrement du client
useSyncClient et useReferenceSync appellent tous deux POST /sync/register
et manipulent un client_uuid. La différence tient à qui le fabrique :
useSyncClient reçoit du serveur le client_uuid issu du deviceUuid que vous
lui passez, useReferenceSync en génère un et le persiste lui-même dans son
store de métadonnées.
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