Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Transforms #16

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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'
agilgur5 marked this conversation as resolved.
Show resolved Hide resolved
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')
})
})