diff --git a/index.d.ts b/index.d.ts index b959017d..d03eb706 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,9 @@ import * as Firebase from 'firebase'; import { Dispatch } from 'redux'; +import { + AsyncThunkAction, + ActionCreatorWithPreparedPayload, +} from '@reduxjs/toolkit'; /** * Action types used within actions dispatched internally. These action types @@ -99,40 +103,44 @@ export interface Config { }; } -type ArrayUnion = ['::arrayUnion', unknown]; -type ArrayRemove = ['::arrayRemove', unknown]; +/** + * + * mutate - Data-driven Design for writing, batching & transacting with Firestore + * + */ +type ArrayUnion = ['::arrayUnion', any | any[]]; +type ArrayRemove = ['::arrayRemove', any | any[]]; type Increment = ['::increment', number]; type ServerTimestamp = ['::serverTimestamp']; type FieldValueTuple = ArrayUnion | ArrayRemove | Increment | ServerTimestamp; -export type MutateData = - | FieldValueTuple - | number - | null - | boolean - | string - | object - | array; - -export type Read = { collection: string; doc: string }; - -export type Write = Read & { data: Record }; - -export type Batch = Write[]; - -export type WriteFn = (readKeys: string) => Write | Write[]; - +type PathId = { id: string; path: string }; +export type Read = PathId; /** - * As of Mar 2021, Firestore does not support transactional queries. + * As of Nov 2021, Firestore does not support transactional queries. * Queries are run as a standard read. Each document returned is read * a second time in the transaction. This is a best effort. Full * Transactional Query support is only available with firebase-admin. */ -export type ReadQuery = Omit & { _slowCollectionRead: true }; +export type ReadQuery = ReduxFirestoreQuerySetting; +export type ReadProvides = () => unknown; -export type Transaction = { - reads: (Read | ReadQuery)[]; - writes: (WriteFn | Writes)[]; +export type Write = PathId & { [key: string]: FieldValueTuple | unknown }; +export type WriteFn = (reads: { [Key in Reads]: any }) => + | Write + | Write[]; + +export type Batch = Write[]; + +export type Transaction> = { + reads: { + [P in keyof Type]: ReadQuery | Read | ReadProvides; + }; + writes: + | WriteFn>[] + | WriteFn> + | Write + | Write[]; }; /** @@ -144,7 +152,119 @@ export type Transaction = { * cache reducer. When the change is accepted or rejected it updated the * cache reducer to reflect data in firestore. */ -export type mutate = (operations: Write | Batch | Transaction) => Promise; +export type mutate = >( + operations: Transaction | Batch | Write, +) => Promise; + +/** + * + * createMutate - Simple wrapper for Redux Toolkit async action creators + * + */ +type Writer> = + | WriteFn> + | Write; +type Writers> = + | Writer + | Writer[]; + +type SimpleRead> = { + [P in keyof ReadType]: ReadProvides; +}; +type TransactionRead> = { + [P in keyof ReadType]: ReadQuery | Read | ReadProvides; +}; + +type SimpleReadFn> = ( + payload: Payload, +) => SimpleRead; + +type TransactionReadFn> = ( + payload: Payload, +) => TransactionRead; + +type SimpleMutate> = { + action: string; + read: SimpleReadFn; + write: Writer; +}; +type ComplexMutate> = { + action: string; + readwrite: (payload: Payload, thunkAPI: FirebaseThunkAPI) => Writer; +}; + +type BatchMutate> = { + action: string; + read: SimpleReadFn; + write: Writers; +}; +type ComplexBatch> = { + action: string; + readwrite: ( + payload: Payload, + thunkAPI: FirebaseThunkAPI, + ) => Writers; +}; + +type TransactionMutate> = { + action: string; + read: TransactionReadFn; + write: Writers; +}; +type ComplexTransaction> = { + action: string; + readwrite: ( + payload: Payload, + thunkAPI: FirebaseThunkAPI, + ) => { + read: TransactionRead; + write: Writers; + }; +}; + +type Mutations> = + | SimpleMutate + | ComplexMutate + | BatchMutate + | ComplexBatch + | TransactionMutate + | ComplexTransaction; + +export function createMutate>( + mutation: Mutations, +): ((arg: Payload) => AsyncThunkAction) & { + pending: ActionCreatorWithPreparedPayload< + [string, void], + undefined, + string, + never, + { + arg: Payload; + requestId: string; + } + >; + rejected: ActionCreatorWithPreparedPayload< + [string, void], + undefined, + string, + never, + { + arg: Payload; + requestId: string; + } + >; + fulfilled: ActionCreatorWithPreparedPayload< + [string, void], + undefined, + string, + never, + { + arg: Payload; + requestId: string; + } + >; + typePrefix: string; +}; /** * A redux store enhancer that adds store.firebase (passed to React component diff --git a/package.json b/package.json index 656973b8..8e9cf6a6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "debug": "^4.3.2", - "immer": "9.0.5", + "immer": "9.0.6", "lodash": "^4.17.21", "reduce-reducers": "^1.0.4" }, @@ -52,6 +52,7 @@ "@babel/preset-react": "^7.14.5", "@babel/register": "^7.14.5", "@babel/runtime": "^7.14.6", + "@reduxjs/toolkit": "^1.6.2", "babel-eslint": "^10.1.0", "babel-loader": "^8.2.2", "babel-plugin-lodash": "^3.3.4", diff --git a/src/index.js b/src/index.js index d6afb805..ee00fa78 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import createFirestoreInstance, { import constants, { actionTypes } from './constants'; import middleware, { CALL_FIRESTORE } from './middleware'; import { getSnapshotByObject } from './utils/query'; +import createMutate from './utils/createMutate'; // converted with transform-inline-environment-variables export const version = process.env.npm_package_version; @@ -34,6 +35,7 @@ export { middleware, CALL_FIRESTORE, mockMutate, + createMutate, }; export default { @@ -51,4 +53,5 @@ export default { middleware, CALL_FIRESTORE, mockMutate, + createMutate, }; diff --git a/src/jest.test.js b/src/jest.test.js new file mode 100644 index 00000000..448ceebb --- /dev/null +++ b/src/jest.test.js @@ -0,0 +1,104 @@ +import { unwrapResult } from '@reduxjs/toolkit'; + +const noop = () => null; + +/** + * shouldPass + * + * it.each([{ + * payload: { id: '999' }, + * mutation: [{id: '999', path: 'orgs/mock-org-id/tasks', archived: true }], + * returned: '999', + * }])(...shouldPass(archiveTask)); + * + * @param {Function} actionCreator + * @returns {Array} - Test Name and Test Function + */ +const shouldPass = (...actionCreator) => { + const [testname, actionCreatorFnc] = + actionCreator.length === 1 + ? [ + `${actionCreator[0].typePrefix || ''} $payload should pass.`, + actionCreator[0], + ] + : [actionCreator[0], actionCreator[1]]; + return [ + testname, + async ({ payload, mutation, returned, provider }) => { + const mutate = jest.fn(); + const getFirestore = jest.fn().mockReturnValue({ + mutate, + }); + + const thunk = [noop, noop, { ...provider, getFirestore }]; + const dispatched = actionCreatorFnc(payload)(...thunk).then(unwrapResult); + const output = await expect(dispatched).resolves.not.toThrow(); + + if (returned !== undefined) { + expect(output).toStrictEqual(returned); + } + + if (mutation !== undefined) { + expect(mutate).toHaveBeenCalledWith(mutation); + } + }, + ]; +}; + +/** + * shouldFail + * + * it.each([{ + * payload: { id: '' }, + * returned: new Error('`id` is empty string.'), + * }])(...shouldFail(archiveTask)); + * + * @param {Function} actionCreator + * @returns {Array} - Test Name and Test Function + */ +const shouldFail = (...actionCreator) => { + const [testname, actionCreatorFnc] = + actionCreator.length === 1 + ? [ + `${actionCreator[0].typePrefix || ''} $payload fails properly.`, + actionCreator[0], + ] + : [actionCreator[0], actionCreator[1]]; + return [ + testname, + async ({ payload, mutation, returned, provider }) => { + const mutate = jest.fn(); + const getFirestore = jest.fn().mockReturnValue({ + mutate, + }); + const thunk = [noop, noop, { ...provider, getFirestore }]; + const dispatched = actionCreatorFnc(payload)(...thunk).then(unwrapResult); + await expect(dispatched).rejects.not.toBeUndefined(); + + if (mutation !== undefined) { + expect(mutate).toHaveBeenCalledWith(mutation); + } + + try { + await dispatched; + } catch (error) { + if (returned === undefined) { + if (!(error instanceof Error) && error.stack) { + return expect(error).not.toBeNull(); + } + return expect(error).toBeInstanceOf(Error); + } + + if (returned instanceof Error) { + return expect(error).toStrictEqual(returned); + } + + Object.keys(returned).map((key) => { + expect(error[key]).toStrictEqual(returned[key]); + }); + } + }, + ]; +}; + +export { shouldFail, shouldPass }; diff --git a/src/utils/createMutate.js b/src/utils/createMutate.js new file mode 100644 index 00000000..9fd676d3 --- /dev/null +++ b/src/utils/createMutate.js @@ -0,0 +1,192 @@ +/* eslint-disable no-unused-vars */ +import { isFunction } from 'lodash'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +// eslint-disable-next-line require-jsdoc +function safeProvider(fnc, state) { + try { + return fnc(state); + } catch (err) { + return new Error( + `Default Provider "${fnc.name}" doesn't accept redux state.`, + ); + } +} + +/** + * createMutate + * @param {{action: string, read: ReadFn, write: WriteFn}} mutation + * @returns {Function} - AsyncActionCreator + */ +export default function createMutate({ action, ...mutation }) { + if (!createAsyncThunk) + throw new Error( + "'createMutate' requires @reduxjs/toolkit. Run 'yarn add @reduxjs/toolkit'", + ); + + const { read, write, readwrite } = mutation; + return createAsyncThunk( + action, + readwrite !== undefined + ? readwrite + : (payload, thunkAPI) => { + const state = (thunkAPI.getState && thunkAPI.getState()) || {}; + const { getFirestore, getFirebase, ...extras } = thunkAPI.extra || {}; + const { mutate } = getFirestore(); + + const statics = Object.keys(extras).reduce( + (obj, extra) => ({ + ...obj, + [extra]: safeProvider(extras[extra], state), + }), + { + uid: + state.firebase && + state.firebase.auth && + state.firebase.auth.uid, + }, + ); + + const reads = read(payload, statics); + const writes = Array.isArray(write) ? write : [write]; + + const isTransaction = Object.keys(reads).every( + (key) => !isFunction(reads[key]), + ); + if (isTransaction) { + return mutate({ reads, writes }); + } + + const values = Object.keys(reads).reduce((reader, key) => { + if (reader[key]) return reader; + return isFunction(reads[key]) + ? { ...reader, [key]: reads[key]() } + : { ...reader, [key]: reads[key] }; + }, statics); + + const isBatch = Array.isArray(write); + if (isBatch) { + return mutate(write.map((writeFnc) => writeFnc(values))); + } + + return mutate(write(values)); + }, + ); +} + +/** + * // Simple Mutation + * + * const const archiveTask = createMutate({ + * action: 'ArchiveTask', + * reads: ({ taskIds }) => ({ + * taskIds, + * }), + * writes: ({ uid, org, taskId }) => + * decode({ + * archived: true, + * updatedAt: ['::serverTimestamp'], + * updatedBy: uid, + * path: `orgs/${org}/tasks`, + * id: taskId, + * }, 'UITaskArchiveChangeset')), + * }); + * + * + * // Batch Mutation + * + * const const archiveTask = createMutate({ + * action: 'ArchiveTask', + * reads: ({ taskIds }) => ({ + * taskIds, + * }), + * writes: [({ uid, org, taskIds }) => + * (Array.isArray(taskIds) ? taskIds : [taskIds]) + * .map((taskId) => + * decode({ + * archived: true, + * updatedAt: ['::serverTimestamp'], + * updatedBy: uid, + * path: `orgs/${org}/tasks`, + * id: taskId, + * }, 'UITaskArchiveChangeset'))], + * }); + * + * // Transaction Mutation + * + * const const archiveTask = createMutate({ + * action: 'ArchiveTask', + * reads: ({ taskId }, { uid }) => ({ + * task: { id: taskId, path: `orgs/${org}/tasks` }, + * }), + * writes: ({ uid, org, task }) => + * (Array.isArray(taskIds) ? taskIds : [taskIds]) + * .map((taskId) => + * decode({ + * archived: true, + * updatedAt: ['::serverTimestamp'], + * updatedBy: uid, + * path: task.path, + * id: task.id, + * }, 'UITaskArchiveChangeset')), + * }); + * + * // Complex ACID-compliant Transaction Mutation + * + * const const archiveSprint = createMutate({ + * action: 'ArchiveSprint', + * reads: ({ taskId, sprintId }, { uid }) => ({ + * task: { id: taskId, path: `orgs/${org}/tasks` }, + * sprint: { id: sprintId, path: `orgs/${org}/sprints` }, + * sprintTasks: { + * path: `orgs/${org}/tasks`, + * where: ['sprint', '==', sprintId], + * }, + * }), + * writes: [ + * ({ uid, task }) => + * decode({ + * archived: true, + * updatedAt: ['::serverTimestamp'], + * updatedBy: uid, + * path: task.path, + * id: task.id, + * }, 'UITaskArchiveChangeset'), + * ({ uid, sprint }) => + * decode({ + * archived: true, + * updatedAt: ['::serverTimestamp'], + * updatedBy: uid, + * path: sprint.path, + * id: sprint.id, + * }, 'UISprintArchiveChangeset'), + * ({ uid, sprintTasks }) => + * sprintTasks.map((task) => + * decode({ + * archived: true, + * updatedAt: ['::serverTimestamp'], + * updatedBy: uid, + * path: task.path, + * id: task.id, + * }, 'UITaskArchiveChangeset'), + * }); + * + * const const archiveSprint = createMutate({ + * action: 'ArchiveSprint', + * reads: ({ taskId, sprintId }, { uid }) => ({ + * task: { id: taskId, path: `orgs/${org}/tasks` }, + * sprint: { id: sprintId, path: `orgs/${org}/sprints` }, + * sprintTasks: { + * path: `orgs/${org}/tasks`, + * where: ['sprint', '==', sprintId], + * }, + * }), + * writes: [ + * archiveTask, + * archiveSprint, + * ({ uid, sprintTasks}) => sprintTasks.map((task) => archieveTask({uid, task})) + * ] + * }); + * + * // + */ diff --git a/src/utils/mutate.js b/src/utils/mutate.js index 39d83089..87c6d565 100644 --- a/src/utils/mutate.js +++ b/src/utils/mutate.js @@ -1,4 +1,12 @@ -import { chunk, cloneDeep, flatten, isFunction, mapValues } from 'lodash'; +import { + chunk, + cloneDeep, + flatten, + isFunction, + isObject, + isPlainObject, + mapValues, +} from 'lodash'; import debug from 'debug'; import { firestoreRef } from './query'; import mark from './profiling'; @@ -78,9 +86,19 @@ function atomize(firebase, operation) { arrayRemove(firebase, val[0], val[1]) || increment(firebase, val[0], val[1]); - if (Array.isArray(val) && val.length > 0) { + if (Array.isArray(val) && val.length > 0 && isPlainObject(val[0])) { + clone[key] = val.map((obj) => { + const [object, update] = atomize(firebase, obj); + if (update) requiresUpdate = true; + return object; + }); + } else if (Array.isArray(val) && val.length > 0) { // eslint-disable-next-line no-param-reassign clone[key] = value; + } else if (isPlainObject(val)) { + const [object, update] = atomize(firebase, val); + clone[key] = object; + if (update) requiresUpdate = true; } return clone; }, cloneDeep(operation)), @@ -183,12 +201,22 @@ async function writeInTransaction(firebase, operations) { return serialize(snapshot.exsits === false ? null : snapshot); } - // else query (As of 7/2021, Firestore doesn't include queries in client-side transactions) - const coll = firestoreRef(firebase, read); - const snapshot = await coll.get(); - if (hasNothing(snapshot) || snapshot.docs.length === 0) return []; - const unserializedDocs = await Promise.all(snapshot.docs.map(getter)); - return unserializedDocs.map(serialize); + // else query + const query = firestoreRef(firebase, read); + + // (As of 7/2021, client-side Firestore doesn't include queries in transactions) + const nonTransactionQuery = await query.get(); + if ( + hasNothing(nonTransactionQuery) || + nonTransactionQuery.docs.length === 0 + ) + return []; + + // followed by transactional get on each document in the result + const transactionDocs = await Promise.all( + nonTransactionQuery.docs.map(getter), + ); + return transactionDocs.map(serialize); }); done(); diff --git a/test/unit/reducers/cacheReducer.spec.js b/test/unit/reducers/cacheReducer.spec.js index 79854175..9fe079cb 100644 --- a/test/unit/reducers/cacheReducer.spec.js +++ b/test/unit/reducers/cacheReducer.spec.js @@ -1471,7 +1471,7 @@ describe('cacheReducer', () => { timestamp: '999', }, payload: { - data: [{ ...updates }], + data: [[{ ...updates }]], }, }; diff --git a/test/unit/utils/mutate.spec.js b/test/unit/utils/mutate.spec.js index 4a842118..f1fa274a 100644 --- a/test/unit/utils/mutate.spec.js +++ b/test/unit/utils/mutate.spec.js @@ -287,10 +287,17 @@ describe('firestore.mutate()', () => { const collection = sinon.spy(() => ({ doc })); const firestore = sinon.spy(() => ({ collection, doc })); firestore.FieldValue = { - serverTimestamp: sinon.spy(() => 'time'), - increment: sinon.spy(() => '++'), - arrayRemove: sinon.spy(() => '-'), - arrayUnion: sinon.spy(() => '+'), + serverTimestamp: sinon.spy(() => 'firestore.FieldValue.serverTimestamp'), + increment: sinon.spy(() => 'firestore.FieldValue.increment'), + arrayRemove: sinon.spy( + (values) => + `firestore.FieldValue.arrayRemove(${ + typeof values === 'string' ? values : JSON.stringify(values) + })`, + ), + arrayUnion: sinon.spy( + (values) => `firestore.FieldValue.arrayUnion(${values.toString()})`, + ), }; await mutate( @@ -303,22 +310,61 @@ describe('firestore.mutate()', () => { 'deeply.nested.map': 'value', 'deeply.nested.array': ['::arrayUnion', ['first', 'second']], addArray: ['::arrayUnion', 'val'], - removeItem: ['::arrayRemove', 'item'], removeArray: ['::arrayRemove', ['item1', 'item2']], + removeArrayObjects: ['::arrayRemove', [{ type: 1 }, { type: 2 }]], updateAt: ['::serverTimestamp'], counter: ['::increment', 3], + deepUnnested: { + moreDeep: { + removeArray: ['::arrayRemove', 'val'], + }, + updateAt: ['::serverTimestamp'], + }, + deepUnnestedArray: [ + { + addArray: ['::arrayUnion', 'val'], + updateAt: ['::serverTimestamp'], + }, + ], null: null, }, }, ); + const { firstArg } = update.getCalls()[0]; + + expect(firstArg).to.eql({ + name: 'Bravo Team 🎄', + 'deeply.nested.map': 'value', + 'deeply.nested.array': 'firestore.FieldValue.arrayUnion(first,second)', + addArray: 'firestore.FieldValue.arrayUnion(val)', + removeArray: 'firestore.FieldValue.arrayRemove(["item1","item2"])', + removeArrayObjects: + 'firestore.FieldValue.arrayRemove([{"type":1},{"type":2}])', + updateAt: 'firestore.FieldValue.serverTimestamp', + counter: 'firestore.FieldValue.increment', + deepUnnested: { + moreDeep: { + removeArray: 'firestore.FieldValue.arrayRemove(val)', + }, + updateAt: 'firestore.FieldValue.serverTimestamp', + }, + deepUnnestedArray: [ + { + addArray: 'firestore.FieldValue.arrayUnion(val)', + updateAt: 'firestore.FieldValue.serverTimestamp', + }, + ], + null: null, + }); + expect(collection.calledWith('orgs/tara-ai/teams')); expect(doc.calledWith('team-bravo')); expect(update.calledWith({ name: 'Bravo Team 🎄' })); expect(set.notCalled); - expect(firestore.FieldValue.serverTimestamp).to.have.been.calledOnce; + expect(firestore.FieldValue.serverTimestamp).to.have.been.calledThrice; expect(firestore.FieldValue.increment).to.have.been.calledOnce; - expect(firestore.FieldValue.arrayRemove).to.have.been.calledTwice; - expect(firestore.FieldValue.arrayUnion).to.have.been.calledTwice; + expect(firestore.FieldValue.arrayRemove).to.have.been.calledThrice; + expect(firestore.FieldValue.arrayUnion).to.have.been.calledThrice; }); }); diff --git a/webpack.config.js b/webpack.config.js index 30ec4e2e..8602b1fe 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -16,7 +16,9 @@ const config = { libraryTarget: 'umd', umdNamedDefine: true, }, - externals: [], + externals: { + '@reduxjs/toolkit': '@reduxjs/toolkit', + }, optimization: { minimize: isProduction, minimizer: isProduction ? [new TerserPlugin()] : [], diff --git a/yarn.lock b/yarn.lock index bd0afbb5..c0fce090 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1706,6 +1706,16 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= +"@reduxjs/toolkit@^1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.6.2.tgz#2f2b5365df77dd6697da28fdf44f33501ed9ba37" + integrity sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA== + dependencies: + immer "^9.0.6" + redux "^4.1.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.0.tgz#f90ffc52a2e519f018b13b6c4da03cbff36ebed6" @@ -3811,10 +3821,10 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -immer@9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.5.tgz#a7154f34fe7064f15f00554cc94c66cc0bf453ec" - integrity sha512-2WuIehr2y4lmYz9gaQzetPR2ECniCifk4ORaQbU3g5EalLt+0IVTosEPJ5BoYl/75ky2mivzdRzV8wWgQGOSYQ== +immer@9.0.6, immer@^9.0.6: + version "9.0.6" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" + integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ== import-fresh@^3.0.0: version "3.2.1" @@ -5287,6 +5297,11 @@ reduce-reducers@^1.0.4: resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-1.0.4.tgz#fb77e751a9eb0201760ac5a605ca8c9c2d0537f8" integrity sha512-Mb2WZ2bJF597exiqX7owBzrqJ74DHLK3yOQjCyPAaNifRncE8OD0wFIuoMhXxTnHK07+8zZ2SJEKy/qtiyR7vw== +redux-thunk@^2.3.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.0.tgz#ac89e1d6b9bdb9ee49ce69a69071be41bbd82d67" + integrity sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA== + redux@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4"