From aa5dd5196094eca177e372e436e4583969b746f9 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Mon, 17 Apr 2023 18:35:49 -0700 Subject: [PATCH] feat: Add persisted state migrations (#818) * feat: Add persisted state migrations * test: Add tests for migrator * chore: add types to persisted migrations * docs: add migration docs * tests: make test cases a little more clear * docs(migrations): slight reordering * test: add one more test --------- Co-authored-by: Peter Weinberg --- index.d.ts | 4 + src/migrations.js | 31 ++++++ src/persistence.js | 10 +- tests/migrations.test.js | 169 +++++++++++++++++++++++++++++++ tests/persist.test.js | 97 ++++++++++++++++++ tests/typescript/persist.tsx | 16 +++ website/docs/docs/api/persist.md | 124 ++++++++++++++++++++--- 7 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 src/migrations.js create mode 100644 tests/migrations.test.js diff --git a/index.d.ts b/index.d.ts index af4bf15ed..eca14142a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1040,6 +1040,10 @@ export interface PersistConfig { allow?: Array; deny?: Array; mergeStrategy?: 'mergeDeep' | 'mergeShallow' | 'overwrite'; + migrations?: { + migrationVersion: number; + [key: number]: (state: Partial) => void; + } storage?: 'localStorage' | 'sessionStorage' | PersistStorage; transformers?: Array; } diff --git a/src/migrations.js b/src/migrations.js new file mode 100644 index 000000000..7d7dab81e --- /dev/null +++ b/src/migrations.js @@ -0,0 +1,31 @@ +import { produce, setAutoFreeze } from 'immer'; + +export const migrate = ( + data, + migrations, +) => { + setAutoFreeze(false); + + let version = data._migrationVersion ?? 0; + const toVersion = migrations.migrationVersion + + if (typeof version !== "number" || typeof toVersion !== 'number') { + throw new Error('No migration version found'); + } + + while (version < toVersion) { + const nextVersion = version + 1; + const migrator = migrations[nextVersion]; + + if (!migrator) { + throw new Error(`No migrator found for \`migrationVersion\` ${nextVersion}`); + } + + data = produce(data, migrator); + data._migrationVersion = nextVersion; + version = data._migrationVersion; + } + + setAutoFreeze(true); + return data; +} diff --git a/src/persistence.js b/src/persistence.js index 52c48afbe..7f5922862 100644 --- a/src/persistence.js +++ b/src/persistence.js @@ -1,4 +1,5 @@ import { clone, get, isPlainObject, isPromise, set, pSeries } from './lib'; +import { migrate } from './migrations'; const noopStorage = { getItem: () => undefined, @@ -32,7 +33,7 @@ const getBrowerStorage = (storageName) => { const localStorage = getBrowerStorage('localStorage'); const sessionStorage = getBrowerStorage('sessionStorage'); -function createStorageWrapper(storage, transformers = []) { +function createStorageWrapper(storage, transformers = [], migrations = {}) { if (storage == null) { storage = sessionStorage(); } @@ -69,10 +70,14 @@ function createStorageWrapper(storage, transformers = []) { }; const deserialize = (data) => { - const result = + const storageData = storage === localStorage() || storage === sessionStorage() ? JSON.parse(data).data : data; + + const hasMigrations = Object.keys(migrations).length > 0; + const result = hasMigrations ? migrate(storageData, migrations) : storageData; + if ( outTransformers.length > 0 && result != null && @@ -117,6 +122,7 @@ export function extractPersistConfig(path, persistdef = {}) { storage: createStorageWrapper( persistdef.storage, persistdef.transformers, + persistdef.migrations, ), }, }; diff --git a/tests/migrations.test.js b/tests/migrations.test.js new file mode 100644 index 000000000..0432c5193 --- /dev/null +++ b/tests/migrations.test.js @@ -0,0 +1,169 @@ +import { migrate } from '../src/migrations'; + +test('leaves an object untouched if there are no migrations pending', () => { + // ARRANGE + const result = migrate( + { + _migrationVersion: 1, + value: 'untouched', + }, + { + migrationVersion: 1, + + 1: (state) => { + state.value = 'modified'; + }, + }, + ); + + // ASSERT + expect(result.value).toBe('untouched'); + expect(result._migrationVersion).toBe(1); +}); + +test('applies a migration if there is one pending', () => { + // ARRANGE + const result = migrate( + { + value: 'untouched', + }, + { + migrationVersion: 1, + + 1: (state) => { + state.value = 'modified'; + }, + }, + ); + + expect(result.value).toBe('modified'); + expect(result._migrationVersion).toBe(1); +}); + +test('applies many migrations if there are many pending', () => { + // ARRANGE + const result = migrate( + { + _migrationVersion: 0, + }, + { + migrationVersion: 4, + + 0: (state) => { + state.zero = true; + }, + 1: (state) => { + state.one = true; + }, + 2: (state) => { + state.two = true; + }, + 3: (state) => { + state.three = true; + }, + 4: (state) => { + state.four = true; + }, + 5: (state) => { + state.five = true; + }, + }, + ); + + // ASSERT + expect(result.zero).toBe(undefined); + expect(result.one).toBe(true); + expect(result.two).toBe(true); + expect(result.three).toBe(true); + expect(result.four).toBe(true); + expect(result.five).toBe(undefined); + expect(result._migrationVersion).toBe(4); +}); + +test('applies migrations and deletes old values', () => { + // ARRANGE + const result = migrate( + { + _migrationVersion: 0, + session: "session", + }, + { + migrationVersion: 2, + + 1: (state) => { + state.userSession = state.session; + delete state.session; + }, + + 2: (state) => { + state.domainSession = state.userSession; + delete state.userSession; + }, + }, + ); + + // ASSERT + expect(result.session).toBe(undefined); + expect(result.userSession).toBe(undefined); + expect(result.domainSession).toBe('session'); + expect(result._migrationVersion).toBe(2); +}); + +test('throws an error if there is no valid version', () => { + // ARRANGE + expect(() => { + migrate( + { + _migrationVersion: '0', + }, + { + migrationVersion: 1, + + 1: (state) => { + state.zero = true; + }, + }, + ); + // ASSERT + }).toThrowError(`No migration version found`); +}); + +test('throws an error if there is no valid migration', () => { + // ARRANGE + expect(() => { + migrate( + { + _migrationVersion: 0, + }, + { + migrationVersion: 1, + + 0: (state) => { + state.zero = true; + }, + }, + ); + // ASSERT + }).toThrowError('No migrator found for `migrationVersion` 1'); +}); + +test('guarantees that the version number ends up correct', () => { + // ARRANGE + const result = migrate( + { + _migrationVersion: 0, + value: 'untouched', + }, + { + migrationVersion: 1, + 1: (state) => { + state.value = 'modified'; + state._migrationVersion = 5; + }, + }, + ); + + // ASSERT + expect(result.value).toBe('modified'); + expect(result._migrationVersion).toBe(1); +}); diff --git a/tests/persist.test.js b/tests/persist.test.js index e7ccad69e..8b20961c5 100644 --- a/tests/persist.test.js +++ b/tests/persist.test.js @@ -774,6 +774,103 @@ test('transformers order', async () => { }); }); +test('migrations', async () => { + // ARRANGE + const memoryStorage = createMemoryStorage({ + '[EasyPeasyStore][0]': { + session: "session", + user: "User Name", + }, + }); + + const makeStore = () => + createStore( + persist( + { + user: { + name: null, + session: null + }, + }, + { + storage: memoryStorage, + migrations: { + migrationVersion: 2, + + 1: (state) => { + state.user = { name: state.user } + state.userSession = state.session; + delete state.session; + }, + 2: (state) => { + state.user.session = state.userSession; + delete state.userSession; + }, + } + }, + ), + ); + + const store = makeStore(); + await store.persist.resolveRehydration(); + + // ASSERT + expect(store.getState().user.name).toBe('User Name') + expect(store.getState().user.session).toBe('session') + expect(store.getState().session).toBeUndefined(); + expect(store.getState().userSession).toBeUndefined(); +}) + +test('partially applied migrations', async () => { + // ARRANGE + const memoryStorage = createMemoryStorage({ + '[EasyPeasyStore][0]': { + user: { + name: "User Name", + }, + userSession: "session", + _migrationVersion: 1, + }, + }); + + const makeStore = () => + createStore( + persist( + { + user: { + name: null, + session: null + }, + }, + { + storage: memoryStorage, + migrations: { + migrationVersion: 2, + + 1: (state) => { + state.user = { name: state.user } + state.userSession = state.session; + delete state.session; + }, + 2: (state) => { + state.user.session = state.userSession; + delete state.userSession; + }, + } + }, + ), + ); + + const store = makeStore(); + await store.persist.resolveRehydration(); + + // ASSERT + expect(store.getState().user.name).toBe('User Name') + expect(store.getState().user.session).toBe('session') + expect(store.getState().session).toBeUndefined(); + expect(store.getState().userSession).toBeUndefined(); +}) + test('multiple stores', async () => { // ARRANGE const memoryStorage = createMemoryStorage(); diff --git a/tests/typescript/persist.tsx b/tests/typescript/persist.tsx index fb9812b22..6feb3e54a 100644 --- a/tests/typescript/persist.tsx +++ b/tests/typescript/persist.tsx @@ -24,6 +24,22 @@ const model = persist( `${model.foo}baz`; +persist( + { + foo: 'bar', + }, + { + migrations: { + migrationVersion: 1, + + 1: (state) => { + state.foo = 'bar'; + delete state.migrationConflict + } + }, + }, +); + createTransform( (data, key) => `${key}foo`, (data, key) => `${key}foo`, diff --git a/website/docs/docs/api/persist.md b/website/docs/docs/api/persist.md index 4f2fdfa2c..a12537c11 100644 --- a/website/docs/docs/api/persist.md +++ b/website/docs/docs/api/persist.md @@ -1,8 +1,8 @@ # persist -This helper allows you to persist your store state, and subsequently rehydrate -the store state when the store is recreated (e.g. on page refresh, new browser -tab, etc). +This helper allows you to persist your store state, perform migrations, and +subsequently rehydrate the store state when the store is recreated (e.g. on page +refresh, new browser tab, etc). By default it uses the browser's [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage), @@ -17,6 +17,8 @@ or provide a custom storage engine. - [Configuring your store to persist](#configuring-your-store-to-persist) - [Rehydrating your store](#rehydrating-your-store) - [Managing model updates](#managing-model-updates) + - [Migrations](#migrations) + - [Forced updates via `version`](#forced-updates-via-version) - [Advanced Tutorial](#advanced-tutorial) - [Merge Strategies](#merge-strategies) - [mergeDeep](#mergedeep) @@ -69,9 +71,29 @@ Below is the API of the `persist` helper function. Please see the [docs](#merge-strategies) below for a full insight and understanding of the various options and their respective implications. + - `migrations` (Object, _optional_) + + This config is used to transform persisted store state from one + representation to another. This object is keyed by version numbers, with + migration functions attached to each version. A `migrationVersion` is also + required for this object, to specify which version to target. + + ```ts + persist( + {...}, + { + migrations: { + migrationVersion: 2, + 1: (state) => { ... }, + 2: (state) => { ... }, + }, + } + ); + ``` + - `transformers` (Array<Transformer>, _optional_) - Transformers are use to apply operations to your data during prior it being + Transformers are used to apply operations to your data prior to it being persisted or hydrated. One use case for a transformer is to handle data that can't be parsed to a @@ -246,23 +268,93 @@ have persisted state based on a previous version of your store model. The user's persisted state may not align with that of your new store model, which could result in errors when a component tries to consume/update the store. -Easy Peasy does its best to try and minimize / alleviate this risk. By default -the persist API utilizes the `mergeDeep` strategy (you can read more above merge -strategies further below). The `mergeDeep` strategy attempts to perform an -optimistic merge of the persisted state against the store model. Where it finds -that the persisted state is missing keys that are present in the store model, it -will ensure to use the respective state from the store model. It will also -verify the types of data at each key. If there is a misalignment (ignoring -`null` or `undefined`) then it will opt for using the data from the store model -instead as this generally indicates that the respective state has been +By default the persist API utilizes the mergeDeep strategy (you can read more +about merge strategies further below). The mergeDeep strategy attempts to +perform an optimistic merge of the persisted state against the store model. +Where it finds that the persisted state is missing keys that are present in the +store model, it will ensure to use the respective state from the store model. It +will also verify the types of data at each key. If there is a misalignment +(ignoring null or undefined) then it will opt for using the data from the store +model instead as this generally indicates that the respective state has been refactored. -Whilst the `mergeDeep` strategy is fairly robust and should be able to cater for -a wide variety of model updates, it can't provide a 100% guarantee that a valid +Whilst the mergeDeep strategy is fairly robust and should be able to cater for a +wide variety of model updates, it can't provide a 100% guarantee that a valid state structure will be resolved. Therefore, when dealing with production applications, we recommend that you -consider removing this risk. You can do so by utilizing the `version` +consider removing this risk by using one of the two options described below: + +### Migrations + +[Similar to `redux-persist`](https://github.com/rt2zz/redux-persist#migrations), +the persist API provides a mechanism for migrating persisted state across store +updates via the `migrations` configuration object. + +**Example** + +Imagine a store model that has a property called `session`, and a recent +requirements change necessitates that `session` be renamed to `userSession` for +specificity reasons. Without a migration, if `session` was previously deployed +to users and persisted, when the `userSession` change is released their +application will break due to the mismatch between `session` and `userSession` +as retrieved from local storage. + +In order to mitigate we can add a state migration: + +```ts +persist( + { + userSession: true, + }, + { + migrations: { + migrationVersion: 1, // 👈 set the latest migration version + + 1: (state) => { + state.userSession = state.session; // 👈 update new prop with old value from local storage + delete state.session; // and then delete, as it is no longer used + }, + }, + }, +); +``` + +If this property changes in the future, we can add another migration: + +```ts +persist( + { + domainSession: true, // 👈 model has changed + }, + { + migrations: { + migrationVersion: 2, // 👈 update to the latest version + + 1: (state) => { + state.userSession = state.session; + delete state.session; + }, + + 2: (state) => { + state.domainSession = state.userSession; + delete state.userSession; + }, + }, + }, +); +``` + +Well-written migrations should obviate the need for merge strategies and render +them no-ops. Note, however, that merge strategies are still applied and +unexpected behavior may occur during rehydration as a result of non-exhaustive +migrations. + +### Forced updates via `version` + +If migrations are insufficient (which can often be the case after a major state +refactor has taken place), the persist API also provides a means to "force +update" the store via version. You can do so by utilizing the `version` configuration property that is available on the store config. ```javascript