Skip to content

Commit

Permalink
Merge pull request #11 from decentraland/feat/storage-migration
Browse files Browse the repository at this point in the history
BREAKING CHANGE: storage migration
  • Loading branch information
Juan Cazala authored Sep 17, 2018
2 parents e454f3d + cf784e0 commit 3d53434
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 19 deletions.
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>
}

0 comments on commit 3d53434

Please sign in to comment.