Synchronization - reference catalog
useReferenceSync embeds offline a reference dataset that the application
browses without modifying: products, categories, third parties, contacts,
along with their images and PDF documents.
It complements useSyncClient, which is transactional
and works in its own database. Both often coexist in the same application: the
catalog comes down through useReferenceSync, business writes go up through
useSyncClient or through a business action queue.
How it differs from useSyncClient
useSyncClient |
useReferenceSync |
|
|---|---|---|
| Direction | Two-way | Download only |
| Storage | Dedicated smartauth_sync database |
Your Dexie stores |
| Read by the application | getEntity, queryEntities |
Direct Dexie queries on your stores |
| Conflicts | Detected and resolved | Not applicable, nothing goes up |
| Attached files | Not handled | Images and PDF, downloaded as ZIP bundles |
| Client registration | Explicit register(deviceUuid) |
Automatic and idempotent on every pass |
The decisive advantage of the latter is that your screens keep querying your own tables, with your indexes and your queries. Nothing changes in the read code when a list goes offline.
Getting started
import { useReferenceSync } from '@cap-rel/smartcommon';
import { useMyModuleDb } from 'src/db';
import { APP_VERSION } from 'src/utils/constants/vite';
const ENTITIES = [
{ objectType: 'product', store: 'products' },
{ objectType: 'category', store: 'categories', cleanOrphans: true }
];
const DOCUMENTS = [
{
objectType: 'product',
store: 'productDocuments',
fk: 'product_id',
doctypes: ['image', 'pdf']
}
];
export const useCatalogSync = () => {
const db = useMyModuleDb();
return useReferenceSync({
db,
appVersion: APP_VERSION,
entities: ENTITIES,
documents: DOCUMENTS,
metaStore: 'syncMeta'
});
};
The metadata store must exist in your Dexie schema, as a plain key / value store:
const db = useDb({
name: 'myModule',
version: 1,
stores: {
products: 'id, ref, label',
categories: 'id, label',
productDocuments: '++local_id, product_id, server_id, type',
syncMeta: 'key'
}
});
Options
| Option | Type | Default | Description |
|---|---|---|---|
db |
object | - | The module's Dexie instance |
appVersion |
string | '1.0.0' |
Version sent to sync/register, useful for server-side diagnosis |
entities |
array | [] |
Object types to pull, see below |
documents |
array | [] |
Documents to download, see below |
dataFeeds |
array | [] |
Dictionaries and configuration blocks, see below |
metaStore |
string | 'syncMeta' |
Key / value store for clientUuid and the delta markers |
getSyncPreferences |
function | null |
async () => prefs, passed to the enabled and doctypes resolvers |
onProgress |
function | null |
Mirror of syncProgress, for an external progress bar |
entities
{ objectType: 'product', store: 'products', mapper: mapProduct, cleanOrphans: false }
| Key | Description |
|---|---|
objectType |
Object type on the SmartAuth side, as declared in the registry |
store |
Target Dexie store name |
mapper |
Optional, (raw) => mapped, renames or filters fields before writing |
cleanOrphans |
Optional, false by default. When true, forces a full pull and deletes locally anything the server did not return |
Each type is pulled in pages of 500 through GET sync/pull, with its own delta
marker stored under the lastSyncAt_<objectType> key. That marker is written
only once every page has gone through: an interruption mid-way makes the
whole pass start over rather than leaving a gap.
cleanOrphanscosts a full pull on every synchronization. Only enable it on small reference datasets, and only if the server does not already publish its exclusions in the delete list.
documents
{
objectType: 'product',
store: 'productDocuments',
fk: 'product_id',
doctypes: (prefs) => [prefs.syncImages && 'image', prefs.syncPdfs && 'pdf'].filter(Boolean),
enabled: (prefs) => prefs.syncProductDocuments
}
| Key | Description |
|---|---|
objectType |
The native type expected by SmartAuth's document controller (product, category, thirdparty, project, intervention) |
store |
Dexie store receiving the blobs |
fk |
Name of the column holding the object id |
doctypes |
Array, or function of the preferences, among image, thumb, pdf |
enabled |
Boolean, or function of the preferences. Lets the user opt out of the download |
Beware of one trap: a module may register its own syncable types
server-side, for instance capfullpos_product to filter the sellable catalog.
Those types apply to entities, but the document controller only knows the
native types. The two lists do not copy each other.
Blobs are fetched as ZIP bundles rather than one by one, falling back to individual downloads for oversized files. This is what avoids flooding the server with one request per thumbnail when a session opens.
Row written locally:
{
local_id, // auto-incremented
[fk]: object_id, // product_id, category_id...
server_id, type, filename, relative_path, mime_type,
blob, size, synced_at, server_updated_at
}
The thumb doctype, when the server allows it, returns only the thumbnail
instead of the full-resolution original. On a grid of tiles, the difference in
embedded weight is considerable.
dataFeeds
For dictionaries and configuration blocks, which do not go through the sync engine but through a plain GET:
{ key: 'paymentModes', endpoint: 'syncdata/payment-modes', store: 'paymentModes', clearBefore: true }
| Key | Description |
|---|---|
key |
Feed identifier, also used as the row key for a single object |
endpoint |
Path called on the private API |
store |
Target Dexie store |
mapper |
Optional, applied to each item |
extract |
Optional, (res) => payload. Defaults to res.data |
clearBefore |
Optional, clears the store before inserting, for a full replacement |
Returned values
| Property | Type | Description |
|---|---|---|
isSyncing |
boolean | Pass in progress |
syncProgress |
object/null | { step, current, total }, step being the name of the store being processed |
lastSyncAt |
Date/null | Date of the last complete pass |
error |
Error/null | Last error |
isInitialized |
boolean | Database provided and ready |
hasApi |
boolean | API context available |
syncNow |
function | Starts a pass |
resetSync |
function | Clears every configured store then runs a full pass |
How a pass runs
1. Client registration (idempotent): POST sync/register
2. Entities, in declaration order: paginated GET sync/pull
3. Documents: metadata, local comparison, ZIP bundle download
4. Data feeds: one GET per feed
5. Writing the lastSyncAt marker
An error on one step is recorded in the result and does not interrupt the following ones. Two exceptions stop everything immediately:
- a cancellation, triggered by
resetSyncduring a pass; - a 403, which raises a
ForbiddenSyncError. The stop is immediate and deliberate: insisting on a series of rejected requests gets the application blacklisted by application firewalls.
A pass also refuses to start while offline, and refuses to overlap a pass already in progress.
Showing progress
const CatalogSyncOverlay = () => {
const { isSyncing, syncProgress, lastSyncAt, error, syncNow } = useCatalogSync();
if (!isSyncing) {
return <button onClick={syncNow}>Update the catalog</button>;
}
return (
<div>
<p>{syncProgress?.step ?? 'Preparing'}</p>
{syncProgress?.total > 0 && (
<progress value={syncProgress.current} max={syncProgress.total} />
)}
</div>
);
};
syncProgress goes back to null at the end of a pass. On entities, current
and total both hold the number of items written so far: progress is a rising
volume, not a percentage, because the total is unknown until the last page.
Trap: reading during a first synchronization
The lastSyncAt_<objectType> markers are only written after a complete success.
A screen that checks for the marker to decide whether it may read locally will
therefore show an empty list for the whole first pass, even though rows are
landing as they come. Test the content of the store, not the marker.
See also
- Offline synchronization - choosing a mechanism
- Data storage -
useDband Dexie stores - API requests -
useApi - Mapping Dolibarr - React - declaring a syncable object