diff --git a/README.md b/README.md index 58972d9..422261b 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,74 @@ persist('some', someStore, { - **jsonify** *bool* Enables serialization as JSON (default: `true`). - **whitelist** *Array\* Only these keys will be persisted (defaults to all keys). - **blacklist** *Array\* These keys will not be persisted (defaults to all keys). + - **transforms** *Array\<[Transform](#transforms)\>* [Transforms](#transforms) to apply to snapshots on the way to and from storage. - returns a void Promise +### Transforms + +Transforms allow you to customize the [snapshot](https://github.com/mobxjs/mobx-state-tree#snapshots) that is persisted and used to hydrate your store. + +Transforms are `object`s with `toStorage` and `fromStorage` functions that are called with a `snapshot`-like argument and expected to return a `snapshot`-like object: + +```typescript +interface ITransform { + readonly toStorage?: ITransformArgs, + readonly fromStorage?: ITransformArgs +} +interface ITransformArgs { + (snapshot: StrToAnyMap): StrToAnyMap +} +type StrToAnyMap = {[key: string]: any} +``` + +You can create your own transforms to serve a variety of needs. +For example, if you wanted to only store the most recent posts: + +```typescript +import { persist, ITransform } from 'mst-persist' + +import { FeedStore } from '../stores' + +const feedStore = FeedStore.create() + +const twoDaysAgo = new Date() +twoDaysAgo.setDate(twoDaysAgo.getDate() - 2) + +const onlyRecentPosts: ITransform = { + toStorage: (snapshot) => { + snapshot.posts = snapshot.posts.filter( + // note that a snapshotted Date is a string + post => new Date(post.date) > twoDaysAgo + ) + return snapshot + } +} + +persist('feed', feedStore, { + transforms: [onlyRecentPosts] +}) +``` + +For some other examples, one may see how [whitelists](https://github.com/agilgur5/mst-persist/blob/9ba76aaf455f42e249dc855d66349351148a17da/src/whitelistTransform.ts#L7-L12) and [blacklists](https://github.com/agilgur5/mst-persist/blob/9ba76aaf455f42e249dc855d66349351148a17da/src/blacklistTransform.ts#L7-L12) are implemented internally as transforms, as well as how the [transform test fixtures](https://github.com/agilgur5/mst-persist/blob/d3aa4476f92a087c882dccf8530a37096d8c64ed/test/fixtures.ts#L19-L34) are implemented internally. + +#### Transform Ordering + +`toStorage` functions are called serially in the order specified in the `transforms` configuration array. +`fromStorage` functions are called in the reverse order, such that the last transform is first. + +Before any `toStorage` functions are run, the snapshot will first be stripped of any keys as specified by the `whitelist` and `blacklist` configuration. +Then, once the `toStorage` functions are all run, the object will be serialized to JSON, if that configuration is enabled. + +Before any `fromStorage` functions are run, the JSON will be deserialized into an object, if that configuration is enabled. + +To put this visually with some pseudo-code: + +```text +onSnapshot -> whitelist -> blacklist -> transforms toStorage -> JSON.stringify -> Storage.setItem +Storage.getItem -> JSON.parse -> transforms.reverse() fromStorage -> applySnapshot +``` + ### Node and Server-Side Rendering (SSR) Usage Node environments are supported so long as you configure a Storage Engine that supports Node, such as [`redux-persist-node-storage`](https://github.com/pellejacobs/redux-persist-node-storage), [`redux-persist-cookie-storage`](https://github.com/abersager/redux-persist-cookie-storage), etc. @@ -88,8 +153,8 @@ Can view the commit that implements it [here](https://github.com/agilgur5/react- ## How it works -Basically just a small wrapper around MST's [`onSnapshot` and `applySnapshot`](https://github.com/mobxjs/mobx-state-tree#snapshots). -The source code is currently shorter than this README, so [take a look under the hood](https://github.com/agilgur5/mst-persist/tree/master/src)! :) +Basically a small wrapper around MST's [`onSnapshot` and `applySnapshot`](https://github.com/mobxjs/mobx-state-tree#snapshots). +The source code is roughly the size of this README, so [take a look under the hood](https://github.com/agilgur5/mst-persist/tree/master/src)! :) ## Credits diff --git a/src/index.ts b/src/index.ts index e932123..c8a2381 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import { onSnapshot, applySnapshot, IStateTreeNode } from 'mobx-state-tree' import AsyncLocalStorage from './asyncLocalStorage' +import { ITransform, whitelistKeys, blacklistKeys } from './transforms/index' +import { StrToAnyMap } from './utils' export interface IArgs { (name: string, store: IStateTreeNode, options?: IOptions): Promise @@ -9,12 +11,13 @@ export interface IOptions { storage?: any, jsonify?: boolean, readonly whitelist?: Array, - readonly blacklist?: Array + readonly blacklist?: Array, + readonly transforms?: Array } -type StrToAnyMap = {[key: string]: any} +export { ITransform, ITransformArgs } from './transforms/index' export const persist: IArgs = (name, store, options = {}) => { - let {storage, jsonify = true, whitelist, blacklist} = options + let {storage, jsonify = true, whitelist, blacklist, transforms = []} = options // use AsyncLocalStorage by default (or if localStorage was passed in) if ( @@ -30,19 +33,19 @@ export const persist: IArgs = (name, store, options = {}) => { 'engine via the `storage:` option.') } - const whitelistDict = arrToDict(whitelist) - const blacklistDict = arrToDict(blacklist) + // whitelist, blacklist, then any custom transforms + transforms = [ + ...(whitelist ? [whitelistKeys(whitelist)] : []), + ...(blacklist ? [blacklistKeys(blacklist)] : []), + ...transforms + ] onSnapshot(store, (_snapshot: StrToAnyMap) => { // need to shallow clone as otherwise properties are non-configurable (https://github.com/agilgur5/mst-persist/pull/21#discussion_r348105595) const snapshot = { ..._snapshot } - Object.keys(snapshot).forEach((key) => { - if (whitelist && !whitelistDict[key]) { - delete snapshot[key] - } - if (blacklist && blacklistDict[key]) { - delete snapshot[key] - } + + transforms.forEach((transform) => { + if (transform.toStorage) { transform.toStorage(snapshot) } }) const data = !jsonify ? snapshot : JSON.stringify(snapshot) @@ -54,18 +57,14 @@ export const persist: IArgs = (name, store, options = {}) => { const snapshot = !isString(data) ? data : JSON.parse(data) // don't apply falsey (which will error), leave store in initial state if (!snapshot) { return } - applySnapshot(store, snapshot) - }) -} -type StrToBoolMap = {[key: string]: boolean} + // in reverse order, like a stack, so that last transform is first + transforms.slice().reverse().forEach((transform) => { + if (transform.fromStorage) { transform.fromStorage(snapshot) } + }) -function arrToDict (arr?: Array): StrToBoolMap { - if (!arr) { return {} } - return arr.reduce((dict: StrToBoolMap, elem) => { - dict[elem] = true - return dict - }, {}) + applySnapshot(store, snapshot) + }) } function isString (value: any): value is string { diff --git a/src/transforms/blacklist.ts b/src/transforms/blacklist.ts new file mode 100644 index 0000000..6d985a9 --- /dev/null +++ b/src/transforms/blacklist.ts @@ -0,0 +1,12 @@ +import { ITransform, arrToDict } from './utils' + +export function blacklistKeys (blacklist: Array): ITransform { + const blacklistDict = arrToDict(blacklist) + + return {toStorage: function blacklistTransform (snapshot) { + Object.keys(snapshot).forEach((key) => { + if (blacklistDict[key]) { delete snapshot[key] } + }) + return snapshot + }} +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts new file mode 100644 index 0000000..a30e68e --- /dev/null +++ b/src/transforms/index.ts @@ -0,0 +1,4 @@ +export { ITransform, ITransformArgs } from './utils' + +export { whitelistKeys } from './whitelist' +export { blacklistKeys } from './blacklist' diff --git a/src/transforms/utils.ts b/src/transforms/utils.ts new file mode 100644 index 0000000..7943e9b --- /dev/null +++ b/src/transforms/utils.ts @@ -0,0 +1,18 @@ +import { StrToAnyMap } from '../utils' + +export interface ITransform { + readonly toStorage?: ITransformArgs, + readonly fromStorage?: ITransformArgs +} +export interface ITransformArgs { + (snapshot: StrToAnyMap): StrToAnyMap +} + +type StrToBoolMap = {[key: string]: boolean} + +export function arrToDict (arr: Array): StrToBoolMap { + return arr.reduce((dict: StrToBoolMap, elem) => { + dict[elem] = true + return dict + }, {}) +} diff --git a/src/transforms/whitelist.ts b/src/transforms/whitelist.ts new file mode 100644 index 0000000..3df87df --- /dev/null +++ b/src/transforms/whitelist.ts @@ -0,0 +1,12 @@ +import { ITransform, arrToDict } from './utils' + +export function whitelistKeys (whitelist: Array): ITransform { + const whitelistDict = arrToDict(whitelist) + + return {toStorage: function whitelistTransform (snapshot) { + Object.keys(snapshot).forEach((key) => { + if (!whitelistDict[key]) { delete snapshot[key] } + }) + return snapshot + }} +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f505a07 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1 @@ +export type StrToAnyMap = {[key: string]: any} diff --git a/test/fixtures.ts b/test/fixtures.ts index 1b110a1..f447883 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,5 +1,7 @@ import { types } from 'mobx-state-tree' +import { ITransform, ITransformArgs } from '../src/index' + export const UserStoreF = types.model('UserStore', { name: 'John Doe', age: 32 @@ -13,3 +15,19 @@ export const persistedDataF = { name: 'Persisted Name', age: 35 } + +function changeName (name: string) { + const changeNameTransform: ITransformArgs = function (snapshot) { + snapshot.name = name + return snapshot + } + return changeNameTransform +} + +export function storeNameAsF (name: string): ITransform { + return {toStorage: changeName(name)} +} + +export function retrieveNameAsF (name: string): ITransform { + return {fromStorage: changeName(name)} +} diff --git a/test/index.spec.ts b/test/index.spec.ts index ac0561f..fb926be 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -2,13 +2,17 @@ import { getSnapshot } from 'mobx-state-tree' import { persist } from '../src/index' -import { UserStoreF, persistedDataF } from './fixtures' +import { UserStoreF, persistedDataF, storeNameAsF, retrieveNameAsF } from './fixtures' function getItem(key: string) { const item = window.localStorage.getItem(key) return item ? JSON.parse(item) : null // can only parse strings } +function setItem(key: string, value: object) { + return window.localStorage.setItem(key, JSON.stringify(value)) +} + describe('basic persist functionality', () => { beforeEach(() => window.localStorage.clear()) @@ -28,15 +32,16 @@ describe('basic persist functionality', () => { }) it('should load persisted data', async () => { - window.localStorage.setItem('user', JSON.stringify(persistedDataF)) + setItem('user', persistedDataF) const user = UserStoreF.create() await persist('user', user) + expect(getSnapshot(user)).toStrictEqual(persistedDataF) }) }) -describe('persist options', () => { +describe('basic persist options', () => { beforeEach(() => window.localStorage.clear()) it('shouldn\'t jsonify', async () => { @@ -74,3 +79,30 @@ describe('persist options', () => { expect(getItem('user')).toStrictEqual(snapshot) }) }) + +describe('transforms', () => { + beforeEach(() => window.localStorage.clear()) + + it('should apply toStorage transforms in order', async () => { + const user = UserStoreF.create() + await persist('user', user, { + transforms: [storeNameAsF('Jack'), storeNameAsF('Joe')] + }) + + user.changeName('Not Joe') // fire action to trigger onSnapshot + expect(getItem('user').name).toBe('Joe') + }) + + it('should apply fromStorage transforms in reverse order', async () => { + const persistedData = {...persistedDataF} + persistedData.name = 'Not Joe' + setItem('user', persistedData) + + const user = UserStoreF.create() + await persist('user', user, { + transforms: [retrieveNameAsF('Joe'), retrieveNameAsF('Jack')] + }) + + expect(getSnapshot(user).name).toBe('Joe') + }) +})