Skip to content

Commit

Permalink
Implemented async storage sync metareducer
Browse files Browse the repository at this point in the history
  • Loading branch information
imolorhe committed Mar 21, 2021
1 parent e540e45 commit cf10a98
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 23 deletions.
4 changes: 3 additions & 1 deletion packages/altair-app/package.json
Expand Up @@ -65,14 +65,15 @@
"crypto-js": "3.3.0",
"curlup": "^1.0.0",
"deepmerge": "^4.2.2",
"dexie": "^2.0.4",
"dexie": "^3.0.3",
"emotion": "^10.0.27",
"express": "^4.17.1",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.2",
"fuse.js": "^6.4.6",
"graphql": "^14.4.2",
"graphql-query-compress": "^1.2.2",
"lodash-es": "^4.17.21",
"loglevel": "^1.7.0",
"loglevel-plugin-prefix": "^0.8.4",
"marked": "^0.8.0",
Expand Down Expand Up @@ -118,6 +119,7 @@
"@types/jasmine": "~3.6.0",
"@types/jest": "26.0.0",
"@types/json-schema": "^7.0.6",
"@types/lodash-es": "^4.17.4",
"@types/memoizee": "^0.4.3",
"@types/mousetrap": "^1.6.3",
"@types/node": "^12.11.1",
Expand Down
33 changes: 28 additions & 5 deletions packages/altair-app/src/app/app.module.ts
@@ -1,12 +1,12 @@
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgModule, CUSTOM_ELEMENTS_SCHEMA, ErrorHandler } from '@angular/core';
import { NgModule, CUSTOM_ELEMENTS_SCHEMA, ErrorHandler, APP_INITIALIZER, ApplicationInitStatus } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HttpClient, HTTP_INTERCEPTORS } from '@angular/common/http';

import { ToastrModule } from 'ngx-toastr';

import { StoreModule } from '@ngrx/store';
import { Store, StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';

Expand All @@ -17,7 +17,7 @@ import { SortablejsModule } from 'ngx-sortablejs';
import { CookieService } from 'ngx-cookie-service';
import { SharedModule } from './modules/shared/shared.module';

import { metaReducers, reducerToken } from './store';
import { metaReducers, reducerToken, State } from './store';

import { QueryEffects } from './effects/query.effect';
import { WindowsEffects } from './effects/windows.effect';
Expand All @@ -42,6 +42,8 @@ import en from '@angular/common/locales/en';
import { OverlayContainer } from '@angular/cdk/overlay';
import { AppOverlayContainer } from './overlay-container';
import { environment } from 'environments/environment';
import { AppInitAction } from './store/action';
import { ReducerBootstrapper } from './store/reducer-bootstrapper';

registerLocaleData(en);

Expand All @@ -57,6 +59,11 @@ export function mapValuesToArray(obj: any): Array<any> {
});
};

export function reducerBootstrapFactory(reducer: ReducerBootstrapper) {
// bootstrap() returns a Promise
return () => reducer.bootstrap();
}

const servicesArray: Array<any> = mapValuesToArray(services);

