Skip to content

Commit

Permalink
feat(app-state): add PersistentStore
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed Dec 18, 2021
1 parent f5c029d commit f85fc6f
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 25 deletions.
1 change: 1 addition & 0 deletions projects/app-state/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { PersistentStore } from './persistent-store';
export { pushToStoreArray } from './push-to-store-array';
export { spreadArrayStore$ } from './spread-array-store';
export { spreadObjectStore$ } from './spread-object-store';
Expand Down
67 changes: 67 additions & 0 deletions projects/app-state/src/lib/utils/persistent-store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { MigrationManager, VersionedObject } from '@s-libs/js-core';
import { PersistentStore } from './persistent-store';

describe('PersistentStore', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
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';
}

class MyStore extends PersistentStore<MyState> {
constructor() {
super('myPersistenceKey', new MyState(), new MigrationManager());
}
}

let store = new MyStore();
store('my_state_key').set('my new value');

// the user leaves the page and comes back later ...

store = new MyStore();
expect(store.state().my_state_key).toBe('my new value');
});

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);
}

#migrateFromV1(oldState: any): any {
return { _version: 2, myStateKey: oldState.my_state_key };
}
}

class MyStore extends PersistentStore<MyState> {
constructor() {
// pass in our new `MyMigrationManager`
super('myPersistenceKey', new MyState(), 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');
});
});
82 changes: 82 additions & 0 deletions projects/app-state/src/lib/utils/persistent-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
MigrationManager,
Persistence,
VersionedObject,
} from '@s-libs/js-core';
import { skip } from 'rxjs/operators';
import { RootStore } from '../root-store';

/**
* 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;
* my_state_key = 'my state value';
* }
*
* class MyStore extends PersistentStore<MyState> {
* constructor() {
* super('myPersistenceKey', new MyState(), new MigrationManager());
* }
* }
*
* let store = new MyStore();
* store('my_state_key').set('my new value');
*
* // the user leaves the page and comes back later ...
*
* store = new MyStore();
* expect(store.state().my_state_key).toBe('my new value');
* ```
*
* Later when you want to change the schema of the state, it's time to take advantage of the `{@link MigrationManager}:
*
* ```ts
* 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 };
* }
* }
*
* class MyStore extends PersistentStore<MyState> {
* constructor() {
* // pass in our new `MyMigrationManager`
* super('myPersistenceKey', new MyState(), 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');
* ```
*/
export class PersistentStore<T extends VersionedObject> extends RootStore<T> {
/**
* @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
*/
constructor(
persistenceKey: string,
defaultState: T,
migrator: MigrationManager<T>,
) {
const persistence = new Persistence<T>(persistenceKey);
super(migrator.run(persistence, defaultState));

this.$.pipe(skip(1)).subscribe((state) => {
persistence.put(state);
});
}
}
41 changes: 22 additions & 19 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 {
PersistentStore,
pushToStoreArray,
RootStore,
spreadArrayStore$,
Expand All @@ -8,29 +9,31 @@ import {
} from '@s-libs/app-state';

describe('app-state', () => {
describe('public API', () => {
it('has RootStore', () => {
expect(RootStore).toBeDefined();
});
it('has PersistentStore', () => {
expect(PersistentStore).toBeDefined();
});

it('has Store', () => {
expect(Store).toBeDefined();
});
it('has RootStore', () => {
expect(RootStore).toBeDefined();
});

it('has UndoManager', () => {
expect(UndoManager).toBeDefined();
});
it('has Store', () => {
expect(Store).toBeDefined();
});

it('has pushToStoreArray', () => {
expect(pushToStoreArray).toBeDefined();
});
it('has UndoManager', () => {
expect(UndoManager).toBeDefined();
});

it('has spreadArrayStore$', () => {
expect(spreadArrayStore$).toBeDefined();
});
it('has pushToStoreArray', () => {
expect(pushToStoreArray).toBeDefined();
});

it('has spreadArrayStore$', () => {
expect(spreadArrayStore$).toBeDefined();
});

it('has spreadObjectStore$', () => {
expect(spreadObjectStore$).toBeDefined();
});
it('has spreadObjectStore$', () => {
expect(spreadObjectStore$).toBeDefined();
});
});
12 changes: 6 additions & 6 deletions projects/js-core/src/lib/migration-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export type MigrateFunction<T> = (source: T, targetVersion: number) => T;
* ```
*/
export class MigrationManager<T extends VersionedObject> {
private migrations = new Map<number | undefined, MigrateFunction<T>>();
#migrations = new Map<number | undefined, MigrateFunction<T>>();

/**
* Returns the value from `persistence`, upgraded to match the version in `defaultValue`. If `persistence` was empty, returns `defaultValue` directly. Updates `peristence` to reflect the returned value.
Expand Down Expand Up @@ -76,10 +76,10 @@ export class MigrationManager<T extends VersionedObject> {
*/
upgrade(object: T, targetVersion: number): T {
let lastVersion = object._version;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- we don't assume as much type safety here, since it may need migration first comply!
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- we don't assume as much type safety here, since it may need migration to comply!
assert(lastVersion === undefined || lastVersion <= targetVersion);
while (lastVersion !== targetVersion) {
object = this.upgradeOneStep(object, targetVersion);
object = this.#upgradeOneStep(object, targetVersion);
const newVersion = object._version;
if (lastVersion) {
assert(
Expand Down Expand Up @@ -121,7 +121,7 @@ export class MigrationManager<T extends VersionedObject> {
sourceVersion: number | undefined,
migrateFunction: MigrateFunction<T>,
): void {
this.migrations.set(sourceVersion, migrateFunction.bind(this));
this.#migrations.set(sourceVersion, migrateFunction.bind(this));
}

/**
Expand All @@ -138,9 +138,9 @@ export class MigrationManager<T extends VersionedObject> {
throw error;
}

private upgradeOneStep(upgradable: T, targetVersion: number): T {
#upgradeOneStep(upgradable: T, targetVersion: number): T {
const version = upgradable._version;
const migrationFunction = this.migrations.get(version);
const migrationFunction = this.#migrations.get(version);
if (!migrationFunction) {
throw new Error(`Unable to migrate from version ${version}`);
}
Expand Down

0 comments on commit f85fc6f

Please sign in to comment.