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

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() 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_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 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