---
source_hash: "8a71de8e"
title: "Offline Synchronization"
weight: 190
---

# Offline Synchronization

SmartMaker does not offer one synchronization mechanism, but **three**, which do
not serve the same purpose. Picking the wrong one is the main cause of wasted
work in an offline project.

This page is the signpost. Each mechanism is then detailed on its own page.

## Choosing a mechanism

| Your need | Mechanism | Page |
| --- | --- | --- |
| Create, modify and delete Dolibarr objects offline, with conflict detection and resolution | `useSyncClient` | below |
| Embed a reference catalog for offline browsing (products, categories, third parties, contacts) along with their images and PDF files | `useReferenceSync` | [Synchronization - reference catalog](/front/synchronisation-reference) |
| Replay a complete business action later (close a work order, validate a document, send a signed photo) | Business queue, written in the module | [Offline mode](/training/module10-fonctionnalites-avancees/offline) |

The three combine. CapFullPOS, for instance, pulls its catalog with
`useReferenceSync` and pushes its sales with `useSyncClient`.

## 1. useSyncClient: generic transactional engine

`useSyncClient` synchronizes **Dolibarr objects field by field**, both ways,
with conflict detection. This is the mechanism to prefer when your screens
handle Dolibarr model objects directly.

### Architecture

| Element | Type | Role |
| --- | --- | --- |
| `useSyncClient` | React hook | Main interface |
| `SyncEngine` | Class | Push / pull / conflict engine |
| `SyncStorage` | Class | IndexedDB layer (Dexie), dedicated `smartauth_sync` database |
| `SyncApi` | Class | HTTP client, JWT auth, retry |
| `ConflictResolver` | React component | Conflict resolution interface |

The hook talks to the `/sync/*` endpoints exposed by SmartAuth's
`SyncController`. Nothing has to be written server-side for the 26 object types
already registered.

### Getting started

Client registration is **mandatory and not automatic**: `sync()` does not
register on its own. Without a `client_uuid`, neither push nor pull can
succeed. Call `register()` once, typically right after device identification.

```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 ? 'Online' : 'Offline'} - {pendingCount} pending
        </span>
    );
};
```

The `deviceUuid` comes from the SmartAuth JWT. See [SmartAuth](/smartauth) for
device identification.

### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `apiUrl` | string | - | Base URL of the sync API |
| `getAccessToken` | function | - | Function returning the JWT token |
| `scope` | string[] | - | Object types to synchronize |
| `autoSync` | boolean | `true` | Syncs when coming back online, after 2 s of stability, only if changes are pending |
| `syncInterval` | number | `null` | Periodic synchronization, in milliseconds |
| `onConflict` | function | `null` | Called with the list of detected conflicts |
| `onSyncStart` | function | `null` | Synchronization started |
| `onSyncComplete` | function | `null` | Synchronization finished, receives the result |
| `onSyncError` | function | `null` | Synchronization failed |
| `dbName` | string | `smartauth_sync` | Local IndexedDB database name |

### Returned values

| Property | Type | Description |
| --- | --- | --- |
| `isOnline` | boolean | Browser is online |
| `isServerReachable` | boolean/null | Server reachable, via `GET /sync/status` |
| `checkNow` | function | Forces a connectivity check |
| `isInitialized` | boolean | Local storage and engine ready |
| `isRegistered` | boolean | Client registered with the server |
| `isSyncing` | boolean | Synchronization in progress |
| `lastSyncTime` | number | Timestamp of the last synchronization |
| `pendingCount` | number | Local changes waiting to be pushed |
| `conflictsCount` | number | Unresolved conflicts |
| `syncError` | Error | Last synchronization error |
| `register` | function | `register(deviceUuid)`, registers the client |
| `sync` | function | Push then pull |
| `push` | function | Push only |
| `pull` | function | Pull only |
| `create` | function | `create(table, data)`, returns a temporary id |
| `update` | function | `update(table, id, data)` |
| `remove` | function | `remove(table, id)` |
| `upsert` | function | `upsert(table, id, data, queueChange = false)` |
| `getEntity` | function | `getEntity(table, id)`, local read |
| `queryEntities` | function | `queryEntities(table, filter)`, filtered local read |
| `getConflicts` | function | Lists conflicts |
| `resolveConflict` | function | `resolveConflict(conflictId, resolution, data)` |
| `getStatus` | function | Full engine state |
| `reset` | function | Wipes all local synchronization data |

> `upsert` with `queueChange` set to `false` writes locally **without** pushing
> anything. It is the right method to cache a server response. With
> `queueChange` set to `true`, the change joins the push queue.

### Synchronization flow

Push, local to server:

```
1. The user modifies data locally (create / update / remove)
2. Changes are stored in IndexedDB (pending_changes)
3. sync() or push() sends them in batches of at most 50
4. The server confirms, or reports a conflict
5. Local temporary ids are replaced by server ids
```

Pull, server to local:

```
1. sync() or pull() requests changes since lastSyncTime
2. The server returns the modified entities, page by page
3. Local entities are updated
4. If an entity moved on both sides, a conflict is recorded
5. The lastSyncTime marker only advances once every page is processed
```

That last point is deliberate: an interruption during a pull leaves the marker
untouched, so the next pass starts over rather than silently skipping records.

### Conflict handling

