001package arez.persist.runtime;
002
003import arez.Disposable;
004import arez.SafeProcedure;
005import java.util.HashMap;
006import java.util.Map;
007import java.util.Objects;
008import javax.annotation.Nonnull;
009import javax.annotation.Nullable;
010import static org.realityforge.braincheck.Guards.*;
011
012/**
013 * The store that contains a cached copy of any state persisted.
014 * The store is also responsible for committing state to a storage service when the state has changed.
015 * The commit is typically an asynchronous process that occurs in another process so as not to block the
016 * ui thread.
017 */
018public final class Store
019{
020  /**
021   * In-memory cache of configuration data.
022   */
023  @Nonnull
024  private final Map<Scope, Map<String, Map<String, StorageService.Entry>>> _config = new HashMap<>();
025  /**
026   * Has the config data been committed to the backend storage?
027   */
028  private boolean _committed = true;
029  /**
030   * The service that stores the state.
031   */
032  @Nonnull
033  private final StorageService _storageService;
034  /**
035   * Cache for action that performs commit.
036   */
037  @Nonnull
038  private final SafeProcedure _commitTriggerAction = this::commit;
039  /**
040   * Flag indicating this store has been disposed.
041   */
042  private boolean _disposed;
043
044  /**
045   * Create the store.
046   *
047   * @param storageService the underlying storage service that manages the state.
048   */
049  Store( @Nonnull final StorageService storageService )
050  {
051    _storageService = Objects.requireNonNull( storageService );
052  }
053
054  /**
055   * Clear the state in store and reload it from the backend service.
056   */
057  void restore()
058  {
059    _config.clear();
060    _storageService.restore( _config );
061  }
062
063  /**
064   * Release all state stored under scope and any nested scope.
065   * If any state is actually removed, then a commit is scheduled.
066   *
067   * @param scope the scope.
068   */
069  void releaseScope( @Nonnull final Scope scope )
070  {
071    scope.getNestedScopes().forEach( this::releaseScope );
072    if ( null != _config.remove( scope ) )
073    {
074      scheduleCommit();
075    }
076  }
077
078  /**
079   * Save the state for a single component to the store.
080   * If the state value is empty this operation is effectively a remove. If any changes are made to
081   * the store as a result of this operation then a commit is scheduled.
082   *
083   * @param scope     the scope in which the state is saved.
084   * @param type      the string that identifies the type of component.
085   * @param id        a string representation of the component id.
086   * @param state     the state to store.
087   * @param converter the converter to use to encode state for storage.
088   */
089  public void save( @Nonnull final Scope scope,
090                    @Nonnull final String type,
091                    @Nonnull final String id,
092                    @Nonnull final Map<String, Object> state,
093                    @Nonnull final TypeConverter converter )
094  {
095    if ( ArezPersist.shouldCheckApiInvariants() )
096    {
097      apiInvariant( () -> !isDisposed(), () -> "Store.save() invoked after the store has been disposed" );
098      apiInvariant( () -> Disposable.isNotDisposed( scope ),
099                    () -> "Store.save() passed a disposed scope named '" + scope.getName() + "'" );
100    }
101
102    if ( state.isEmpty() )
103    {
104      remove( scope, type, id );
105    }
106    else
107    {
108      // Initial experiments converted state in a separate idle callback but the overhead of
109      // asynchronous queuing and callback when the encoded types are primitive and not rich types
110      // requiring converters did not seem worth it
111      _config
112        .computeIfAbsent( scope, t -> new HashMap<>() )
113        .computeIfAbsent( type, t -> new HashMap<>() )
114        .put( id, new StorageService.Entry( state, _storageService.encodeState( state, converter ) ) );
115      scheduleCommit();
116    }
117  }
118
119  /**
120   * Remove the state for a single component from the store.
121   * If the state does not exist then this is a noop, otherwise a commit is scheduled.
122   *
123   * @param scope the scope in which the state is saved.
124   * @param type  the string that identifies the type of component.
125   * @param id    a string representation of the component id.
126   */
127  public void remove( @Nonnull final Scope scope, @Nonnull final String type, @Nonnull final String id )
128  {
129    if ( ArezPersist.shouldCheckApiInvariants() )
130    {
131      apiInvariant( () -> !isDisposed(), () -> "Store.remove() invoked after the store has been disposed" );
132      apiInvariant( () -> Disposable.isNotDisposed( scope ),
133                    () -> "Store.remove() passed a disposed scope named '" + scope.getName() + "'" );
134    }
135    final Map<String, Map<String, StorageService.Entry>> scopeMap = _config.get( scope );
136    final Map<String, StorageService.Entry> typeMap = null != scopeMap ? scopeMap.get( type ) : null;
137    if ( null != typeMap && null != typeMap.remove( id ) )
138    {
139      scheduleCommit();
140    }
141  }
142
143  /**
144   * Retrieve the state for a single component from the store.
145   * If the state does not exist then a null is returned.
146   *
147   * @param scope     the scope in which the state is saved.
148   * @param type      the string that identifies the type of component.
149   * @param id        a string representation of the component id.
150   * @param converter the converter to use to decode state from storage.
151   * @return the component state if it exists, else null.
152   */
153  @Nullable
154  public Map<String, Object> get( @Nonnull final Scope scope,
155                                  @Nonnull final String type,
156                                  @Nonnull final String id,
157                                  @Nonnull final TypeConverter converter )
158  {
159    if ( ArezPersist.shouldCheckApiInvariants() )
160    {
161      apiInvariant( () -> !isDisposed(), () -> "Store.get() invoked after the store has been disposed" );
162      apiInvariant( () -> Disposable.isNotDisposed( scope ),
163                    () -> "Store.get() passed a disposed scope named '" + scope.getName() + "'" );
164    }
165    final Map<String, Map<String, StorageService.Entry>> scopeMap = _config.get( scope );
166    final Map<String, StorageService.Entry> typeMap = null != scopeMap ? scopeMap.get( type ) : null;
167    if ( null != typeMap )
168    {
169      final StorageService.Entry entry = typeMap.get( id );
170      if ( null == entry )
171      {
172        return null;
173      }
174      else
175      {
176        final Map<String, Object> data = entry.getData();
177        if ( null == data )
178        {
179          final Map<String, Object> decoded = _storageService.decodeState( entry.getEncoded(), converter );
180          entry.setData( decoded );
181          return decoded;
182        }
183        else
184        {
185          return data;
186        }
187      }
188    }
189    else
190    {
191      return null;
192    }
193  }
194
195  /**
196   * Return true if this service has been disposed.
197   * A disposed service should not be interacted with.
198   *
199   * @return true if this service has been disposed, false otherwise.
200   * @see #dispose()
201   */
202  public boolean isDisposed()
203  {
204    return _disposed;
205  }
206
207  void dispose()
208  {
209    assert !_disposed;
210    _disposed = true;
211    _storageService.dispose();
212  }
213
214  /**
215   * Schedule a commit if one is not already pending.
216   */
217  private void scheduleCommit()
218  {
219    if ( _committed )
220    {
221      _committed = false;
222      _storageService.scheduleCommit( _commitTriggerAction );
223    }
224  }
225
226  /**
227   * Commit state to storage service.
228   */
229  private void commit()
230  {
231    if ( !_committed )
232    {
233      _storageService.commit( _config );
234      _committed = true;
235    }
236  }
237
238  @Nonnull
239  Map<Scope, Map<String, Map<String, StorageService.Entry>>> getConfig()
240  {
241    return _config;
242  }
243}