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, theX-DeviceIdheader and token refresh are handled byuseApi, no component has to pass a URL or a token. - The device must be identified (
<DeviceIdentificationComponent>):sync/registerreads the device from the token and answers 400 otherwise. scopelists 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 |
upsertwithqueueChangeset tofalsewrites locally without pushing anything. This is the right method to cache a server response. WithqueueChangeset totrue, 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_conflictsas a list of fields) andresolveConflict()goes throughsync/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_uuidgenerated 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_uuidand 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
- Synchronization - reference catalog -
useReferenceSyncin detail - SmartCommon Hooks - all hooks
- Data storage -
useDb,useCachedQuery - PWA - Service Worker and updates
- Mapping Dolibarr - React - the
Dmclasses