A conflict is created when an entity has been modified locally **and** on the
server since the last synchronization. Detection relies on the Dolibarr object
`tms`, compared field by field.

Resolutions are `'client'`, `'server'`, or a merged object passed as a third
argument.

```javascript
const conflicts = await sync.getConflicts();

for (const conflict of conflicts) {
    // Keep the client version
    await sync.resolveConflict(conflict.conflict_id, 'client');

    // Keep the server version
    await sync.resolveConflict(conflict.conflict_id, 'server');

    // Merge field by field
    await sync.resolveConflict(conflict.conflict_id, 'client', {
        ...conflict.server_data,
        label: conflict.client_data.label
    });
}
```

The `ConflictResolver` component provides the matching interface:

```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([])}
        />
    );
};
```

It shows a side-by-side comparison, marks conflicting fields, offers to keep the
client version, the server version or to merge field by field, and navigates
between multiple conflicts.

| Prop | Type | Description |
| --- | --- | --- |
| `conflicts` | array | Conflicts (`conflict_id`, `table`, `object_id`, `client_data`, `server_data`, `field_conflicts`) |
| `onResolve` | function | `(conflictId, resolution, data)` |
| `onCancel` | function | Closes the resolver |
| `renderField` | function | Custom field rendering (optional) |
| `labels` | object | Interface labels (optional, French by default) |

### IndexedDB schema

`useSyncClient` manages **its own database**, separate from your module's one.

| Store | Description |
| --- | --- |
| `entities` | Synchronized data |
| `pending_changes` | Local changes waiting to be pushed |
| `pending_conflicts` | Unresolved conflicts |
| `sync_meta` | Metadata (`clientUuid`, `lastSyncTime`, `sync_scope`) |
| `local_tombstones` | Locally deleted entities |

Practical consequence: your screens read data through `getEntity` and
`queryEntities`, not directly from the module's Dexie stores. If you want to
keep control over your own stores, `useReferenceSync` is what you need.

### What useSyncClient does not do

- It does not synchronize business **actions**, only object states. Closing a
  work order, applying a status change governed by server rules or uploading a
  document cannot be modelled as a field update.
- It does not handle composite objects (documents with their lines). This is
  planned, not implemented.
- It does not embed attached files. See
  [Synchronization - reference catalog](/front/synchronisation-reference) for
  blobs.

## 2. useReferenceSync: reference catalog, pull only

When the application needs to **browse** a large reference dataset offline,
without ever modifying it, `useSyncClient` is oversized and its separate
database gets in the way.

`useReferenceSync` pulls data into **your own Dexie stores**, with the
associated images and PDF files downloaded as ZIP bundles, and your screens
query those stores directly.

See [Synchronization - reference catalog](/front/synchronisation-reference).

## 3. Business action queue

Some modules do not synchronize table rows but **business gestures**: closing a
work order, adding a consumed part, uploading an annotated and signed photo.
Semantics, validation rules and idempotency then live in the module's
controllers, not in a generic engine.

This is smartInterventions' choice. The principle:

- each gesture is written locally as a queue row, with a `client_uuid` generated
  client-side that acts as the idempotency key;
- the queue is drained when connectivity returns, by calling the module's
  business endpoints;
- the server recognizes a replay through the `client_uuid` and answers without
  duplicating;
- a failed row stays visible, with its error message, for a new attempt.

This is not a fallback for the lack of a generic engine: it is the only correct
model when the action to replay cannot be reduced to a field `UPDATE`.

See [Offline mode](/training/module10-fonctionnalites-avancees/offline) for the
full pattern.

## What the three have in common

### Connectivity detection

`useOnlineStatus` is used by all three, directly or indirectly. It combines the
browser state with an optional server health check, with a stability delay
before declaring that connectivity is back.

```javascript
const { isOnline, isServerReachable, checkNow } = useOnlineStatus({
    healthCheckUrl: '/api/smartauth/sync/status',
    healthCheckInterval: 60000,
    stabilityDelay: 2000
});
```

Without `healthCheckUrl`, only `navigator.onLine` is consulted, which detects
neither a captive portal nor a server that is down.

### Client registration

`useSyncClient` and `useReferenceSync` both call `POST /sync/register` and
handle a `client_uuid`. The difference lies in who creates it: `useSyncClient`
receives from the server the `client_uuid` derived from the `deviceUuid` you
pass, while `useReferenceSync` generates one and persists it itself in its
metadata store.

### Application updates

Activating a new Service Worker version **does not destroy** the queues: they
live in IndexedDB, not in the Service Worker cache. See [PWA](/front/pwa) for
`usePWAUpdate`, `UpdatePrompt` and the Provider's `pwaUpdate` prop.

### The role of Dm classes

Server-side, the very same `Dm<Entity>` mappers serve the REST facade and the
synchronization. In particular, the write allowlist of a synchronized object
**is** its mapper's `$writableFields`: a field missing from it is silently
rejected on push. See [Mapping Dolibarr - React](/back/mapping-dolibarr-react).

## See also

- [Synchronization - reference catalog](/front/synchronisation-reference) - `useReferenceSync` in detail
- [SmartCommon Hooks](/front/hooks) - all hooks
- [Data storage](/front/stockage-de-donnees) - `useDb`, `useCachedQuery`
- [PWA](/front/pwa) - Service Worker and updates
- [Mapping Dolibarr - React](/back/mapping-dolibarr-react) - the `Dm` classes
