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 |
upsertwithqueueChangeset tofalsewrites locally without pushing anything. It is the right method to cache a server response. WithqueueChangeset totrue, 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_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 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
- Synchronization - reference catalog -
useReferenceSyncin detail - SmartCommon Hooks - all hooks
- Data storage -
useDb,useCachedQuery - PWA - Service Worker and updates
- Mapping Dolibarr - React - the
Dmclasses