---
title: "Synchronisation offline"
weight: 190
---

# 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](/front/synchronisation-reference) |
| 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](/training/module10-fonctionnalites-avancees/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.

```javascript
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](/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.

```javascript
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 :

```javascript
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](/front/synchronisation-reference) 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](/front/synchronisation-reference).

## 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](/training/module10-fonctionnalites-avancees/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.

```javascript
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](/front/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](/back/mapping-dolibarr-react).

## Voir aussi

- [Synchronisation - catalogue de référence](/front/synchronisation-reference) - `useReferenceSync` en détail
- [Hooks SmartCommon](/front/hooks) - tous les hooks
- [Stockage de données](/front/stockage-de-donnees) - `useDb`, `useCachedQuery`
- [PWA](/front/pwa) - Service Worker et mises à jour
- [Mapping Dolibarr - React](/back/mapping-dolibarr-react) - les classes `Dm`
