Aller au contenu

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: run npm run site:sync-rules to 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 extends BaseEntityService;
  • Implements IRootDataSynchroService interface with methods: synchronize(), hasOfflineData(), terminateById(), runImport();
  • Inject an Injector and pass it to super():
    constructor(injector: Injector, /* ... */) {
      super(injector, MyEntity, MyEntityFilter, {
        queries: Queries,
        mutations: Mutations,
      });
      this._featureName = MY_FEATURE_NAME;
    }
    

Local storage via EntityStorage

  • Use this.entities (an EntityStorage instance 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 }) and await 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) and EntityUtils.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 synchronizationStatus field 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 to saveLocally():

    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 in EntityStorage:

    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 calls deleteLocally() 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 to watchAllLocally():
    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 in EntityStorage;
  • copyLocally(source): copies a remote entity to local storage using COPY_LOCALLY_AS_OBJECT_OPTIONS, then calls saveLocally();
  • copyLocallyById(id): loads a remote entity then calls copyLocally();
  • runImport(filter, opts): imports data from the server to local storage using jobs returned by getImportJobs();
  • 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;