Skip to content
69 changes: 67 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,74 @@ persist('some', someStore, {
- **jsonify** *bool* Enables serialization as JSON (default: `true`).
- **whitelist** *Array\<string\>* Only these keys will be persisted (defaults to all keys).
- **blacklist** *Array\<string\>* 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.
Expand All @@ -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

Expand Down
43 changes: 21 additions & 22 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void>
Expand All @@ -9,12 +11,13 @@ export interface IOptions {
storage?: any,
jsonify?: boolean,
readonly whitelist?: Array<string>,
readonly blacklist?: Array<string>
readonly blacklist?: Array<string>,
readonly transforms?: Array<ITransform>
}
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 (
Expand All @@ -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)
Expand All @@ -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<string>): 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 {
Expand Down
12 changes: 12 additions & 0 deletions src/transforms/blacklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ITransform, arrToDict } from './utils'

export function blacklistKeys (blacklist: Array<string>): ITransform {
const blacklistDict = arrToDict(blacklist)

return {toStorage: function blacklistTransform (snapshot) {
Object.keys(snapshot).forEach((key) => {
if (blacklistDict[key]) { delete snapshot[key] }
})
return snapshot
}}
}
4 changes: 4 additions & 0 deletions src/transforms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { ITransform, ITransformArgs } from './utils'

export { whitelistKeys } from './whitelist'
export { blacklistKeys } from './blacklist'
18 changes: 18 additions & 0 deletions src/transforms/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string>): StrToBoolMap {
return arr.reduce((dict: StrToBoolMap, elem) => {
dict[elem] = true
return dict
}, {})
}
12 changes: 12 additions & 0 deletions src/transforms/whitelist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ITransform, arrToDict } from './utils'

export function whitelistKeys (whitelist: Array<string>): ITransform {
const whitelistDict = arrToDict(whitelist)

return {toStorage: function whitelistTransform (snapshot) {
Object.keys(snapshot).forEach((key) => {
if (!whitelistDict[key]) { delete snapshot[key] }
})
return snapshot
}}
}
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type StrToAnyMap = {[key: string]: any}
18 changes: 18 additions & 0 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)}
}
38 changes: 35 additions & 3 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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')
})
})