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
Embed a reference dataset for offline browsing (products, categories, third parties, contacts) along with their images and PDF files useReferenceSync Synchronization - reference catalog
Create, modify and delete Dolibarr objects offline, with conflict detection and resolution useSyncClient below
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

When in doubt, start with useReferenceSync: most offline screens browse data without modifying it, and it writes into your module's Dexie stores, which your screens query directly. useSyncClient is only worth it when the application itself modifies Dolibarr objects 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 modify 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 of the sync/* routes, through useApi().private
ConflictResolver React component Conflict resolution interface

The hook talks to the sync/* routes exposed by the SmartAuth SyncController. Nothing has to be written server-side for the object types already registered (thirdparty, contact, product, category, order, invoice...).

Prerequisites

  • The hook goes through useApi: it must be mounted under <Provider>. The token, the X-DeviceId header and token refresh are handled by useApi, no component has to pass a URL or a token.
  • The device must be identified (<DeviceIdentificationComponent>): sync/register reads the device from the token and answers 400 otherwise.
  • scope lists SmartAuth object types, parents first: creations go out in that order, deletions in reverse order.

Getting started

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

Client registration is automatic: on the first sync(), push() or pull(), the hook generates a client_uuid, keeps it in its database and sends it to sync/register. If the server no longer knows that client (database reset), it registers again with the same identifier and replays the request. register() remains available to register before the first synchronization.

Options

Option Type Default Description
scope string[] - SmartAuth object types to synchronize, parents first
appVersion string null Version sent to sync/register, useful for diagnosis
healthCheckUrl string/null health route Reachability probe of useOnlineStatus, null for network state only
autoSync boolean true Synchronizes when 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 start
onSyncComplete function null Synchronization end, receives the result
onSyncError function null Synchronization failure
dbName string smartauth_sync Local IndexedDB database name

apiUrl and getAccessToken belonged to the former contract: they are ignored, with a warning logged once.

Returned values

Property Type Description
isOnline boolean Browser online
isServerReachable boolean/null Server reachable, through the public health route (null when the probe is disabled)
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 string Server time of the last complete synchronization
pendingCount number Local changes waiting to be pushed
conflictsCount number Unresolved conflicts
syncError Error Last synchronization error
register function register(), registers the client without waiting for the first synchronization
sync function Push then pull
push function Push only
pull function Pull only
create function create(type, data), returns a local_... temporary id
update function update(type, id, data)
remove function remove(type, id)
upsert function upsert(type, id, data, queueChange = false)
getEntity function getEntity(type, id), local read
queryEntities function queryEntities(type, filter), filtered local read
getConflicts function Lists conflicts
resolveConflict function resolveConflict(conflictId, resolution, data)
getStatus function Local engine state
getServerStatus function Server-side state of the client (GET sync/status)
reset function Erases all local synchronization data

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

Synchronization flow

Push, from local to server:

1. The user modifies data locally (create / update / remove)
2. Changes are stored in IndexedDB (pending_changes)
3. sync() or push() folds the changes of a same entity (a creation followed
   by updates goes out as a single creation)
4. One sync/push call per object type, at most 50 changes per call:
   parents created first, children deleted first
5. The server confirms, refuses or reports a conflict
6. Temporary ids are replaced by server ids, in the entity and in the
   foreign keys of the other changes

A child whose parent is not created yet stays queued (WAITING_FOR_PARENT). An object missing server-side ("Object not found") leaves the queue; other refusals stay queued for the next push.

Pull, from server to local:

1. sync() or pull() queries sync/pull type by type
2. The server returns the modified entities, page by page (next_cursor)
3. Local entities are updated, except those modified locally
4. Each type keeps its own watermark, advanced once all its pages are
   processed

That last point is deliberate: an interruption during a pull leaves the watermark untouched, so the next pass starts over rather than silently skipping records. A failing type does not stop the others. A 403 stops everything (ForbiddenSyncError), to avoid being blocked by the firewall.

Conflict handling

A conflict is created when an entity was modified locally and on the server since the client received it. Detection relies on the Dolibarr object's tms, compared field by field.

  • Update: SmartAuth records the conflict. getConflicts() returns its list (numeric id, client_data, server_data, field_conflicts as a list of fields) and resolveConflict() goes through sync/conflicts/{id}/resolve.
  • Deletion: SmartAuth does not record it. The conflict stays local (local:<type>:<id> id): 'client' queues the deletion again against the current server version, 'server' gives it up and fetches the object again on the next pull. Merging is refused.
  • Offline, getConflicts() returns local conflicts only.

Resolutions are 'client', 'server' and 'merged', the latter with the merged object as 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, 'merged', {
        ...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({ 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([])}
        />
    );
};

It shows a side-by-side comparison, highlights conflicting fields, offers to keep the client, the server or 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 (client_uuid, per-type watermarks, 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 and a server probe, with a stability delay before declaring the return online.

By default, the probe is an unauthenticated HEAD request on the public SmartAuth health route, under the API prefixUrl (for instance /mymodule/pwa/api.php/health). It answers 204 without touching the database. Mounted instances share a single request per URL.

// Default probe: <prefixUrl>/health
const { isOnline, isServerReachable, checkNow } = useOnlineStatus({
    healthCheckInterval: 60000,
    stabilityDelay: 2000
});

// Network state only, no request
const { isOnline: online } = useOnlineStatus({ healthCheckUrl: null });

Do not use sync/status as a probe: the route requires a token and a client_uuid, an unauthenticated HEAD on it always fails and the server looks unreachable. Without a probe, only navigator.onLine is checked, which detects neither a captive portal nor a server that is down.

Client registration: device_uuid and client_uuid

useSyncClient and useReferenceSync both call POST sync/register and handle two identifiers not to be confused:

Identifier Provided by Carried by Role
device_uuid SmartAuth, on device identification the JWT identifies the device, the server reads it from the token
client_uuid the client, generated once and stored locally the query (GET) or the body (POST) of every sync/* call identifies the synchronization client, its watermarks and conflicts

Neither has to be passed by hand: both hooks generate and store their client_uuid (useSyncClient in its database, useReferenceSync in your metadata store) and register on their own.

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