Chapter 1: Offline mode
This chapter covers one precise case: replaying a business action later, one the user triggered without connectivity. Closing a work order, adding a consumed part, uploading an annotated and signed photo.
This is not SmartMaker's only offline mechanism, and often it is not the right one. Read Offline synchronization before committing.
Choose first
| What you want to do | What to use |
|---|---|
| Create and modify Dolibarr objects offline, with conflict handling | useSyncClient, nothing to write |
| Embed a reference dataset for offline browsing, with its images | useReferenceSync, nothing to write |
| Replay a business action governed by server rules | This chapter |
The criterion is simple. If the action boils down to "these fields now hold
these values", useSyncClient already handles it, and better than hand-written
code. If the action is a gesture the server must validate, sequence or reject,
no generic engine can carry it: you need a queue inside the module.
Do not write a home-made queue that merely replays field
POSTandPUTcalls. That is exactly whatuseSyncClientdoes, plus tombstones, conflict detection and recovery after an interruption.
The principle
1. The user triggers the action, with or without network
2. The action is written locally as a queue row
3. The interface shows the row as "waiting for synchronization"
4. When the network returns, the queue is drained against business endpoints
5. The server recognizes any replay and duplicates nothing
6. A failed row stays visible, with its message, for a new attempt
Everything hinges on point 5. Without it, an interruption at the wrong moment creates duplicates and the pattern falls apart.
The idempotency contract
This is the part to design first, before any line of React code.
Creation: an identifier minted by the client
The client generates a client_uuid when it enqueues the action. That
identifier serves both as the local primary key and as the server-side
idempotency key.
// src/lib/taskSync.js
import { db } from 'src/db';
import { generateUuidV4 } from './clientUuid';
export const enqueueTask = async (payload, projectId) => {
const clientUuid = payload.client_uuid || generateUuidV4();
const row = {
id: clientUuid,
payload: { ...payload, client_uuid: clientUuid, fk_project: projectId },
projectId,
op: 'create',
status: 'pending',
attempts: 0,
lastAttemptAt: null,
lastError: null,
serverId: null,
updatedAt: Date.now()
};
await db.taskQueue.put(row);
notifyChange();
return clientUuid;
};
The put on the identifier makes enqueuing itself idempotent: re-enqueuing the
same action overwrites the row instead of creating a second one.
Server-side, the controller first looks up the client_uuid:
$existing = \MyModuleTask::fetchByClientUuid($db, $entity, $clientUuid);
if ($existing !== null) {
// Replay: return the existing object, create nothing
return [$mapper->exportMappedData($existing), 200];
}
The convention is to answer 201 for an actual creation and 200 for a recognized replay. The client treats both as a success.
Plan for the column from the start, with a unique index:
-- sql/llx_mymodule_task.key.sql
CREATE UNIQUE INDEX uk_mymodule_task_client_uuid
ON llx_mymodule_task (entity, client_uuid);
A header to flag the replay
A POST coming from a deferred queue does not deserve the same trust as a
POST coming from an online screen. The price of a part computed three hours
ago on an offline device, for instance, must not be authoritative.
The SmartMaker convention is an X-Offline-Sync: 1 header, which the controller
uses to recompute server-side whatever must be recomputed and to ignore the
sensitive values in the request body.
await api.private.post('task', {
json: item.payload,
headers: { 'X-Offline-Sync': '1' }
});
Updates: beware of non-replayable transitions
A PUT /task/{id} writing fields replays harmlessly. A state transition
does not: closing an already closed record returns an error, typically a 409.
That 409 is not a failure. The target state has been reached. Treat it as a terminal success, otherwise the row stays stuck in the queue and the object keeps showing as "pending" forever.
} catch (err) {
const status = err?.response?.status ?? null;
// 409 on a close: the record is already closed, the goal is reached
if (status === 409 && item.op === 'close') {
await db.taskQueue.delete(item.id);
ok += 1;
continue;
}
console.error('taskSync.syncQueue: sync failed', { id: item.id, status, err });
// ... error tagging
}
The queue schema
import { useDb } from '@cap-rel/smartcommon';
const db = useDb({
name: 'myApp',
version: 1,
stores: {
// Business data read by the screens
tasks: 'id, ref, label, status, updatedAt',
// Queue, indexed on the client_uuid
taskQueue: 'id, status, projectId, updatedAt'
}
});
| Field | Role |
|---|---|
id |
The client_uuid, idempotency key |
payload |
The body ready to be sent, free of transient UI fields |
op |
create, update, close... routes to the right endpoint |
status |
pending, syncing, synced, error |
attempts |
Automatic attempt counter |
lastAttemptAt |
Timestamp, used to detect zombie rows |
lastError |
Error message, shown to the user |
serverId |
Server identifier, filled in after the first success |
Strip the payload of display-only fields at enqueue time, not at send time:
the stored row must be ready to go as is.
Draining the queue
export const MAX_AUTO_ATTEMPTS = 5;
export const syncQueue = async ({ api, includeMaxedOut = false } = {}) => {
if (!api || typeof api.post !== 'function') {
console.error('taskSync.syncQueue: missing api.post');
return { ok: 0, ko: 0, skipped: 0 };
}
// Rows stuck in "syncing" come from an app killed mid-flight: since the
// replay is idempotent, we put them back to "pending".
await reviveStuckSyncing();
const all = await db.taskQueue.toArray();
const pending = all.filter(r => r.status === 'pending' || r.status === 'error');
let ok = 0, ko = 0, skipped = 0;
for (const item of pending) {
if (!includeMaxedOut && item.attempts >= MAX_AUTO_ATTEMPTS) {
skipped += 1;
continue;
}
await db.taskQueue.update(item.id, {
status: 'syncing',
lastAttemptAt: Date.now(),
updatedAt: Date.now()
});
try {
const isUpdate = item.op === 'update' && Number(item.serverId) > 0;
const res = isUpdate
? await api.private.put(`task/${item.serverId}`, {
json: item.payload,
headers: { 'X-Offline-Sync': '1' }
})
: await api.private.post('task', {
json: item.payload,
headers: { 'X-Offline-Sync': '1' }
});
await db.taskQueue.delete(item.id);
await db.tasks.put({ ...item.payload, id: res.id, synced: true });
ok += 1;
} catch (err) {
const message = err?.apiMessage || err?.message || String(err);
console.error('taskSync.syncQueue: sync failed', { id: item.id, message });
await db.taskQueue.update(item.id, {
status: 'error',
attempts: (item.attempts ?? 0) + 1,
lastError: message,
updatedAt: Date.now()
});
ko += 1;
}
}
notifyChange();
return { ok, ko, skipped };
};
Three decisions deserve an explanation.
Tagging as syncing before the call. It prevents a concurrent trigger from
picking up the same row. In exchange, an application killed during the request
leaves a row stuck in that state: this is what reviveStuckSyncing is for, as
it puts back to pending any syncing row whose lastAttemptAt is more than a
few minutes old. Since the replay is idempotent, that recovery is safe.
The attempt cap. Beyond MAX_AUTO_ATTEMPTS, automatic passes leave the row
alone. Without a cap, a permanently invalid row, rejected by a business rule,
would be resent on every network comeback until the end of time. The user keeps
control through a manual retry button, which resets the counter.
The error is logged before being stored. A queue that fails silently is impossible to diagnose in the field.
Notifying the interface
The queue is modified from several places, including in the background. Screens showing a counter or a "pending" badge must refresh without polling.
export const TASK_QUEUE_EVENT = 'task-queue-changed';
export const notifyChange = () => {
if (typeof window === 'undefined') return;
try {
window.dispatchEvent(new Event(TASK_QUEUE_EVENT));
} catch (err) {
console.error('taskSync.notifyChange: dispatch failed', err);
}
};
On the component side:
import { useEffect } from 'react';
import { useStates } from '@cap-rel/smartcommon';
import { db } from 'src/db';
import { TASK_QUEUE_EVENT } from 'src/lib/taskSync';
export const PendingBadge = () => {
const st = useStates({ initialStates: { count: 0 } });
useEffect(() => {
const refresh = async () => {
const rows = await db.taskQueue.toArray();
st.set('count', rows.filter(r => r.status !== 'synced').length);
};
refresh();
window.addEventListener(TASK_QUEUE_EVENT, refresh);
return () => window.removeEventListener(TASK_QUEUE_EVENT, refresh);
}, []);
if (st.get('count') === 0) return null;
return <Tag color="orange">{st.get('count')} pending</Tag>;
};
Triggering the drain
Three triggers, which can be combined:
import { useEffect } from 'react';
import { useApi, useOnlineStatus } from '@cap-rel/smartcommon';
import { syncQueue } from 'src/lib/taskSync';
export const useTaskQueueDrain = () => {
const api = useApi();
const { isOnline, isServerReachable } = useOnlineStatus({
healthCheckUrl: '/api/health'
});
// 1. When the network returns
useEffect(() => {
if (isOnline && isServerReachable) {
syncQueue({ api }).catch(err =>
console.error('useTaskQueueDrain: drain failed', err)
);
}
}, [isOnline, isServerReachable]);
// 2. On the user's refresh gesture, and 3. on a manual retry button,
// passing includeMaxedOut as true
return {
drain: () => syncQueue({ api }),
retryAll: () => syncQueue({ api, includeMaxedOut: true })
};
};
Prefer useOnlineStatus over the browser's online event. The latter says
nothing about whether the server is actually reachable: a hotel captive portal
fires it even though no request will go through.
Dependent queues: the case of files
A business gesture often carries a photo or a signature whose upload is itself deferred. The action can then only leave once its files are up and their identifiers known.
The solution is to keep the list of pending uploads in the row:
const row = {
id: clientUuid,
payload: body,
// Fields whose upload is deferred: they carry a local identifier
// but no server identifier yet
pendingUploads: ['signature', 'photo_before'],
status: 'pending'
};
The drain skips rows whose pendingUploads is not empty. On every successful
file upload, the matching payload field is filled in and the entry removed
from the list. When it empties, the row becomes eligible and can leave right
away.
Classic traps
Overwriting local changes on refresh. A db.tasks.clear() followed by a
bulkAdd of server data destroys everything the user entered offline and that
has not left yet. Merge instead of replacing, or only refresh objects that are
absent from the queue.
Locking, or not, the pending object. An object whose action is queued can be edited again by the user, which produces two concurrent actions on the same object. The simplest approach is to make it read-only while its row is queued, and to say so clearly in the interface.
Trusting navigator.onLine. It only reflects the state of the network
interface, not whether the server is reachable.
Forgetting that the queue survives an application update. It lives in
IndexedDB, not in the Service Worker cache. That is good news, but it means a
change in the payload format must plan a migration for the rows already
waiting on devices.
Key takeaways
- Choose the right mechanism before writing a queue: in most cases,
useSyncClientalready answers the need - Idempotency first: a
client_uuidminted by the client, a unique index server-side, a replay answering 200 without duplicating - Flag the replay to the server with
X-Offline-Sync, so it recomputes whatever must not come from the client - Handle non-replayable transitions: a 409 on an already performed close is a success
- Cap the attempts and offer a manual retry
- Log every failure before storing it
- Notify the interface through an event, without polling
<- Back to the module | Next chapter: Internationalization ->