Skip to content

Commit

Permalink
feat: Add persisted state migrations (#818)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
damassi and no-stack-dub-sack authored Apr 18, 2023
1 parent 7e13adf commit aa5dd51
Show file tree
Hide file tree
Showing 7 changed files with 433 additions and 18 deletions.
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,10 @@ export interface PersistConfig<Model extends object> {
allow?: Array<keyof Model>;
deny?: Array<keyof Model>;
mergeStrategy?: 'mergeDeep' | 'mergeShallow' | 'overwrite';
migrations?: {
migrationVersion: number;
[key: number]: (state: Partial<Model & { [key: string | number]: any }>) => void;
}
storage?: 'localStorage' | 'sessionStorage' | PersistStorage;
transformers?: Array<Transformer>;
}
Expand Down
31 changes: 31 additions & 0 deletions src/migrations.js
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 8 additions & 2 deletions src/persistence.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { clone, get, isPlainObject, isPromise, set, pSeries } from './lib';
import { migrate } from './migrations';

const noopStorage = {
getItem: () => undefined,
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -117,6 +122,7 @@ export function extractPersistConfig(path, persistdef = {}) {
storage: createStorageWrapper(
persistdef.storage,
persistdef.transformers,
persistdef.migrations,
),
},
};
Expand Down
169 changes: 169 additions & 0 deletions tests/migrations.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
97 changes: 97 additions & 0 deletions tests/persist.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions tests/typescript/persist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading

1 comment on commit aa5dd51

@vercel
Copy link

@vercel vercel bot commented on aa5dd51 Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

easy-peasy – ./

easy-peasy-git-master-ctrlplusb.vercel.app
easy-peasy-ctrlplusb.vercel.app
easy-peasy.dev

Please sign in to comment.