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
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

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.

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 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.

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:

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 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.

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 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.

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 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.

See also