diff --git a/projects/app-state/src/lib/utils/index.ts b/projects/app-state/src/lib/utils/index.ts index e28111dd..b116b326 100644 --- a/projects/app-state/src/lib/utils/index.ts +++ b/projects/app-state/src/lib/utils/index.ts @@ -1,4 +1,4 @@ -export { PersistentStore } from './persistent-store'; +export { PersistentStore, PersistenceTranslator } from './persistent-store'; export { pushToStoreArray } from './push-to-store-array'; export { spreadArrayStore$ } from './spread-array-store'; export { spreadObjectStore$ } from './spread-object-store'; diff --git a/projects/app-state/src/lib/utils/persistent-store.spec.ts b/projects/app-state/src/lib/utils/persistent-store.spec.ts index 9574a2b4..30a314d0 100644 --- a/projects/app-state/src/lib/utils/persistent-store.spec.ts +++ b/projects/app-state/src/lib/utils/persistent-store.spec.ts @@ -1,5 +1,7 @@ +import { fakeAsync, tick } from '@angular/core/testing'; import { MigrationManager, VersionedObject } from '@s-libs/js-core'; -import { PersistentStore } from './persistent-store'; +import { omit } from '@s-libs/micro-dash'; +import { PersistenceTranslator, PersistentStore } from './persistent-store'; describe('PersistentStore', () => { beforeEach(() => { @@ -9,59 +11,100 @@ describe('PersistentStore', () => { localStorage.clear(); }); - it('works for the first example in the docs', () => { - class MyState implements VersionedObject { - _version = 1; - // eslint-disable-next-line camelcase - my_state_key = 'my state value'; - } + describe('documentation', () => { + it('is working for the simple example', () => { + class MyState implements VersionedObject { + _version = 1; + // eslint-disable-next-line camelcase -- will fix in next version + my_state_key = 'my state value'; + } - class MyStore extends PersistentStore { - constructor() { - super('myPersistenceKey', new MyState(), new MigrationManager()); + class MyStore extends PersistentStore { + constructor() { + super('myPersistenceKey', new MyState()); + } } - } - let store = new MyStore(); - store('my_state_key').set('my new value'); + let store = new MyStore(); + store('my_state_key').set('my new value'); - // the user leaves the page and comes back later ... + // the user leaves the page and comes back later ... - store = new MyStore(); - expect(store.state().my_state_key).toBe('my new value'); - }); + store = new MyStore(); + expect(store.state().my_state_key).toBe('my new value'); + }); + + it('is working for the migration example', () => { + localStorage.setItem( + 'myPersistenceKey', + '{ "_version": 1, "my_state_key": "my new value" }', + ); + + class MyState implements VersionedObject { + _version = 2; // bump version to 2 + myStateKey = 'my state value'; // schema change: my_state_key => myStateKey + } + + class MyMigrationManager extends MigrationManager { + constructor() { + super(); + this.registerMigration(1, this.#migrateFromV1); + } + + #migrateFromV1(oldState: any): any { + return { _version: 2, myStateKey: oldState.my_state_key }; + } + } - it('works for the second example in the docs', () => { - localStorage.setItem( - 'myPersistenceKey', - '{ "_version": 1, "my_state_key": "my new value" }', - ); - - class MyState implements VersionedObject { - _version = 2; // bump version to 2 - myStateKey = 'my state value'; // schema change: my_state_key => myStateKey - } - - class MyMigrationManager extends MigrationManager { - constructor() { - super(); - this.registerMigration(1, this.#migrateFromV1); + class MyStore extends PersistentStore { + constructor() { + // pass in our new `MyMigrationManager` + super('myPersistenceKey', new MyState(), { + migrator: new MyMigrationManager(), + }); + } } - #migrateFromV1(oldState: any): any { - return { _version: 2, myStateKey: oldState.my_state_key }; + // the store gets the value persisted from version 1 in our previous example + const store = new MyStore(); + expect(store.state().myStateKey).toBe('my new value'); + }); + + it('is working for the translator example', fakeAsync(() => { + class MyState implements VersionedObject { + _version = 1; + sessionStart = Date.now(); } - } + type Persisted = Omit; - class MyStore extends PersistentStore { - constructor() { - // pass in our new `MyMigrationManager` - super('myPersistenceKey', new MyState(), new MyMigrationManager()); + class MyTranslator implements PersistenceTranslator { + toState(right: Persisted): MyState { + return { ...right, sessionStart: Date.now() }; + } + + toPersisted(left: MyState): Persisted { + return omit(left, 'sessionStart'); + } } - } - // the store gets the value persisted from version 1 in our previous example - const store = new MyStore(); - expect(store.state().myStateKey).toBe('my new value'); + class MyStore extends PersistentStore { + constructor() { + super('myPersistenceKey', new MyState(), { + translator: new MyTranslator(), + }); + } + } + + const session1Start = Date.now(); + let store = new MyStore(); + expect(store.state().sessionStart).toBe(session1Start); + expect(localStorage.getItem('myPersistenceKey')).toBe('{"_version":1}'); + + // the user leaves the page and comes back later... + + tick(300_000); // 5 minutes pass + store = new MyStore(); + expect(store.state().sessionStart).toBe(session1Start + 300_000); + })); }); }); diff --git a/projects/app-state/src/lib/utils/persistent-store.ts b/projects/app-state/src/lib/utils/persistent-store.ts index bad86c01..a64f4444 100644 --- a/projects/app-state/src/lib/utils/persistent-store.ts +++ b/projects/app-state/src/lib/utils/persistent-store.ts @@ -3,21 +3,28 @@ import { Persistence, VersionedObject, } from '@s-libs/js-core'; +import { identity } from '@s-libs/micro-dash'; import { skip } from 'rxjs/operators'; import { RootStore } from '../root-store'; +export interface PersistenceTranslator { + toState: (right: Persisted) => State; + toPersisted: (left: State) => Persisted; +} + /** * A store that is automatically saved to and restored from local storage. This is suitable for small stores that can very quickly be (de)serialized to/from JSON without any noticeable delay. * * ```ts * class MyState implements VersionedObject { * _version = 1; + * // eslint-disable-next-line camelcase -- will fix in next version * my_state_key = 'my state value'; * } * * class MyStore extends PersistentStore { * constructor() { - * super('myPersistenceKey', new MyState(), new MigrationManager()); + * super('myPersistenceKey', new MyState()); * } * } * @@ -52,7 +59,9 @@ import { RootStore } from '../root-store'; * class MyStore extends PersistentStore { * constructor() { * // pass in our new `MyMigrationManager` - * super('myPersistenceKey', new MyState(), new MyMigrationManager()); + * super('myPersistenceKey', new MyState(), { + * migrator: new MyMigrationManager(), + * }); * } * } * @@ -60,23 +69,80 @@ import { RootStore } from '../root-store'; * const store = new MyStore(); * expect(store.state().myStateKey).toBe('my new value'); * ``` + * + * If you want to persist something a little different from what is in the store, for example to omit some properties, use a {@link Translator}: + * + * ```ts + * class MyState implements VersionedObject { + * _version = 1; + * sessionStart = Date.now(); + * } + * type Persisted = Omit; + * + * class MyTranslator implements PersistenceTranslator { + * toState(right: Persisted): MyState { + * return { ...right, sessionStart: Date.now() }; + * } + * + * toPersisted(left: MyState): Persisted { + * return omit(left, 'sessionStart'); + * } + * } + * + * class MyStore extends PersistentStore { + * constructor() { + * super('myPersistenceKey', new MyState(), { + * translator: new MyTranslator(), + * }); + * } + * } + * + * const session1Start = Date.now(); + * let store = new MyStore(); + * expect(store.state().sessionStart).toBe(session1Start); + * expect(localStorage.getItem('myPersistenceKey')).toBe('{"_version":1}'); + * + * // the user leaves the page and comes back later... + * + * tick(300_000); // 5 minutes pass + * store = new MyStore(); + * expect(store.state().sessionStart).toBe(session1Start + 300_000); + * ``` */ -export class PersistentStore extends RootStore { +export class PersistentStore< + State extends VersionedObject, + Persisted extends VersionedObject = State, +> extends RootStore { + // TODO: see if this works for the docs /** * @param persistenceKey the key in local storage at which to persist the state * @param defaultState used when the state has not been persisted yet * @param migrator used to update the state when it was at a lower {@link VersionedObject._version} when it was last persisted + * @param translator use to persist a different format than what is kept in the store */ constructor( persistenceKey: string, - defaultState: T, - migrator: MigrationManager, + defaultState: State, + { + migrator = new MigrationManager(), + translator = new IdentityTranslator() as PersistenceTranslator< + State, + Persisted + >, + } = {}, ) { - const persistence = new Persistence(persistenceKey); - super(migrator.run(persistence, defaultState)); + const persistence = new Persistence(persistenceKey); + const defaultPersisted = translator.toPersisted(defaultState); + const persisted = migrator.run(persistence, defaultPersisted); + super(translator.toState(persisted)); this.$.pipe(skip(1)).subscribe((state) => { - persistence.put(state); + persistence.put(translator.toPersisted(state)); }); } } + +class IdentityTranslator implements PersistenceTranslator { + toState = identity; + toPersisted = identity; +} diff --git a/projects/integration/src/app/api-tests/app-state.spec.ts b/projects/integration/src/app/api-tests/app-state.spec.ts index 2c45cadf..da1f41c7 100644 --- a/projects/integration/src/app/api-tests/app-state.spec.ts +++ b/projects/integration/src/app/api-tests/app-state.spec.ts @@ -1,4 +1,5 @@ import { + PersistenceTranslator, PersistentStore, pushToStoreArray, RootStore, @@ -7,8 +8,13 @@ import { Store, UndoManager, } from '@s-libs/app-state'; +import { expectTypeOf } from 'expect-type'; describe('app-state', () => { + it('has PersistenceTranslator', () => { + expectTypeOf>(); + }); + it('has PersistentStore', () => { expect(PersistentStore).toBeDefined(); });