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ête X-DeviceId et le rafraîchissement du token sont gérés par useApi, aucun composant n'a à fournir d'URL ni de token.
  • L'appareil doit être identifié (<DeviceIdentificationComponent>) : sync/register lit l'appareil dans le token et répond 400 sinon.
  • scope liste 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

upsert avec queueChange à false écrit en local sans rien pousser. C'est la bonne méthode pour mettre en cache une réponse serveur. Avec queueChange à 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_conflicts sous forme de liste de champs) et resolveConflict() passe par sync/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_uuid gé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_uuid et 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