Skip to content

Commit

Permalink
feat(app-state): add translator param to PersistentStore
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed Dec 18, 2021
1 parent e3ca8a2 commit 5a008f7
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 52 deletions.
2 changes: 1 addition & 1 deletion projects/app-state/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
129 changes: 86 additions & 43 deletions projects/app-state/src/lib/utils/persistent-store.spec.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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<MyState> {
constructor() {
super('myPersistenceKey', new MyState(), new MigrationManager());
class MyStore extends PersistentStore<MyState> {
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<MyState> {
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<MyState> {
constructor() {
super();
this.registerMigration(1, this.#migrateFromV1);
class MyStore extends PersistentStore<MyState> {
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<MyState, 'sessionStart'>;

class MyStore extends PersistentStore<MyState> {
constructor() {
// pass in our new `MyMigrationManager`
super('myPersistenceKey', new MyState(), new MyMigrationManager());
class MyTranslator implements PersistenceTranslator<MyState, Persisted> {
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<MyState, Persisted> {
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);
}));
});
});
82 changes: 74 additions & 8 deletions projects/app-state/src/lib/utils/persistent-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<State, Persisted> {
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<MyState> {
* constructor() {
* super('myPersistenceKey', new MyState(), new MigrationManager());
* super('myPersistenceKey', new MyState());
* }
* }
*
Expand Down Expand Up @@ -52,31 +59,90 @@ import { RootStore } from '../root-store';
* class MyStore extends PersistentStore<MyState> {
* constructor() {
* // pass in our new `MyMigrationManager`
* super('myPersistenceKey', new MyState(), new MyMigrationManager());
* super('myPersistenceKey', new MyState(), {
* migrator: new MyMigrationManager(),
* });
* }
* }
*
* // 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');
* ```
*
* 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<MyState, 'sessionStart'>;
*
* class MyTranslator implements PersistenceTranslator<MyState, Persisted> {
* toState(right: Persisted): MyState {
* return { ...right, sessionStart: Date.now() };
* }
*
* toPersisted(left: MyState): Persisted {
* return omit(left, 'sessionStart');
* }
* }
*
* class MyStore extends PersistentStore<MyState, Persisted> {
* 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<T extends VersionedObject> extends RootStore<T> {
export class PersistentStore<
State extends VersionedObject,
Persisted extends VersionedObject = State,
> extends RootStore<State> {
// 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<T>,
defaultState: State,
{
migrator = new MigrationManager<Persisted>(),
translator = new IdentityTranslator() as PersistenceTranslator<
State,
Persisted
>,
} = {},
) {
const persistence = new Persistence<T>(persistenceKey);
super(migrator.run(persistence, defaultState));
const persistence = new Persistence<Persisted>(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<T> implements PersistenceTranslator<T, T> {
toState = identity;
toPersisted = identity;
}
6 changes: 6 additions & 0 deletions projects/integration/src/app/api-tests/app-state.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
PersistenceTranslator,
PersistentStore,
pushToStoreArray,
RootStore,
Expand All @@ -7,8 +8,13 @@ import {
Store,
UndoManager,
} from '@s-libs/app-state';
import { expectTypeOf } from 'expect-type';

describe('app-state', () => {
it('has PersistenceTranslator', () => {
expectTypeOf<PersistenceTranslator<number, string>>();
});

it('has PersistentStore', () => {
expect(PersistentStore).toBeDefined();
});
Expand Down

0 comments on commit 5a008f7

Please sign in to comment.