const providers = [
Expand All @@ -80,6 +87,7 @@ const providers = [
// Setting the reducer provider in main.ts now (for proper config initialization)
// reducerProvider,
CookieService,
ReducerBootstrapper,
{
provide: HTTP_INTERCEPTORS,
useClass: HTTPErrorInterceptor,
Expand All @@ -93,7 +101,13 @@ const providers = [
provide: OverlayContainer,
useClass: AppOverlayContainer,
// useFactory: () => new AppOverlayContainer()
}
},
{
provide: APP_INITIALIZER,
deps: [ReducerBootstrapper],
multi: true,
useFactory: reducerBootstrapFactory
},
];

@NgModule({
Expand All @@ -119,6 +133,7 @@ const providers = [
strictStateImmutability: false,
strictActionImmutability: false,
},
// initialState: {},
}),
EffectsModule.forRoot([ QueryEffects, WindowsEffects, QueryCollectionEffects, PluginEventEffects ]),
StoreDevtoolsModule.instrument({
Expand Down Expand Up @@ -147,4 +162,12 @@ const providers = [
CUSTOM_ELEMENTS_SCHEMA
]
})
export class AppModule { }
export class AppModule {
constructor(
applicationInitStatus: ApplicationInitStatus,
store: Store<State>,
reducerBootstrapper: ReducerBootstrapper,
) {
applicationInitStatus.donePromise.then(() => store.dispatch(new AppInitAction({ initialState: reducerBootstrapper.initialState })));
}
}
2 changes: 2 additions & 0 deletions packages/altair-app/src/app/interfaces/shared.ts
Expand Up @@ -3,3 +3,5 @@ export interface IDictionary<V = any> {
}

export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export type TODO = any;
Expand Up @@ -5,15 +5,17 @@ import { IQueryCollection } from 'app/store/collection/collection.reducer';
@Injectable()
export class StorageService extends Dexie {
queryCollections: Dexie.Table<IQueryCollection, number>;
appState: Dexie.Table<{ key: string, value: any }, string>;

constructor() {
super('AltairDB');
this.schema();
}

schema() {
this.version(1).stores({
queryCollections: '++id, title'
this.version(2).stores({
queryCollections: '++id, title',
appState: 'key',
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/altair-app/src/app/store/action.ts
Expand Up @@ -5,3 +5,9 @@ export interface ActionWithPayload extends NGRXAction {
}

export const INIT_WINDOW = '___INIT_WINDOW___';

export const APP_INIT_ACTION = 'APP_INIT_ACTION';
export class AppInitAction {
readonly type = APP_INIT_ACTION;
constructor(public payload: { initialState: any }) {}
}
200 changes: 200 additions & 0 deletions packages/altair-app/src/app/store/async-storage-sync.ts
@@ -0,0 +1,200 @@
import { INIT } from '@ngrx/store';
import { LocalStorageConfig, rehydrateApplicationState } from 'ngrx-store-localstorage';
import { ActionWithPayload, AppInitAction, APP_INIT_ACTION } from './action';
import deepmerge from 'deepmerge';
import { StorageService } from 'app/services';
import { IDictionary } from 'app/interfaces/shared';
import { Transaction } from 'dexie';
import { debounce } from 'lodash-es';
import { debug } from 'app/utils/logger';
import { localStorageSyncConfig } from '.';

const normalizeToKeyValue = (state: any, keys: string[]) => {
const normalized: IDictionary = {};
keys.forEach(key => {
if (key === 'windows' && state[key]) {
// handle specially
Object.keys(state[key]).forEach(windowId => {
normalized[`${key}::${windowId}`] = state[key][windowId];
});
} else {
normalized[key] = state[key];
}
});

return normalized;
};

interface SyncOperation {
operation: 'put' | 'delete';
key: string;
value?: string;
}

let syncTransaction: Transaction | null = null;
let syncOperations: SyncOperation[] = [];
// { operation: 'put', key, value };
const getSyncOperations = (oldState: any, newState: any, keys: string[]) => {
const ops: SyncOperation[] = [];
const normalizedOldState = normalizeToKeyValue(oldState, keys);
const normalizedNewState = normalizeToKeyValue(newState, keys);

// Get old keys from old state and remove any undefined in new state (especially window state)
const removedKeys = Object.keys(normalizedOldState).filter(key => !Object.keys(normalizedNewState).includes(key));

removedKeys.forEach(key => {
ops.push({
operation: 'delete',
key,
});
});

Object.keys(normalizedNewState).map(key => {
// Add operation only if value is changed
if (normalizedNewState[key] !== normalizedOldState[key]) {
ops.push({
operation: 'put',
key,
value: JSON.stringify(normalizedNewState[key]),
});
}
});

return ops;
};

const updateSyncOperations = (oldState: any, newState: any, keys: string[]) => {
const newOps = getSyncOperations(oldState, newState, keys);
syncOperations = syncOperations.filter(op => !newOps.find(no => no.key === op.key)).concat(newOps);
};

const syncStateUpdate = () => {
const asyncStorage = new StorageService();
if (syncTransaction) {
syncTransaction.abort();
syncTransaction = null;
}

debug.log('updating state...');
return asyncStorage.transaction('rw', asyncStorage.appState, async(trans) => {
// Store transaction handles for cancellation later
syncTransaction = trans;

const ops: Promise<any>[] = [];

syncOperations.forEach(op => {
switch (op.operation) {
case 'put':
ops.push(
asyncStorage.appState.put({
key: op.key,
value: op.value,
})
);
break;
case 'delete':
ops.push(
asyncStorage.appState.delete(op.key)
);
break;
}
});

// flush the sync operations list
syncOperations = [];

return Promise.all(ops);
});
};
const debouncedSyncStateUpdate = debounce(syncStateUpdate, 1000);

export const defaultMergeReducer = (state: any, rehydratedState: any, action: any) => {
if (action.type === APP_INIT_ACTION && rehydratedState) {
const overwriteMerge = (destinationArray: any, sourceArray: any) => sourceArray;
const options: deepmerge.Options = {
arrayMerge: overwriteMerge,
};

state = deepmerge(state, rehydratedState, options);
}

return state;
};

export const getAppStateFromStorage = async(updateFromLocalStorage = false) => {
const asyncStorage = new StorageService();
let stateList = await asyncStorage.appState.toArray();
const reducedState: IDictionary = {
windows: {},
};

if (!stateList.length) {
if (!updateFromLocalStorage) {
return;
}
// migrate the data from localStorage into async storage
const hydratedState = rehydrateApplicationState(
localStorageSyncConfig.keys,
localStorageSyncConfig.storage,
localStorageSyncConfig.storageKeySerializer,
localStorageSyncConfig.restoreDates,
);
updateSyncOperations({}, hydratedState, localStorageSyncConfig.keys);
debug.log('pulling state from localStorage since async storage is empty..');
await syncStateUpdate();

stateList = await asyncStorage.appState.toArray();
if (!stateList.length) {
return;
}
// TODO: Clean from localStorage
}

stateList.forEach((curStateItem) => {
if (curStateItem.key.includes('windows::')) {
// Handle reducing window state
reducedState.windows[curStateItem.key.replace('windows::', '')] = JSON.parse(curStateItem.value);
} else {
reducedState[curStateItem.key] = JSON.parse(curStateItem.value);
}
});

return reducedState;
};

export const asyncStorageSync = (opts: LocalStorageConfig) => (reducer: any) => {
return function (state: any, action: ActionWithPayload) {
let nextState: any;

// If state arrives undefined, we need to let it through the supplied reducer
// in order to get a complete state as defined by user
if (action.type === INIT && !state) {
nextState = reducer(state, action);
} else {
nextState = { ...state };
}
// Merge the store state with the rehydrated state using
// either a user-defined reducer or the default.
if (action.type === APP_INIT_ACTION) {
if (action.payload?.initialState) {
nextState = defaultMergeReducer(nextState, (action as AppInitAction).payload.initialState, action);
}
}

nextState = reducer(nextState, action);

if (![INIT, APP_INIT_ACTION].includes(action.type)) {
// update storage
// Queue update changes before debouncing
updateSyncOperations(state, nextState, opts.keys);
debouncedSyncStateUpdate();
}

return nextState;
};
};

// const syncStateUpdate = (oldState: any, newState: any, keys: string[], immediate = false) => {
// updateSyncOperations(oldState, newState, keys);
// debouncedSyncStateUpdate();
// };

0 comments on commit cf10a98

Please sign in to comment.