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

BREAKING CHANGE: storage migration #11

Merged
merged 8 commits into from
Sep 17, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,12 @@ function* handleFetchLandAmountRequest(action: FetchLandAmountRequestAction) {

## Storage

The storage module allows you to save parts of the redux store in localStorage to make them persistent.
This module is required to use other modules like `Transaction` and `Translation`.
The storage module allows you to save parts of the redux store in localStorage to make them persistent and migrate it from different versions without loosing it.
This module is required to use other modules like `Transaction`, `Translation` and `Wallet`.

### Installation

You need to add a middleware and a two reducers to your dApp.
You need to add a middleware and two reducers to your dApp.

**Middleware**:

Expand All @@ -305,15 +305,17 @@ You will need to create a `storageMiddleware` and add apply it along with your o
// store.ts
import { applyMiddleware, compose, createStore } from 'redux'
import { createStorageMiddleware } from 'decentraland-dapps/dist/modules/storage/middleware'
import { migrations } from './migrations'

const composeEnhancers =
(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const { storageMiddleware, loadStorageMiddleware } = createStorageMiddleware(
'storage-key', // this is the key used to save the state in localStorage (required)
[], // array of paths from state to be persisted (optional)
[] // array of actions types that will trigger a SAVE (optional)
)
const { storageMiddleware, loadStorageMiddleware } = createStorageMiddleware({
storageKey: 'storage-key' // this is the key used to save the state in localStorage (required)
paths: [] // array of paths from state to be persisted (optional)
actions: [] // array of actions types that will trigger a SAVE (optional)
migrations: migrations // migration object that will migrate your localstorage (optional)
})

const middleware = applyMiddleware(
// your other middlewares
Expand All @@ -325,6 +327,29 @@ const store = createStore(rootReducer, enhancer)
loadStorageMiddleware(store)
```

**Migrations**:

`migrations` looks like

`migrations.ts`:

```ts
export const migrations = {
2: migrateToVersion2(data),
3: migrateToVersion3(data)
}
```

Where every `key` represent a migration and every `method` should return the new localstorage data:

```ts
function migrateToVersion2(data) {
return omit(data, 'translations')
}
```

You don't need to care about updating the version of the migration because it will be set automatically.

**Reducer**:

You will need to add `storageReducer` as `storage` to your `rootReducer` and then wrap the whole reducer with `storageReducerWrapper`
Expand All @@ -347,7 +372,7 @@ export const rootReducer = storageReducerWrapper(

### Advanced Usage

This module is necessary to use other modules like `Transaction` or `Translation`, but you can also use it to make other parts of your dApp's state persistent
This module is necessary to use other modules like `Transaction`, `Translation` and `Wallet`, but you can also use it to make other parts of your dApp's state persistent

<details><summary>Learn More</summary>
<p>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"scripts": {
"prebuild": "rimraf dist",
"build": "tsc --project tsconfig.json",
"test": "nyc mocha --require ts-node/register src/**/*.spec.ts",
"test": "nyc mocha --require ts-node/register src/**/*.spec.ts src/**/**/*.spec.ts",
"test:watch": "npm test -- --watch --watch-extensions ts",
"semantic-release": "semantic-release",
"commitmsg": "validate-commit-msg"
Expand Down
73 changes: 73 additions & 0 deletions src/lib/localStorage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect } from 'chai'
import { Migrations } from './types'
import {
hasLocalStorage,
migrateStorage,
getLocalStorage
} from './localStorage'
declare var global: any
let fakeStore = {}
global.window = {}

describe('localStorage', function() {
const migrations: Migrations<any> = {
2: (data: any) => ({ ...data, data: 'new version' })
}

beforeEach(function() {
fakeStore = {}
global.window['localStorage'] = {
getItem: (key: string) => fakeStore[key],
setItem: (key: string, value: string) => (fakeStore[key] = value),
removeItem: (key: string) => delete fakeStore[key]
}
})

describe('hasLocalStorage', function() {
it('should return false if localStorage is not available', function() {
delete global.window['localStorage']
expect(hasLocalStorage()).to.equal(false)
})
it('should return true if localStorage is available', function() {
expect(hasLocalStorage()).to.equal(true)
})
})

describe('migrateStorage', function() {
it('should migrate', function() {
const key = 'key'
const localStorage = getLocalStorage()
localStorage.setItem(key, JSON.stringify('{}'))
let data = JSON.parse(localStorage.getItem(key) as string)
expect(data.storage).to.equal(undefined)
migrateStorage(key, migrations)
data = JSON.parse(localStorage.getItem(key) as string)
expect(data.storage.version).to.equal(2)
expect(data.data).to.equal('new version')
})

it('should set corrent version', function() {
const key = 'key'
const localStorage = getLocalStorage()

localStorage.setItem(key, JSON.stringify('{ storage: { version: null }}'))
migrateStorage(key, migrations)
let data = JSON.parse(localStorage.getItem(key) as string)
expect(data.storage.version).to.equal(2)
})

it('should not migrate if there is no migrations left', function() {
const key = 'key'
const localStorage = getLocalStorage()
localStorage.setItem(key, JSON.stringify('{}'))
let data = JSON.parse(localStorage.getItem(key) as string)
expect(data.storage).to.equal(undefined)
migrateStorage(key, migrations)
data = JSON.parse(localStorage.getItem(key) as string)
expect(data.storage.version).to.equal(2)

migrateStorage(key, migrations)
expect(data.storage.version).to.equal(2)
})
})
})
41 changes: 38 additions & 3 deletions src/lib/localStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Migrations, LocalStorage } from './types'

export function hasLocalStorage(): boolean {
try {
// https://gist.github.com/paulirish/5558557
Expand All @@ -11,6 +13,39 @@ export function hasLocalStorage(): boolean {
}
}

export const localStorage = hasLocalStorage()
? window.localStorage
: { getItem: () => null, setItem: () => null, removeItem: () => null }
export function getLocalStorage(): LocalStorage {
return hasLocalStorage()
? window.localStorage
: {
getItem: () => null,
setItem: () => null,
removeItem: () => null
}
}

export function migrateStorage<T>(key: string, migrations: Migrations<T>) {
let version = 1
const localStorage = getLocalStorage()
const dataString = localStorage.getItem(key)

if (dataString) {
const data = JSON.parse(dataString as string)

if (data.storage && data.storage.version) {
version = parseInt(data.storage.version, 10)
}
let nextVersion = version + 1

while (migrations[nextVersion]) {
const newData = migrations[nextVersion](data)
localStorage.setItem(
key,
JSON.stringify({
...(newData as Object),
storage: { version: nextVersion }
})
)
nextVersion++
}
}
}
10 changes: 10 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ export type ModelByAddress<T extends AddressModel> = DataByKey<T>

export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
export type Overwrite<T1, T2> = Pick<T1, Exclude<keyof T1, keyof T2>> & T2

export interface Migrations<T> {
[key: string]: (data: T) => T
}

export interface LocalStorage {
getItem: (key?: string) => string | null
setItem: (key?: string, value?: string) => void | null
removeItem: (key?: string) => void | null
}
14 changes: 8 additions & 6 deletions src/modules/storage/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as storage from 'redux-storage'
import createStorageEngine from 'redux-storage-engine-localstorage'
import filter from 'redux-storage-decorator-filter'
import { hasLocalStorage } from '../../lib/localStorage'
import { hasLocalStorage, migrateStorage } from '../../lib/localStorage'
import { disabledMiddleware } from '../../lib/disabledMiddleware'
import { STORAGE_LOAD } from './actions'
import { StorageMiddleware } from './types'
import {
CHANGE_LOCALE,
FETCH_TRANSLATIONS_REQUEST,
Expand All @@ -19,23 +20,24 @@ import {
const disabledLoad = (store: any) =>
setTimeout(() => store.dispatch({ type: STORAGE_LOAD, payload: {} }))

export function createStorageMiddleware(
storageKey: string,
paths: string[] | string[][] = [],
actions: string[] = []
) {
export function createStorageMiddleware<T>(options: StorageMiddleware<T>) {
const { storageKey, migrations = {}, paths = [], actions = [] } = options

if (!hasLocalStorage()) {
return {
storageMiddleware: disabledMiddleware as any,
loadStorageMiddleware: disabledLoad as any
}
}

migrateStorage(storageKey, migrations)

const storageEngine = filter(createStorageEngine(storageKey), [
'transaction',
'translation',
['wallet', 'data', 'locale'],
['wallet', 'data', 'derivationPath'],
['storage', 'version'],
...paths
])
const storageMiddleware: any = storage.createMiddleware(
Expand Down
3 changes: 3 additions & 0 deletions src/modules/storage/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import * as storage from 'redux-storage'
import { STORAGE_LOAD } from './actions'

export type StorageState = {
version: number
loading: boolean
}

export const INITIAL_STATE: StorageState = {
version: 1,
loading: true
}

Expand All @@ -21,6 +23,7 @@ export function storageReducer(state = INITIAL_STATE, action: AnyAction) {
switch (action.type) {
case STORAGE_LOAD:
return {
...state,
loading: false
}
default:
Expand Down
8 changes: 8 additions & 0 deletions src/modules/storage/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Migrations } from '../../lib/types'

export interface StorageMiddleware<T> {
storageKey: string
paths: string[] | string[][]
actions: string[]
migrations: Migrations<T>
}