---
source_hash: "a1307fc4"
title: "Chapter 1: Offline mode"
weight: 710
---

# 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](/front/synchronisation) before committing.

## Choose first

| What you want to do | What to use |
| --- | --- |
| Create and modify Dolibarr objects offline, with conflict handling | [`useSyncClient`](/training/module7-smartcommon-hooks/sync), nothing to write |
| Embed a reference dataset for offline browsing, with its images | [`useReferenceSync`](/front/synchronisation-reference), 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 `POST` and `PUT`
> calls. That is exactly what `useSyncClient` does, 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.

```javascript
// 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`:

```php
$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
-- 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.

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

```javascript
} 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

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

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

```javascript
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:

```javascript
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:

```javascript
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:

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

1. **Choose the right mechanism** before writing a queue: in most cases,
   `useSyncClient` already answers the need
2. **Idempotency first**: a `client_uuid` minted by the client, a unique index
   server-side, a replay answering 200 without duplicating
3. **Flag the replay** to the server with `X-Offline-Sync`, so it recomputes
   whatever must not come from the client
4. **Handle non-replayable transitions**: a 409 on an already performed close is
   a success
5. **Cap the attempts** and offer a manual retry
6. **Log every failure** before storing it
7. **Notify the interface through an event**, without polling

[<- Back to the module](/training/module10-fonctionnalites-avancees) | [Next chapter: Internationalization ->](/training/module10-fonctionnalites-avancees/i18n)
