Offline data management¶
Copy of
sumaris-app/.agents/rules/service-offline.md(rule written for AI coding agents, when implementing offline data support in services). Do not edit here: runnpm run site:sync-rulesto update it.
Services that need offline support extend RootDataSynchroService instead of BaseEntityService. This base class provides the synchronization lifecycle: local storage, synchronization to the server, and import from the server;
Base class¶
- Extend
RootDataSynchroService<T, F, ID>which itself extendsBaseEntityService; - Implements
IRootDataSynchroServiceinterface with methods:synchronize(),hasOfflineData(),terminateById(),runImport(); - Inject an
Injectorand pass it tosuper():constructor(injector: Injector, /* ... */) { super(injector, MyEntity, MyEntityFilter, { queries: Queries, mutations: Mutations, }); this._featureName = MY_FEATURE_NAME; }
Local storage via EntityStorage¶
- Use
this.entities(anEntityStorageinstance provided by the base class) for all local CRUD operations; - Save locally:
await this.entities.save(json, { entityName: MyEntity.TYPENAME }); - Delete locally:
await this.entities.deleteById(id, { entityName: MyEntity.TYPENAME }); - Load locally:
await this.entities.loadAll(MyEntity.TYPENAME, { offset, size, filter }); - Watch locally:
this.entities.watchAll<T>(MyEntity.TYPENAME, { offset, size, filter }); - Trash support:
await this.entities.saveToTrash(json, { entityName: MyEntity.TYPENAME })andawait this.entities.deleteFromTrash(entity, { entityName: MyEntity.TYPENAME });
Local IDs vs remote IDs¶
- Local entities use negative IDs (assigned by
fillOfflineDefaultProperties()); - Remote entities use positive IDs;
- Use
EntityUtils.isLocalId(id)andEntityUtils.isRemoteId(id)to distinguish; - Use
RootDataEntityUtils.isLocal(entity)to check if an entity is stored locally;
Serialization options¶
- For remote save: use
SAVE_AS_OBJECT_OPTIONS; - For local storage: use
MINIFY_DATA_ENTITY_FOR_LOCAL_STORAGE; - For copying to local: use
COPY_LOCALLY_AS_OBJECT_OPTIONS;
synchronizationStatus field¶
- Entities have a
synchronizationStatusfield with values:'DIRTY'(local, not synced),'SYNC'(synchronized),'READY_TO_SYNC'; - Set
entity.synchronizationStatus = 'DIRTY'when saving locally; - Set
entity.synchronizationStatus = 'SYNC'before sending to the server during synchronization;
Save pattern with offline support¶
-
The
save()method must detect local entities and delegate tosaveLocally():async save(entity: MyEntity, opts?: MySaveOptions): Promise<MyEntity> { // If is a local entity: force a local save if (RootDataEntityUtils.isLocal(entity)) { return this.saveLocally(entity, opts); } // ... remote save via GraphQL (same as standard save pattern) } -
The
saveLocally()method stores data inEntityStorage:async saveLocally(entity: MyEntity, opts?: MySaveOptions): Promise<MyEntity> { if (isNotNil(entity.id) && entity.id >= 0) throw new Error('Must be a local entity'); this.fillDefaultProperties(entity); this.resetQualityProperties(entity); // Assign local IDs if new await this.fillOfflineDefaultProperties(entity); // Mark as dirty entity.synchronizationStatus = 'DIRTY'; // Serialize for local storage const json = this.asObject(entity, MINIFY_DATA_ENTITY_FOR_LOCAL_STORAGE); // Save to local storage await this.entities.save(json, { entityName: MyEntity.TYPENAME }); // Save children locally if needed // ... return entity; }
Delete pattern with offline support¶
-
deleteLocally()deletes the entity and its children from local storage, optionally moving them to trash:async deleteLocally(entity: MyEntity, opts?: { trash?: boolean }): Promise<any> { const trash = !opts || opts?.trash !== false; // Load and delete children first // ... // Delete entity from local storage await this.entities.delete(entity, { entityName: MyEntity.TYPENAME }); // Move to trash if needed if (trash) { entity.updateDate = moment(); const json = entity.asObject({ ...MINIFY_DATA_ENTITY_FOR_LOCAL_STORAGE, keepLocalId: false }); await this.entities.saveToTrash(json, { entityName: MyEntity.TYPENAME }); } } -
deleteAllLocally()iterates over local entities and callsdeleteLocally()for each;
Synchronize pattern¶
synchronize()pushes a local entity to the remote server, then cleans up local storage:async synchronize(entity: MyEntity, opts?: MySaveOptions): Promise<MyEntity> { const localId = entity?.id; if (!EntityUtils.isLocalId(localId)) throw new Error('Entity must be a local entity'); if (this.network.offline) throw new Error('Could not synchronize if network is offline'); // Clone and prepare for remote save entity = entity.clone(); entity.synchronizationStatus = 'SYNC'; entity.id = undefined; // Load and synchronize children (e.g. local vessels, landings) // ... // Save remotely entity = await this.save(entity, { ...opts, emitEvent: false }); // Synchronize children with the new remote parent ID // ... // Delete local entity await this.entities.deleteById(localId, { entityName: MyEntity.TYPENAME }); return entity; }
WatchAll with offline support¶
watchAll()detects offline mode or local filter and delegates towatchAllLocally():watchAll(offset, size, sortBy, sortDirection, dataFilter, opts): Observable<LoadResult<T>> { const offlineData = this.network.offline || (dataFilter?.synchronizationStatus && dataFilter.synchronizationStatus !== 'SYNC') || false; if (offlineData) { return this.watchAllLocally(offset, size, sortBy, sortDirection, dataFilter, opts); } // ... remote watchAll via GraphQL }
Other offline methods¶
hasOfflineData(): checks if any local entities exist inEntityStorage;copyLocally(source): copies a remote entity to local storage usingCOPY_LOCALLY_AS_OBJECT_OPTIONS, then callssaveLocally();copyLocallyById(id): loads a remote entity then callscopyLocally();runImport(filter, opts): imports data from the server to local storage using jobs returned bygetImportJobs();getImportJobs(filter, opts): returns an array of import job definitions (override to customize);
Reference examples¶
- Offline-capable service (RootDataSynchroService):
src/app/trip/observedlocation/observed-location.service.ts;