├── projects └── ngrx-store-idb │ ├── src │ ├── public-api.ts │ └── lib │ │ ├── index.ts │ │ ├── ngrx-store-idb.actions.ts │ │ ├── ngrx-store-idb.module.ts │ │ ├── ngrx-store-idb.effects.ts │ │ ├── ngrx-store-idb.service.ts │ │ ├── ngrx-store-idb.options.ts │ │ └── ngrx-store-idb.metareducer.ts │ ├── ng-package.json │ ├── tsconfig.lib.prod.json │ ├── tslint.json │ ├── tsconfig.spec.json │ ├── tsconfig.lib.json │ ├── package.json │ ├── .gitignore │ ├── package-lock.json │ └── README.md ├── .editorconfig ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json ├── angular.json ├── tslint.json └── README.md /projects/ngrx-store-idb/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngrx-store-idb 3 | */ 4 | export * from './lib/index'; 5 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngrx-store-idb", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | }, 7 | "allowedNonPeerDependencies": [ 8 | "tslib", 9 | "deepmerge" 10 | ] 11 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "enableIvy": true, 9 | "compilationMode": "partial" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "lib", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "lib", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import * as RehydrateActions from './ngrx-store-idb.actions'; 2 | 3 | export * from './ngrx-store-idb.module'; 4 | export { 5 | KeyConfiguration, 6 | Keys, 7 | NgrxStoreIdbOptions 8 | } from './ngrx-store-idb.options'; 9 | export { RehydrateActions }; 10 | 11 | export * from './ngrx-store-idb.service'; 12 | 13 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/ngrx-store-idb.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export interface RehydrateActionPayload { 4 | rehydratedState: any; 5 | } 6 | 7 | export const rehydrateInitAction = createAction('NgrxStoreIdb/Init'); 8 | export const rehydrateAction = createAction('NgrxStoreIdb/Rehydrate', props()); 9 | export const rehydrateErrorAction = createAction('NgrxStoreIdb/RehydrateError'); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "allowSyntheticDefaultImports": true, 13 | "target": "es2020", 14 | "module": "es2020", 15 | "lib": [ 16 | "es2018", 17 | "dom" 18 | ], 19 | "paths": { 20 | "ngrx-store-idb": [ 21 | "dist/ngrx-store-idb/ngrx-store-idb", 22 | "dist/ngrx-store-idb" 23 | ] 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /projects/ngrx-store-idb/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "target": "es2020", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "inlineSources": true, 10 | "types": [], 11 | "lib": [ 12 | "dom", 13 | "es2018" 14 | ] 15 | }, 16 | "angularCompilerOptions": { 17 | "skipTemplateCodegen": true, 18 | "strictMetadataEmit": true, 19 | "enableResourceInlining": true, 20 | "fullTemplateTypeCheck": true 21 | }, 22 | "exclude": [ 23 | "src/test.ts", 24 | "**/*.spec.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-store-idb", 3 | "version": "15.1.0", 4 | "license": "MIT", 5 | "author": { 6 | "name": "Stefan Radacovsky", 7 | "url": "https://github.com/radacovsky/ngrx-store-idb" 8 | }, 9 | "readme": "https://github.com/radacovsky/ngrx-store-idb#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/radacovsky/ngrx-store-idb.git" 13 | }, 14 | "peerDependencies": { 15 | "@angular/common": "^15.2.3", 16 | "@angular/core": "^15.2.3", 17 | "@ngrx/effects": "^15.4.0", 18 | "@ngrx/store": "^15.4.0", 19 | "idb-keyval": "^6.2.0" 20 | }, 21 | "dependencies": { 22 | "deepmerge": "^4.3.1", 23 | "tslib": "^2.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /.angular 8 | # Only exists if Bazel was run 9 | /bazel-out 10 | 11 | # dependencies 12 | /node_modules 13 | 14 | # profiling files 15 | chrome-profiler-events*.json 16 | speed-measure-plugin*.json 17 | 18 | # IDEs and editors 19 | /.idea 20 | .project 21 | .classpath 22 | .c9/ 23 | *.launch 24 | .settings/ 25 | *.sublime-workspace 26 | 27 | # IDE - VSCode 28 | .vscode/* 29 | !.vscode/settings.json 30 | !.vscode/tasks.json 31 | !.vscode/launch.json 32 | !.vscode/extensions.json 33 | .history/* 34 | 35 | # misc 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stefan Radacovsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-ng-libs", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^15.2.3", 14 | "@angular/common": "^15.2.3", 15 | "@angular/compiler": "^15.2.3", 16 | "@angular/core": "^15.2.3", 17 | "@angular/forms": "^15.2.3", 18 | "@angular/platform-browser": "^15.2.3", 19 | "@angular/platform-browser-dynamic": "^15.2.3", 20 | "@angular/router": "^15.2.3", 21 | "@ngrx/effects": "^15.4.0", 22 | "@ngrx/store": "^15.4.0", 23 | "idb-keyval": "^6.2.0", 24 | "rxjs": "~7.8.0", 25 | "tslib": "^2.0.0", 26 | "zone.js": "~0.11.4" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^15.2.4", 30 | "@angular/cli": "^15.2.4", 31 | "@angular/compiler-cli": "^15.2.3", 32 | "@types/node": "^12.11.1", 33 | "codelyzer": "^6.0.0", 34 | "ng-packagr": "^15.2.2", 35 | "ts-node": "~8.3.0", 36 | "tslint": "~6.1.0", 37 | "typescript": "~4.8.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngrx-store-idb": { 7 | "projectType": "library", 8 | "root": "projects/ngrx-store-idb", 9 | "sourceRoot": "projects/ngrx-store-idb/src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "tsConfig": "projects/ngrx-store-idb/tsconfig.lib.json", 16 | "project": "projects/ngrx-store-idb/ng-package.json" 17 | }, 18 | "configurations": { 19 | "production": { 20 | "tsConfig": "projects/ngrx-store-idb/tsconfig.lib.prod.json" 21 | } 22 | } 23 | }, 24 | "test": { 25 | "builder": "@angular-devkit/build-angular:karma", 26 | "options": { 27 | "main": "projects/ngrx-store-idb/src/test.ts", 28 | "tsConfig": "projects/ngrx-store-idb/tsconfig.spec.json", 29 | "karmaConfig": "projects/ngrx-store-idb/karma.conf.js" 30 | } 31 | }, 32 | "lint": { 33 | "builder": "@angular-devkit/build-angular:tslint", 34 | "options": { 35 | "tsConfig": [ 36 | "projects/ngrx-store-idb/tsconfig.lib.json", 37 | "projects/ngrx-store-idb/tsconfig.spec.json" 38 | ], 39 | "exclude": [ 40 | "**/node_modules/**" 41 | ] 42 | } 43 | } 44 | } 45 | }}, 46 | "defaultProject": "ngrx-store-idb" 47 | } 48 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/ngrx-store-idb.module.ts: -------------------------------------------------------------------------------- 1 | import { APP_INITIALIZER, ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; 2 | import { EffectsModule } from '@ngrx/effects'; 3 | import { META_REDUCERS } from '@ngrx/store'; 4 | import { idbStoreFactory, metaReducerFactoryWithOptions, ngrxStoreIdbServiceInitializer, optionsFactory } from './ngrx-store-idb.metareducer'; 5 | import { IDB_STORE, NgrxStoreIdbOptions, OPTIONS } from './ngrx-store-idb.options'; 6 | import { RehydrateEffects } from './ngrx-store-idb.effects'; 7 | import { NgrxStoreIdbService } from './ngrx-store-idb.service'; 8 | 9 | 10 | /** 11 | * Import this module in your AppModule using forRoot() method to 12 | * enable synchornisation of redux store with IndexedDB. 13 | */ 14 | @NgModule({ 15 | declarations: [], 16 | imports: [ 17 | EffectsModule.forFeature([RehydrateEffects]), 18 | ], 19 | exports: [], 20 | }) 21 | export class NgrxStoreIdbModule { 22 | 23 | constructor(@Optional() @SkipSelf() parentModule?: NgrxStoreIdbModule) { 24 | if (parentModule) { 25 | throw new Error( 26 | 'NgrxStoreIdbModule is already loaded. Import it in the AppModule only'); 27 | } 28 | } 29 | 30 | static forRoot(options: Partial = {}): ModuleWithProviders { 31 | return { 32 | ngModule: NgrxStoreIdbModule, 33 | providers: [ 34 | // Used to pass options into RehydrateEffects 35 | { 36 | provide: OPTIONS, 37 | useValue: optionsFactory(options), 38 | }, 39 | // Used to pass idb store into RehydrateEffects 40 | { 41 | provide: IDB_STORE, 42 | deps: [OPTIONS], 43 | useFactory: idbStoreFactory, 44 | }, 45 | { 46 | provide: APP_INITIALIZER, 47 | deps: [OPTIONS, NgrxStoreIdbService], 48 | useFactory: ngrxStoreIdbServiceInitializer, 49 | multi: true, 50 | }, 51 | // This installs NgrxStateIdb metareducer into use 52 | { 53 | provide: META_REDUCERS, 54 | deps: [OPTIONS, IDB_STORE, NgrxStoreIdbService], 55 | useFactory: metaReducerFactoryWithOptions, 56 | multi: true, 57 | }, 58 | ], 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/ngrx-store-idb.effects.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType, OnInitEffects } from '@ngrx/effects'; 3 | import { Action } from '@ngrx/store'; 4 | import { get, UseStore } from 'idb-keyval'; 5 | import { combineLatest, from, of } from 'rxjs'; 6 | import { catchError, map, mergeMap, tap } from 'rxjs/operators'; 7 | import { rehydrateAction, rehydrateErrorAction, rehydrateInitAction } from './ngrx-store-idb.actions'; 8 | import { IDB_STORE, NgrxStoreIdbOptions, OPTIONS, SAVED_STATE_KEY, SAVED_VERSION_KEY } from './ngrx-store-idb.options'; 9 | 10 | @Injectable({ 11 | providedIn: 'root', 12 | }) 13 | export class RehydrateEffects implements OnInitEffects { 14 | 15 | // State read from IDB 16 | rehydratedState: any; 17 | 18 | /** 19 | * This effect is triggered after root store initialisation. 20 | * This is the very first thing done by NgRx and the best place 21 | * to load saved data. 22 | */ 23 | rehydrateOnInit$ = createEffect(() => 24 | this.actions$.pipe( 25 | ofType(rehydrateInitAction), 26 | mergeMap(action => { 27 | if (this.options.debugInfo) { 28 | console.debug('NgrxStoreIdb: Load state from IndexedDB'); 29 | } 30 | return combineLatest([from(get(SAVED_STATE_KEY, this.idbStore)), from(get(SAVED_VERSION_KEY, this.idbStore))]).pipe( 31 | map(([value, version]) => { 32 | if (version === this.options.version || (version === undefined && this.options.version === 0)) { 33 | console.debug('NgrxStoreIdb: version matched.'); 34 | return value; 35 | } 36 | 37 | if (this.options.debugInfo) { 38 | console.debug('NgrxStoreIdb: Saved state version does not match current version.'); 39 | } 40 | 41 | if (this.options.migrate) { 42 | if (this.options.debugInfo) { 43 | console.debug('NgrxStoreIdb: Migrating saved state.'); 44 | } 45 | 46 | return this.options.migrate(value, version); 47 | } 48 | 49 | if (this.options.debugInfo) { 50 | console.debug('NgrxStoreIdb: Discard saved state since there is no migration function.'); 51 | } 52 | 53 | return {}; 54 | }), 55 | tap(value => this.rehydratedState = value), 56 | tap(value => { 57 | if (this.options.debugInfo) { 58 | console.debug('NgrxStoreIdb: Loaded state from IndexedDB:', value); 59 | } 60 | }), 61 | map(value => rehydrateAction({ rehydratedState: value })), 62 | catchError(err => { 63 | console.error('NgrxStoreIdb: Error reading state from IndexedDB', err); 64 | return of(rehydrateErrorAction()); 65 | }), 66 | ); 67 | }), 68 | ), 69 | ); 70 | 71 | constructor( 72 | private actions$: Actions, 73 | @Inject(OPTIONS) private options: NgrxStoreIdbOptions, 74 | @Inject(IDB_STORE) private idbStore: UseStore, 75 | ) { } 76 | 77 | ngrxOnInitEffects(): Action { 78 | if (this.options.rehydrate) { 79 | return rehydrateInitAction(); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/ngrx-store-idb.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | import { Action } from '@ngrx/store'; 3 | import { get, set, UseStore } from 'idb-keyval'; 4 | import { EMPTY, from, Observable, ReplaySubject, timer } from 'rxjs'; 5 | import { map, switchMap } from 'rxjs/operators'; 6 | import { IDB_STORE, NgrxStoreIdbOptions, OPTIONS } from './ngrx-store-idb.options'; 7 | 8 | interface ConcurrencyTimestamp { 9 | uniqueKey: string; 10 | timestamp: number; 11 | } 12 | 13 | export interface NgrxStoreIdbSyncEvent { 14 | action: Action; 15 | success: boolean; 16 | } 17 | 18 | /** 19 | * This service emits events each time NgrxStoreIdb metareducer successfuly 20 | * syncs data to IndexedDB. The data emited from onSync$ observable is 21 | * the action that triggered the synchronisation event. 22 | */ 23 | @Injectable({ 24 | providedIn: 'root', 25 | }) 26 | export class NgrxStoreIdbService { 27 | 28 | private uniqueKey: string; 29 | 30 | private broadcastSubject = new ReplaySubject(1); 31 | 32 | private onSync$ = this.broadcastSubject.asObservable(); 33 | 34 | private lockAcquiredSubject = new ReplaySubject(1); 35 | 36 | private onLockAcquired$ = this.lockAcquiredSubject.asObservable(); 37 | 38 | private iAmMasterOfStore = false; 39 | 40 | constructor( 41 | @Inject(OPTIONS) private opts: NgrxStoreIdbOptions, 42 | @Inject(IDB_STORE) private idbStore: UseStore, 43 | ) { 44 | this.uniqueKey = this.uuidv4(); 45 | 46 | // Have a look if there is already some other instance running 47 | from(get(this.opts.concurrency.trackKey, this.idbStore)).pipe( 48 | map(inData => !inData || inData.timestamp < (Date.now() - opts.concurrency.refreshRate * 1.1)), 49 | switchMap(lockAcquired => { 50 | if (lockAcquired) { 51 | this.iAmMasterOfStore = true; 52 | this.lockAcquiredSubject.next(true); 53 | this.lockAcquiredSubject.complete(); 54 | // No instance or it was not updated for a long time. 55 | // Start a timer and keep updating the timestamp 56 | return timer(0, opts.concurrency.refreshRate).pipe( 57 | map(() => { 58 | uniqueKey: this.uniqueKey, 59 | timestamp: Date.now(), 60 | }), 61 | switchMap(outData => from(set(this.opts.concurrency.trackKey, outData, this.idbStore)).pipe(map(() => outData)), 62 | )); 63 | } 64 | // Otherwise do nothing - some other instance is syncing/master of the IDB store 65 | this.lockAcquiredSubject.next(false); 66 | this.lockAcquiredSubject.complete(); 67 | return EMPTY; 68 | }), 69 | ).subscribe(outData => { 70 | if (opts.debugInfo) { 71 | console.debug(`NgrxStoreIdb: Updating concurrency timestamp '${opts.concurrency.trackKey}'`, outData); 72 | } 73 | }); 74 | } 75 | 76 | public onLockAcquired(): Observable { 77 | return this.onLockAcquired$; 78 | } 79 | 80 | public canConcurrentlySync(): boolean { 81 | return this.opts.concurrency.allowed || this.iAmMasterOfStore; 82 | } 83 | 84 | private uuidv4(): string { 85 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 86 | const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); 87 | return v.toString(16); 88 | }); 89 | } 90 | 91 | broadcastSyncEvent(action: Action, success: boolean): void { 92 | this.broadcastSubject.next(action); 93 | } 94 | 95 | public onSync(): Observable { 96 | return this.onSync$; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "align": { 8 | "options": [ 9 | "parameters", 10 | "statements" 11 | ] 12 | }, 13 | "array-type": false, 14 | "arrow-return-shorthand": true, 15 | "curly": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "eofline": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": { 26 | "options": [ 27 | "spaces" 28 | ] 29 | }, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-empty": false, 55 | "no-inferrable-types": [ 56 | true, 57 | "ignore-params" 58 | ], 59 | "no-non-null-assertion": true, 60 | "no-redundant-jsdoc": true, 61 | "no-switch-case-fall-through": true, 62 | "no-var-requires": false, 63 | "object-literal-key-quotes": [ 64 | true, 65 | "as-needed" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": { 72 | "options": [ 73 | "always" 74 | ] 75 | }, 76 | "space-before-function-paren": { 77 | "options": { 78 | "anonymous": "never", 79 | "asyncArrow": "always", 80 | "constructor": "never", 81 | "method": "never", 82 | "named": "never" 83 | } 84 | }, 85 | "typedef": [ 86 | true, 87 | "call-signature" 88 | ], 89 | "typedef-whitespace": { 90 | "options": [ 91 | { 92 | "call-signature": "nospace", 93 | "index-signature": "nospace", 94 | "parameter": "nospace", 95 | "property-declaration": "nospace", 96 | "variable-declaration": "nospace" 97 | }, 98 | { 99 | "call-signature": "onespace", 100 | "index-signature": "onespace", 101 | "parameter": "onespace", 102 | "property-declaration": "onespace", 103 | "variable-declaration": "onespace" 104 | } 105 | ] 106 | }, 107 | "variable-name": { 108 | "options": [ 109 | "ban-keywords", 110 | "check-format", 111 | "allow-pascal-case" 112 | ] 113 | }, 114 | "whitespace": { 115 | "options": [ 116 | "check-branch", 117 | "check-decl", 118 | "check-operator", 119 | "check-separator", 120 | "check-type", 121 | "check-typecast" 122 | ] 123 | }, 124 | "component-class-suffix": true, 125 | "contextual-lifecycle": true, 126 | "directive-class-suffix": true, 127 | "no-conflicting-lifecycle": true, 128 | "no-host-metadata-property": true, 129 | "no-input-rename": true, 130 | "no-inputs-metadata-property": true, 131 | "no-output-native": true, 132 | "no-output-on-prefix": true, 133 | "no-output-rename": true, 134 | "no-outputs-metadata-property": true, 135 | "template-banana-in-box": true, 136 | "template-no-negated-async": true, 137 | "use-lifecycle-interface": true, 138 | "use-pipe-transform-interface": true 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/ngrx-store-idb.options.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { Action } from '@ngrx/store'; 3 | import { UseStore } from 'idb-keyval'; 4 | 5 | /** 6 | * Injection token for injection options 7 | */ 8 | export const OPTIONS = new InjectionToken('NgrxStoreIdb options'); 9 | 10 | /** 11 | * Injection token for injecting IDB store 12 | */ 13 | export const IDB_STORE = new InjectionToken('IDB Store'); 14 | 15 | /** 16 | * Name of the key in IndexedDB database store under which the state will be saved 17 | */ 18 | export const SAVED_STATE_KEY = 'State'; 19 | 20 | /** 21 | * Name of the key in IndexedDB database store under which the version will be saved 22 | */ 23 | export const SAVED_VERSION_KEY = 'Version'; 24 | 25 | export interface KeyConfiguration { 26 | [key: string]: string[] | number[] | KeyConfiguration[]; 27 | } 28 | 29 | export type Keys = (KeyConfiguration | string)[]; 30 | 31 | /** 32 | * Configuration options for NgrxStoreIdb 33 | */ 34 | export interface NgrxStoreIdbOptions { 35 | /** 36 | * IndexDB configuration 37 | */ 38 | idb: { 39 | /** 40 | * Database name 41 | */ 42 | dbName: string; 43 | /** 44 | * Store name 45 | */ 46 | storeName: string; 47 | }; 48 | /** 49 | * If true then store will be restored from IndexedDB on application startup 50 | */ 51 | rehydrate: boolean; 52 | /** 53 | * Save state into IndexedDB only if the state to be save changed since last save. 54 | */ 55 | saveOnChange: boolean; 56 | /** 57 | * Defines what slices of store should be stored/rehydrated. 58 | * Can not be defined if marshaller & unmarshaller are defined. 59 | * Default is null. 60 | */ 61 | keys: Keys; 62 | /** 63 | * If defined then synchronisation of store -> IDB will be done only when the function returns true. 64 | * You can use it e.g. to do syncing only on certain action. 65 | */ 66 | syncCondition: ((state: any, action: Action) => boolean) | null; 67 | /** 68 | * Method used to merge data loaded from IDB with Store state during rehydratation. 69 | * When null then default will be full deep merge. Must be used together with marshaller. 70 | * Can not be used together with keys. 71 | */ 72 | unmarshaller: (state: any, rehydratedState: any) => any; 73 | /** 74 | * Method used to marshall store state into object to be written into IDB. 75 | * Must be used together with unmarshaller. 76 | * Can not be used together with keys. 77 | */ 78 | marshaller: (state: any) => any; 79 | /** 80 | * If the stored state's version mismatch the one specified here, the storage will not be used. 81 | * This is useful when adding a breaking change to your store. 82 | */ 83 | version?: number 84 | /** 85 | * A function to perform persisted state migration. 86 | * This function will be called when persisted state versions mismatch with the one specified here. 87 | */ 88 | migrate?: (persistedState: any, version: number) => any 89 | /** 90 | * Print debug info if true 91 | */ 92 | debugInfo: boolean; 93 | /** 94 | * Configuration of concurrency options 95 | */ 96 | concurrency: { 97 | /** 98 | * If false then library won't sync state to IndexedDB if it detects that another instance of 99 | * Window/Tab is already syncing. 100 | * Default is false. 101 | */ 102 | allowed: boolean; 103 | /** 104 | * Time in ms how often library updates timestamp. 105 | * This shouldn't be less than 1000ms. 106 | * Default is 5000ms. 107 | */ 108 | refreshRate?: number; 109 | /** 110 | * Name of key that holds the timestamp data. 111 | * Default is 'ConcurrencyTimestamp'. 112 | */ 113 | trackKey?: string; 114 | /** 115 | * If the library detects that another instance of application already exists 116 | * (e.g. running in different tab/window) and this is set to true then application 117 | * won't start up. 118 | * Default if false. 119 | */ 120 | failInitialisationIfNoLock: boolean; 121 | } 122 | } 123 | 124 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngrx-store-idb", 3 | "version": "1.1.4", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "ngrx-store-idb", 9 | "version": "1.1.4", 10 | "license": "MIT", 11 | "dependencies": { 12 | "deepmerge": "^4.3.1", 13 | "tslib": "^2.0.0" 14 | }, 15 | "peerDependencies": { 16 | "@angular/common": "^15.2.3", 17 | "@angular/core": "^15.2.3", 18 | "@ngrx/effects": "^15.4.0", 19 | "@ngrx/store": "^15.4.0", 20 | "idb-keyval": "^6.2.0" 21 | } 22 | }, 23 | "node_modules/@angular/common": { 24 | "version": "15.2.3", 25 | "resolved": "https://registry.npmjs.org/@angular/common/-/common-15.2.3.tgz", 26 | "integrity": "sha512-J68CSb57XadC2weHw7kmHjCdrHNgxPv8ZW6KlnmYvIRJrkKsZuCl+PvFe90VMDvHtlBnSnz8sjAPqoUxesMRNg==", 27 | "peer": true, 28 | "dependencies": { 29 | "tslib": "^2.3.0" 30 | }, 31 | "engines": { 32 | "node": "^14.20.0 || ^16.13.0 || >=18.10.0" 33 | }, 34 | "peerDependencies": { 35 | "@angular/core": "15.2.3", 36 | "rxjs": "^6.5.3 || ^7.4.0" 37 | } 38 | }, 39 | "node_modules/@angular/core": { 40 | "version": "15.2.3", 41 | "resolved": "https://registry.npmjs.org/@angular/core/-/core-15.2.3.tgz", 42 | "integrity": "sha512-e+d6upOqAyqE7MxxRthd1ZJILSKX+hXHCmujc48id8G3zhP0tD59iZ03KgUe8RMvXMlSBUhwOwDX39tr701eig==", 43 | "peer": true, 44 | "dependencies": { 45 | "tslib": "^2.3.0" 46 | }, 47 | "engines": { 48 | "node": "^14.20.0 || ^16.13.0 || >=18.10.0" 49 | }, 50 | "peerDependencies": { 51 | "rxjs": "^6.5.3 || ^7.4.0", 52 | "zone.js": "~0.11.4 || ~0.12.0 || ~0.13.0" 53 | } 54 | }, 55 | "node_modules/@ngrx/effects": { 56 | "version": "15.4.0", 57 | "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-15.4.0.tgz", 58 | "integrity": "sha512-/8gHhOM9aeGaw8OG2LLwi4I4p84xzG0EU9TqWrvQcW74wn8sFZONjLvUte5YOzJ5502PPFFrfXSOc+lHnVAJUA==", 59 | "peer": true, 60 | "dependencies": { 61 | "tslib": "^2.0.0" 62 | }, 63 | "peerDependencies": { 64 | "@angular/core": "^15.0.0", 65 | "@ngrx/store": "15.4.0", 66 | "rxjs": "^6.5.3 || ^7.5.0" 67 | } 68 | }, 69 | "node_modules/@ngrx/store": { 70 | "version": "15.4.0", 71 | "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-15.4.0.tgz", 72 | "integrity": "sha512-OvCuNBHL8mAUnRTS6QSFm+IunspsYNu2cCwDovBNn7EGhxRuGihBeNoX47jCqWPHBFtokj4BlatDfpJ/yCh4xQ==", 73 | "peer": true, 74 | "dependencies": { 75 | "tslib": "^2.0.0" 76 | }, 77 | "peerDependencies": { 78 | "@angular/core": "^15.0.0", 79 | "rxjs": "^6.5.3 || ^7.5.0" 80 | } 81 | }, 82 | "node_modules/deepmerge": { 83 | "version": "4.3.1", 84 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 85 | "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 86 | "engines": { 87 | "node": ">=0.10.0" 88 | } 89 | }, 90 | "node_modules/idb-keyval": { 91 | "version": "6.2.0", 92 | "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz", 93 | "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==", 94 | "peer": true, 95 | "dependencies": { 96 | "safari-14-idb-fix": "^3.0.0" 97 | } 98 | }, 99 | "node_modules/rxjs": { 100 | "version": "7.5.5", 101 | "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", 102 | "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", 103 | "peer": true, 104 | "dependencies": { 105 | "tslib": "^2.1.0" 106 | } 107 | }, 108 | "node_modules/safari-14-idb-fix": { 109 | "version": "3.0.0", 110 | "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", 111 | "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==", 112 | "peer": true 113 | }, 114 | "node_modules/tslib": { 115 | "version": "2.4.0", 116 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", 117 | "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" 118 | }, 119 | "node_modules/zone.js": { 120 | "version": "0.11.5", 121 | "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.5.tgz", 122 | "integrity": "sha512-D1/7VxEuQ7xk6z/kAROe4SUbd9CzxY4zOwVGnGHerd/SgLIVU5f4esDzQUsOCeArn933BZfWMKydH7l7dPEp0g==", 123 | "peer": true, 124 | "dependencies": { 125 | "tslib": "^2.3.0" 126 | } 127 | } 128 | }, 129 | "dependencies": { 130 | "@angular/common": { 131 | "version": "15.2.3", 132 | "resolved": "https://registry.npmjs.org/@angular/common/-/common-15.2.3.tgz", 133 | "integrity": "sha512-J68CSb57XadC2weHw7kmHjCdrHNgxPv8ZW6KlnmYvIRJrkKsZuCl+PvFe90VMDvHtlBnSnz8sjAPqoUxesMRNg==", 134 | "peer": true, 135 | "requires": { 136 | "tslib": "^2.3.0" 137 | } 138 | }, 139 | "@angular/core": { 140 | "version": "15.2.3", 141 | "resolved": "https://registry.npmjs.org/@angular/core/-/core-15.2.3.tgz", 142 | "integrity": "sha512-e+d6upOqAyqE7MxxRthd1ZJILSKX+hXHCmujc48id8G3zhP0tD59iZ03KgUe8RMvXMlSBUhwOwDX39tr701eig==", 143 | "peer": true, 144 | "requires": { 145 | "tslib": "^2.3.0" 146 | } 147 | }, 148 | "@ngrx/effects": { 149 | "version": "15.4.0", 150 | "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-15.4.0.tgz", 151 | "integrity": "sha512-/8gHhOM9aeGaw8OG2LLwi4I4p84xzG0EU9TqWrvQcW74wn8sFZONjLvUte5YOzJ5502PPFFrfXSOc+lHnVAJUA==", 152 | "peer": true, 153 | "requires": { 154 | "tslib": "^2.0.0" 155 | } 156 | }, 157 | "@ngrx/store": { 158 | "version": "15.4.0", 159 | "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-15.4.0.tgz", 160 | "integrity": "sha512-OvCuNBHL8mAUnRTS6QSFm+IunspsYNu2cCwDovBNn7EGhxRuGihBeNoX47jCqWPHBFtokj4BlatDfpJ/yCh4xQ==", 161 | "peer": true, 162 | "requires": { 163 | "tslib": "^2.0.0" 164 | } 165 | }, 166 | "deepmerge": { 167 | "version": "4.3.1", 168 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 169 | "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" 170 | }, 171 | "idb-keyval": { 172 | "version": "6.2.0", 173 | "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz", 174 | "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==", 175 | "peer": true, 176 | "requires": { 177 | "safari-14-idb-fix": "^3.0.0" 178 | } 179 | }, 180 | "rxjs": { 181 | "version": "7.5.5", 182 | "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", 183 | "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", 184 | "peer": true, 185 | "requires": { 186 | "tslib": "^2.1.0" 187 | } 188 | }, 189 | "safari-14-idb-fix": { 190 | "version": "3.0.0", 191 | "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", 192 | "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==", 193 | "peer": true 194 | }, 195 | "tslib": { 196 | "version": "2.4.0", 197 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", 198 | "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" 199 | }, 200 | "zone.js": { 201 | "version": "0.11.5", 202 | "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.5.tgz", 203 | "integrity": "sha512-D1/7VxEuQ7xk6z/kAROe4SUbd9CzxY4zOwVGnGHerd/SgLIVU5f4esDzQUsOCeArn933BZfWMKydH7l7dPEp0g==", 204 | "peer": true, 205 | "requires": { 206 | "tslib": "^2.3.0" 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ngrx-store-idb 2 | 3 | Simple syncing between your ngrx store and IndexedDB. This library was adapted from excellent [ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage) library. 4 | 5 | ## Description 6 | 7 | This library is intended for projects implemented with Angular and NGRX. It saves (selected parts of) your store into IndexedDB and again reads them back upon your application load. This is acomplished by installing NGRX metareducer. 8 | 9 | The main difference to [ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage) library is that this library uses IndexedDB for storage. 10 | 11 | Local storage can store only 5MB of data, can store only strings and is synchronous. 12 | 13 | IndexedDB uses [Structured Cloning Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) to serialize the data, is asynchronous and has much higher [storage limit](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria#Storage_limits). 14 | 15 | The data from storage is first read and merged into NGRX store immediatelly after NGRX store is initialized and NGRX effects are activated. There are additional merges executed when a new store feature is activated (e.g. for lazy loaded module). 16 | 17 | ## Concurrency issues 18 | 19 | Since your users can open your web application in multiple tabs or windows it can present interesting challenges to design of your application. If there are multiple instances of your application open at the same time, they fight for access to IndexedDB and will overwrite the data. This might not be a problem if you design for it but usually it will be a problem. 20 | 21 | The library supports measures to handle concurrency issues. Each application (running this library) instance will try to acquire a so called lock over IndexedDB store and then will periodically update it to signal that it is still using it. If another instance opens, it will discover that it can not acquire the lock and will either stop syncing state to IndexedDB (but will still be able to rehydrate state) or will fail to load. See `concurrency` options. 22 | 23 | If your application needs to synchronise state across multiple tabs/windows then you are better off using [ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage) library. Local storage supports event notification when the state changes allowing you to react to changes easily. 24 | 25 | ## Dependencies 26 | 27 | `ngrx-store-idb` depends on 28 | [@ngrx/store](https://github.com/ngrx/store), 29 | [Angular 2+](https://github.com/angular/angular), 30 | [idb-keyval](https://github.com/jakearchibald/idb-keyval) 31 | 32 | ## Usage 33 | 34 | ```bash 35 | npm install ngrx-store-idb --save 36 | ``` 37 | 38 | 1. Import NgrxStoreIdbModule in your main AppModule. 39 | 2. Profit! 40 | 41 | ```ts 42 | import { NgModule } from '@angular/core'; 43 | import { BrowserModule } from '@angular/platform-browser'; 44 | import { NgrxStoreIdbModule } from 'ngrx-store-idb'; 45 | 46 | @NgModule({ 47 | imports: [ 48 | BrowserModule, 49 | NgrxStoreIdbModule.forRoot() 50 | ] 51 | }) 52 | export class MyAppModule {} 53 | ``` 54 | 55 | ## API 56 | 57 | ### `NgrxStoreIdbModule.forRoot(options: NgrxStoreIdbOptions)` 58 | 59 | Install module that bootstraps NGRX metareducer and registers additional NGRX effects and Angular service. 60 | 61 | #### Arguments 62 | 63 | * `options` An object that matches with the `NgrxStoreIdbOptions` interface. Uses default values when not provided. 64 | 65 | ### **NgrxStoreIdbOptions** 66 | 67 | An interface defining the configuration attributes to bootstrap NGRX metareducer. The following are properties which compose `NgrxStoreIdbOptions`: 68 | * `keys` State keys/slices to sync with IndexedDB. The keys can be defined in two different formats: 69 | * `string[]`: Array of strings representing the state (reducer) keys. Full state will be synced (e.g. `NgrxStoreIdbModule.forRoot({keys: ['todos']})`). 70 | 71 | * `object[]`: Array of objects where for each object the key represents the state slice and the value represents properties of given slice which should be synced. This allows for the partial state sync (e.g. `NgrxStoreIdbModule.forRoot({keys: [{todos: ['name', 'status'] }, ... ]})`). 72 | Default value is null (i.e. not used). Can not be used together with `unmarshaller/marshaller` options. 73 | 74 | * `rehydrate: boolean`: Pull initial state from IndexedDB on startup, this will default to `true`. 75 | 76 | * `saveOnChange: boolean`: If `true` then state will be synced to IndexedDB only if it differs from previous synced value. It uses object equality to compare previous and current state. See `\projects\ngrx-store-idb\src\lib\ngrx-store-idb.metareducer.ts#statesAreEqual()` for details of the comparison algorithm. Default value is `true`. 77 | 78 | * `syncCondition: (state: any, action: Action) => boolean`: Custom comparator used to determine if the current state should be synced to IndexedDB. You can use it to e.g. implement your own state comparison or trigger synchronisation only for certain actions. Default is `null`. 79 | 80 | * `unmarshaller: (state: any, rehydratedState: any) => any`: Defines the reducer to use to merge the rehydrated state from storage with the state from the ngrx store. Must be defined together with `marshaller`. Can not be used together with `keys`. If unspecified, defaults to performing a full [deepmerge](https://github.com/TehShrike/deepmerge). 81 | 82 | * `marshaller: (state: any) => any`: Method used to marshall store state to be written into IndexedDB. Must be used together with `unmarshaller`. Can not be used together with `keys`. Default marshaller saves to whole state. 83 | 84 | * `debugInfo: boolean`: Set to true to see debug messages in console. It can help you to understand when and how is state synced. Default is `true`. 85 | 86 | * `concurrency.allowed: boolean`: If false then library won't sync state to IndexedDB if it detects that another instance of Window/Tab is already syncing. Default is false. 87 | 88 | * `concurrency.refreshRate: number`: Time in ms how often library updates timestamp. This shouldn't be less than 1000ms. Default is 5000ms. 89 | 90 | * `concurrency.trackKey: string`: Name of IndexedDB key that holds the timestamp data. Default is 'ConcurrencyTimestamp'. 91 | 92 | * `concurrency.failInitialisationIfNoLock: boolean`: If the library detects that another instance of application already exists (e.g. running in different tab/window) and this is set to true then application won't start up. Default is false. 93 | 94 | * `idb.dbName`: IndexedDB database name. Use it if your application already uses IndexedDB and you want to keep everything together. Default value is `NgrxStoreIdb`. 95 | 96 | * `idb.storeName`: IndexedDb store name. Use it if your application already uses IndexedDB and you want to keep everything together. Default value is `Store`. 97 | 98 | ### `NgrxStoreIdbService` 99 | 100 | This service broadcasts information every time ngrx-store-idb syncs store to IndexedDB. 101 | 102 | #### `onSync(): Observable` 103 | 104 | Subscribe to observable returned by this method to receive `NgrxStoreIdbSyncEvent` events every time when store is synced. 105 | These are properties of `NgrxStoreIdbSyncEvent`: 106 | 107 | * `success: boolean`: indicates if synchronisation was successful. Falsy value means that data wasn't written. 108 | 109 | * `action: Action`: holds the action that triggered the synchronisation. You could use this to wait for synchronisation after some user action e.g. wait until store is synchronised after logout to close the page. 110 | 111 | #### `onLockAcquired(): Observable` 112 | 113 | Subscribe to observable returned by this method to receive information whether current instance was able to acquire lock. If the value returned is true then current instance of application is the one that will sync its state to IndexedDB. False means that some other instance is already running. 114 | 115 | #### `canConcurrentlySync(): boolean` 116 | 117 | Retuns true if current instance has lock or if the concurrency configuration allows concurrent instances to update IndexedDB (`concurrency.allowed = true`). 118 | 119 | ### Usage 120 | 121 | #### Target Depth Configuration 122 | 123 | ```ts 124 | NgrxStoreIdbModule.forRoot({ 125 | keys: [ 126 | { feature1: [{ slice11: ['slice11_1'], slice14: ['slice14_2'] }] }, 127 | { feature2: ['slice21'] } 128 | ], 129 | }); 130 | ``` 131 | In this example, `feature1.slice11.slice11_1`, `feature1.slice14.slice14_2`, and `feature2.slice21` will be synced to IndexedDB. 132 | -------------------------------------------------------------------------------- /projects/ngrx-store-idb/README.md: -------------------------------------------------------------------------------- 1 | # ngrx-store-idb 2 | 3 | Simple syncing between your ngrx store and IndexedDB. This library was adapted from excellent [ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage) library. 4 | 5 | ## Description 6 | 7 | This library is intended for projects implemented with Angular and NGRX. It saves (selected parts of) your store into IndexedDB and again reads them back upon your application load. This is acomplished by installing NGRX metareducer. 8 | 9 | The main difference to [ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage) library is that this library uses IndexedDB for storage. 10 | 11 | Local storage can store only 5MB of data, can store only strings and is synchronous. 12 | 13 | IndexedDB uses [Structured Cloning Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) to serialize the data, is asynchronous and has much higher [storage limit](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria#Storage_limits). 14 | 15 | The data from storage is first read and merged into NGRX store immediatelly after NGRX store is initialized and NGRX effects are activated. There are additional merges executed when a new store feature is activated (e.g. for lazy loaded module). 16 | 17 | ## Concurrency issues 18 | 19 | Since your users can open your web application in multiple tabs or windows it can present interesting challenges to design of your application. If there are multiple instances of your application open at the same time, they fight for access to IndexedDB and will overwrite the data. This might not be a problem if you design for it but usually it will be a problem. 20 | 21 | The library supports measures to handle concurrency issues. Each application (running this library) instance will try to acquire a so called lock over IndexedDB store and then will periodically update it to signal that it is still using it. If another instance opens, it will discover that it can not acquire the lock and will either stop syncing state to IndexedDB (but will still be able to rehydrate state) or will fail to load. See `concurrency` options. 22 | 23 | If your application needs to synchronise state across multiple tabs/windows then you are better off using [ngrx-store-localstorage](https://github.com/btroncone/ngrx-store-localstorage) library. Local storage supports event notification when the state changes allowing you to react to changes easily. 24 | 25 | ## Dependencies 26 | 27 | `ngrx-store-idb` depends on 28 | [@ngrx/store](https://github.com/ngrx/store), 29 | [Angular 2+](https://github.com/angular/angular), 30 | [idb-keyval](https://github.com/jakearchibald/idb-keyval) 31 | 32 | ## Usage 33 | 34 | ```bash 35 | npm install ngrx-store-idb --save 36 | ``` 37 | 38 | 1. Import NgrxStoreIdbModule in your main AppModule. 39 | 2. Profit! 40 | 41 | ```ts 42 | import { NgModule } from '@angular/core'; 43 | import { BrowserModule } from '@angular/platform-browser'; 44 | import { NgrxStoreIdbModule } from 'mgrx-store-idb'; 45 | 46 | @NgModule({ 47 | imports: [ 48 | BrowserModule, 49 | NgrxStoreIdbModule.forRoot() 50 | ] 51 | }) 52 | export class MyAppModule {} 53 | ``` 54 | 55 | ## API 56 | 57 | ### `NgrxStoreIdbModule.forRoot(options: NgrxStoreIdbOptions)` 58 | 59 | Install module that bootstraps NGRX metareducer and registers additional NGRX effects and Angular service. 60 | 61 | #### Arguments 62 | 63 | * `options` An object that matches with the `NgrxStoreIdbOptions` interface. Uses default values when not provided. 64 | 65 | ### **NgrxStoreIdbOptions** 66 | 67 | An interface defining the configuration attributes to bootstrap NGRX metareducer. The following are properties which compose `NgrxStoreIdbOptions`: 68 | * `keys` State keys/slices to sync with IndexedDB. The keys can be defined in two different formats: 69 | * `string[]`: Array of strings representing the state (reducer) keys. Full state will be synced (e.g. `NgrxStoreIdbModule.forRoot({keys: ['todos']})`). 70 | 71 | * `object[]`: Array of objects where for each object the key represents the state slice and the value represents properties of given slice which should be synced. This allows for the partial state sync (e.g. `NgrxStoreIdbModule.forRoot({keys: [{todos: ['name', 'status'] }, ... ]})`). 72 | Default value is null (i.e. not used). Can not be used together with `unmarshaller/marshaller` options. 73 | 74 | * `rehydrate: boolean`: Pull initial state from IndexedDB on startup, this will default to `true`. 75 | 76 | * `saveOnChange: boolean`: If `true` then state will be synced to IndexedDB only if it differs from previous synced value. It uses object equality to compare previous and current state. See `\projects\ngrx-store-idb\src\lib\ngrx-store-idb.metareducer.ts#statesAreEqual()` for details of the comparison algorithm. Default value is `true`. 77 | 78 | * `syncCondition: (state: any, action: Action) => boolean`: Custom comparator used to determine if the current state should be synced to IndexedDB. You can use it to e.g. implement your own state comparison or trigger synchronisation only for certain actions. Default is `null`. 79 | 80 | * `unmarshaller: (state: any, rehydratedState: any) => any`: Defines the reducer to use to merge the rehydrated state from storage with the state from the ngrx store. Must be defined together with `marshaller`. Can not be used together with `keys`. If unspecified, defaults to performing a full [deepmerge](https://github.com/TehShrike/deepmerge). 81 | 82 | * `marshaller: (state: any) => any`: Method used to marshall store state to be written into IndexedDB. Must be used together with `unmarshaller`. Can not be used together with `keys`. Default marshaller saves to whole state. 83 | 84 | * `debugInfo: boolean`: Set to true to see debug messages in console. It can help you to understand when and how is state synced. Default is `true`. 85 | 86 | * `concurrency.allowed: boolean`: If false then library won't sync state to IndexedDB if it detects that another instance of Window/Tab is already syncing. Default is false. 87 | 88 | * `concurrency.refreshRate: number`: Time in ms how often library updates timestamp. This shouldn't be less than 1000ms. Default is 5000ms. 89 | 90 | * `concurrency.trackKey: string`: Name of IndexedDB key that holds the timestamp data. Default is 'ConcurrencyTimestamp'. 91 | 92 | * `concurrency.failInitialisationIfNoLock: boolean`: If the library detects that another instance of application already exists (e.g. running in different tab/window) and this is set to true then application won't start up. Default is false. 93 | 94 | * `idb.dbName`: IndexedDB database name. Use it if your application already uses IndexedDB and you want to keep everything together. Default value is `NgrxStoreIdb`. 95 | 96 | * `idb.storeName`: IndexedDb store name. Use it if your application already uses IndexedDB and you want to keep everything together. Default value is `Store`. 97 | 98 | ### `NgrxStoreIdbService` 99 | 100 | This service broadcasts information every time ngrx-store-idb syncs store to IndexedDB. 101 | 102 | #### `onSync(): Observable` 103 | 104 | Subscribe to observable returned by this method to receive `NgrxStoreIdbSyncEvent` events every time when store is synced. 105 | These are properties of `NgrxStoreIdbSyncEvent`: 106 | 107 | * `success: boolean`: indicates if synchronisation was successful. Falsy value means that data wasn't written. 108 | 109 | * `action: Action`: holds the action that triggered the synchronisation. You could use this to wait for synchronisation after some user action e.g. wait until store is synchronised after logout to close the page. 110 | 111 | #### `onLockAcquired(): Observable` 112 | 113 | Subscribe to observable returned by this method to receive information whether current instance was able to acquire lock. If the value returned is true then current instance of application is the one that will sync its state to IndexedDB. False means that some other instance is already running. 114 | 115 | #### `canConcurrentlySync(): boolean` 116 | 117 | Retuns true is current instance has lock or if the concurrency configuration allows concurrent instances to update IndexedDB (`concurrency.allowed = true`). 118 | 119 | ### Usage 120 | 121 | #### Target Depth Configuration 122 | 123 | ```ts 124 | NgrxStoreIdbModule.forRoot({ 125 | keys: [ 126 | { feature1: [{ slice11: ['slice11_1'], slice14: ['slice14_2'] }] }, 127 | { feature2: ['slice21'] } 128 | ], 129 | }); 130 | ``` 131 | In this example, `feature1.slice11.slice11_1`, `feature1.slice14.slice14_2`, and `feature2.slice21` will be synced to IndexedDB. -------------------------------------------------------------------------------- /projects/ngrx-store-idb/src/lib/ngrx-store-idb.metareducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, INIT, UPDATE } from '@ngrx/store'; 2 | import deepmerge from 'deepmerge'; 3 | import { createStore, set, UseStore } from 'idb-keyval'; 4 | import { rehydrateAction, RehydrateActionPayload, rehydrateErrorAction, rehydrateInitAction } from './ngrx-store-idb.actions'; 5 | import { KeyConfiguration, Keys, NgrxStoreIdbOptions, SAVED_STATE_KEY, SAVED_VERSION_KEY } from './ngrx-store-idb.options'; 6 | import { NgrxStoreIdbService } from './ngrx-store-idb.service'; 7 | 8 | /** 9 | * Default marshaller saves the whole store state 10 | */ 11 | export const defaultMarshaller = (state: any) => state; 12 | 13 | // Default merge strategy is a full deep merge. 14 | export const defaultUnmarshaller = (state: any, rehydratedState: any) => { 15 | const overwriteMerge = (destinationArray: any, sourceArray: any, options: any) => sourceArray; 16 | const options: deepmerge.Options = { 17 | arrayMerge: overwriteMerge, 18 | }; 19 | 20 | return deepmerge(state, rehydratedState, options); 21 | }; 22 | 23 | export const DEFAULT_OPTS: NgrxStoreIdbOptions = { 24 | rehydrate: true, 25 | saveOnChange: true, 26 | syncCondition: null, 27 | keys: null, 28 | unmarshaller: defaultUnmarshaller, 29 | marshaller: defaultMarshaller, 30 | debugInfo: true, 31 | version: 0, 32 | idb: { 33 | dbName: 'NgrxStoreIdb', 34 | storeName: 'Store', 35 | }, 36 | concurrency: { 37 | allowed: false, 38 | refreshRate: 5000, 39 | trackKey: 'ConcurrencyTimestamp', 40 | failInitialisationIfNoLock: false, 41 | }, 42 | }; 43 | 44 | 45 | // Recursively traverse all properties of the existing slice as defined by the `filter` argument, 46 | // and output the new object with extraneous properties removed. 47 | const createStateSlice = (existingSlice: any, keys: (string | number | KeyConfiguration)[]) => { 48 | return keys.reduce( 49 | (memo: { [x: string]: any; [x: number]: any }, attr: string | number | KeyConfiguration) => { 50 | if (typeof attr === 'string' || typeof attr === 'number') { 51 | const value = existingSlice?.[attr]; 52 | if (value !== undefined) { 53 | memo[attr] = value; 54 | } 55 | } else { 56 | for (const key in attr) { 57 | if (Object.prototype.hasOwnProperty.call(attr, key)) { 58 | const element = attr[key]; 59 | memo[key] = createStateSlice(existingSlice[key], element); 60 | } 61 | } 62 | } 63 | return memo; 64 | }, 65 | {}, 66 | ); 67 | }; 68 | 69 | /** 70 | * Marshalls actual state using keys 71 | */ 72 | const keysMarshaller = (state: any, keys: Keys) => { 73 | if (!state) { 74 | return state; 75 | } 76 | 77 | const res = {}; 78 | keys.forEach(key => { 79 | let name = key as string; 80 | // If key was a string then stateSlice has value and we are done 81 | let stateSlice = state[name]; 82 | 83 | // If key was a nested structure then we need to dig deeper 84 | if (typeof key === 'object') { 85 | // keys configuration always has only 1 attribute and the rest is nested 86 | name = Object.keys(key)[0]; 87 | stateSlice = state[name]; 88 | stateSlice = createStateSlice(stateSlice, key[name]); 89 | } 90 | 91 | if (stateSlice !== undefined) { 92 | res[name] = stateSlice; 93 | } 94 | }); 95 | 96 | return res; 97 | }; 98 | 99 | let lastSavedState = null; 100 | 101 | /** 102 | * Compare two objects if they are equal. 103 | * They are equal if both have keys with the same name 104 | * and the same value. 105 | */ 106 | const statesAreEqual = (prev: any, next: any): boolean => { 107 | if (prev === next) { 108 | return true; 109 | } 110 | 111 | if (!prev || !next) { 112 | return false; 113 | } 114 | 115 | if (typeof prev !== 'object' || typeof next !== 'object') { 116 | return false; 117 | } 118 | 119 | const prevSlices = Object.keys(prev); 120 | const nextSlices = Object.keys(next); 121 | if (prevSlices.length !== nextSlices.length) { 122 | return false; 123 | } 124 | 125 | for (const slice of prevSlices) { 126 | if (!statesAreEqual(prev[slice], next[slice])) { 127 | return false; 128 | } 129 | } 130 | 131 | return true; 132 | }; 133 | 134 | /** 135 | * Method used to save actual state into IndexedDB 136 | */ 137 | const syncStateUpdate = (state, action, opts: NgrxStoreIdbOptions, idbStore: UseStore, service: NgrxStoreIdbService) => { 138 | if (!service.canConcurrentlySync()) { 139 | if (opts.debugInfo) { 140 | console.debug('NgrxStoreIdb: State will not be persisted. Application runs also in other tab/window.'); 141 | } 142 | return; 143 | } 144 | 145 | if (opts.syncCondition) { 146 | try { 147 | if (opts.syncCondition(state, action) !== true) { 148 | if (opts.debugInfo) { 149 | console.debug('NgrxStoreIdb: State will not be persisted. syncCondition is false'); 150 | } 151 | return; 152 | } 153 | } catch (e) { 154 | // Treat TypeError as do not sync 155 | if (e instanceof TypeError) { 156 | if (opts.debugInfo) { 157 | console.debug('NgrxStoreIdb: State will not be persisted. syncCondition has error', e); 158 | } 159 | return; 160 | } 161 | console.error('NgrxStoreIdb: syncCondition raised error', e); 162 | throw e; 163 | } 164 | } 165 | 166 | let marshalledState = {}; 167 | 168 | if (opts.keys) { 169 | marshalledState = keysMarshaller(state, opts.keys); 170 | } else { 171 | marshalledState = opts.marshaller(state); 172 | } 173 | 174 | if (opts.saveOnChange && statesAreEqual(lastSavedState, marshalledState)) { 175 | if (opts.debugInfo) { 176 | console.debug('NgrxStoreIdb: No change in state. Will skip saving to IndexedDB.'); 177 | } 178 | return; 179 | } 180 | 181 | set(SAVED_STATE_KEY, marshalledState, idbStore) 182 | .then(() => { 183 | lastSavedState = marshalledState; 184 | service.broadcastSyncEvent(action, true); 185 | if (opts.debugInfo) { 186 | console.debug('NgrxStoreIdb: Store state persisted to IndexedDB', marshalledState, action); 187 | } 188 | 189 | return set(SAVED_VERSION_KEY, opts.version, idbStore).then(() => { 190 | if (opts.debugInfo) { 191 | console.debug('NgrxStoreIdb: Store version persisted to IndexedDb.', opts.version, action); 192 | } 193 | }); 194 | }) 195 | .catch(err => { 196 | if (opts.debugInfo) { 197 | console.error('NgrxStoreIdb: Error storing state to IndexedDB', err, action); 198 | } 199 | service.broadcastSyncEvent(action, false); 200 | }); 201 | }; 202 | 203 | /** 204 | * This is the main factory that creates our metareducer. 205 | */ 206 | 207 | export const metaReducerFactoryWithOptions = (options: NgrxStoreIdbOptions, idbStore: UseStore, service: NgrxStoreIdbService) => { 208 | let rehydratedState = null; 209 | return (reducer: ActionReducer) => (state: any, action) => { 210 | let nextState: any; 211 | 212 | if (options.debugInfo) { 213 | console.group('NgrxStoreIdb: metareducer', state, action); 214 | } 215 | 216 | // If we are processing rehydrateAction then save rehydrated state (for later use). 217 | // There is no other reducer for this action. 218 | if (action.type === rehydrateAction.type) { 219 | const payload = action as RehydrateActionPayload; 220 | rehydratedState = payload.rehydratedState || {}; 221 | if (!payload.rehydratedState) { 222 | if (options.debugInfo) { 223 | console.debug('NgrxStoreIdb: Rehydrated state is empty - nothing to rehydrate.'); 224 | console.groupEnd(); 225 | } 226 | return state; 227 | } 228 | } 229 | 230 | // If action is rehydrateAction (i.e. initial rehydratation) 231 | // then merge the store state with the rehydrated state 232 | if (action.type === rehydrateAction.type) { 233 | nextState = options.unmarshaller(state, rehydratedState); 234 | if (options.debugInfo) { 235 | console.debug('NgrxStoreIdb: After rehydrating current state', nextState); 236 | } 237 | } else { 238 | // Run normal reducer for this action 239 | nextState = reducer(state, action); 240 | } 241 | 242 | // If action is UPDATE then rehydrate feature slices just created (when lazy module store loads) 243 | if (action.type === UPDATE && action.features && rehydratedState) { 244 | const rehydratedStateCopy = {}; 245 | for (const feature of action.features) { 246 | if (rehydratedState[feature]) { 247 | rehydratedStateCopy[feature] = rehydratedState[feature]; 248 | } 249 | } 250 | nextState = options.unmarshaller(nextState, rehydratedStateCopy); 251 | if (options.debugInfo) { 252 | console.debug('NgrxStoreIdb: After rehydrating current state', nextState); 253 | } 254 | } 255 | 256 | if (action.type !== INIT && 257 | action.type !== UPDATE && 258 | action.type !== rehydrateInitAction.type && 259 | action.type !== rehydrateAction.type && 260 | action.type !== rehydrateErrorAction.type && 261 | // If rehydrating is requested then don't sync until the saved state was loaded first 262 | (rehydratedState || !options.rehydrate)) { 263 | if (options.debugInfo) { 264 | console.debug('NgrxStoreIdb: Try to persist state into IndexedDB', nextState, action); 265 | } 266 | syncStateUpdate(nextState, action, options, idbStore, service); 267 | } 268 | 269 | if (options.debugInfo) { 270 | console.groupEnd(); 271 | } 272 | 273 | return nextState; 274 | }; 275 | }; 276 | 277 | export const optionsFactory = (options: Partial) => { 278 | if (options.keys && (options.unmarshaller || options.marshaller)) { 279 | throw new Error('NgrxStoreIdb: define keys or unmarshaller+marshaller but not both!'); 280 | } 281 | if (!!options.marshaller !== !!options.unmarshaller) { 282 | throw new Error('NgrxStoreIdb: define unmarshaller and marshaller!'); 283 | } 284 | const opts = deepmerge(DEFAULT_OPTS, options); 285 | if (opts.debugInfo) { 286 | console.info('NgrxStoreIdbModule: Using the following options', { 287 | ...opts, 288 | marshaller: opts.marshaller === defaultMarshaller ? 'default marshaller' : 'custom marshaller', 289 | unmarshaller: opts.unmarshaller === defaultUnmarshaller ? 'default unmarshaller' : 'custom unmarshaller', 290 | }); 291 | } 292 | return opts; 293 | }; 294 | 295 | export const idbStoreFactory = (opts: NgrxStoreIdbOptions) => { 296 | return createStore(opts.idb.dbName, opts.idb.storeName); 297 | }; 298 | 299 | export const ngrxStoreIdbServiceInitializer = (opts: NgrxStoreIdbOptions, service: NgrxStoreIdbService) => { 300 | return (): Promise => { 301 | return service.onLockAcquired().toPromise().then(hasLock => new Promise((resolve, reject) => { 302 | if (hasLock || !opts.concurrency.failInitialisationIfNoLock) { 303 | resolve(true); 304 | } else { 305 | reject('Can not acquire master lock. Another tab/window is open?'); 306 | } 307 | })); 308 | }; 309 | } 310 | --------------------------------------------------------------------------------