import { action , IReactionDisposer , IReactionOptions , makeObservable , observable , reaction , toJS , when } from "mobx" ; import produce , { Draft } from "immer" ; import { isEqual , merge } from "lodash" ; import { createLogger } from "./createLogger" ; export interface StorageHelperOptions < T > { defaultValue ? : T ; autoLoad ? : boolean ; autoSync ? : boolean ; autoSyncDelay ? : number ; migrations ? : StorageMigrationCallback < T > [ ] ; storage : StorageAdapter < T > ; } export type StorageMigrationCallback < T > = ( data : T | any ) => T | void ; export interface StorageAdapter < T > { getItem ( key ) : T | Promise < T > ; setItem ( key , value ) : void ; removeItem ( key ) : void ; } export class StorageHelper < T > { protected logger = createLogger ( { systemPrefix : `[StorageHelper](${ this . key })` } ) ; protected storage : StorageAdapter < T > = this . options . storage ; protected data = observable . box < T > ( ) ; @ observable initialized = false ; @ observable saving = false ; @ observable loading = false ; @ observable loaded = false ; protected disposers = { unbindAutoSync : null as IReactionDisposer , } ; get whenReady ( ) { return when ( ( ) => this . initialized && this . loaded ) ; } ; get defaultValue ( ) : T { return this . options . defaultValue ; } constructor ( readonly key , private options ) { makeObservable ( this ) ; this . options = { autoLoad : true , autoSync : true , ... options } ; this . data . set ( this . defaultValue ) ; if ( this . options . autoLoad ) { this . load ( ) ; } if ( this . options . autoSync ) { this . bindAutoSync ( ) ; } } public bindAutoSync ( ) { const { autoSyncDelay } = this . options ; const bindAutoSync = ( ) => { this . disposers . unbindAutoSync ?. ( ) ; this . disposers . unbindAutoSync = reaction ( ( ) => this . toJS ( ) , state => this . save ( state ) , autoSyncDelay ? { delay : autoSyncDelay } : { } ) ; } ; if ( autoSyncDelay > 0 ) { this . whenReady . then ( bindAutoSync ) ; } else { bindAutoSync ( ) ; } } @ action load ( { force = false } = { } ) { if ( this . initialized && ! force ) return ; this . initialized = true ; try { this . loading = true ; const data = this . storage . getItem ( this . key ) ; if ( data instanceof Promise ) data . then ( this . onData , this . onError ) ; else this . onData ( data ) ; } catch ( error ) { this . logger . error ( "loading failed" , error ) ; this . onError ( error ) ; } finally { this . loading = false ; } } @ action protected save ( data ) { try { this . logger . info ( "saving data to external storage" , data ) ; this . saving = true ; this . storage . setItem ( this . key , data ) ; } catch ( error ) { this . logger . error ( "saving data has failed" , error ) ; } finally { this . saving = false ; } } @ action protected onData = ( data ) => { this . logger . info ( "data received" , data ) ; const notEmpty = data != null ; if ( notEmpty ) { for ( let callback of this . options . migrations ?? [ ] ) { let migratedData = callback ( data ) ; if ( migratedData !== undefined ) data = migratedData as T ; } if ( ! this . isDefaultValue ( data ) ) { this . set ( data ) ; } } this . loaded = true ; this . loading = false ; } ; @ action protected onError = ( error ? ) => { this . loading = false ; this . logger . error ( "loading failed" , error , this ) ; } ; isDefaultValue ( value ) { return isEqual ( this . defaultValue , value ) ; } get ( ) { return this . data . get ( ) ; } set ( value , { silent = false } = { } ) { if ( silent && this . options . autoSync ) { this . disposers ?. unbindAutoSync ( ) ; this . data . set ( value ) ; this . bindAutoSync ( ) ; } else { this . data . set ( value ) ; } } reset ( ) { this . set ( this . defaultValue ) ; } merge ( state ) { let value = this . toJS ( ) ; let nextValue ; if ( typeof state === "function" ) { nextValue = produce ( value , state ) ; } else { nextValue = produce ( value , ( draft ) => { if ( typeof state === "object" ) return merge ( draft , state ) ; return state ; } ) ; } this . set ( nextValue ) ; } toJS ( ) { return toJS ( this . get ( ) ) ; } }