From 78c9789c6b97cb5bbff004bc79899baa08bb60b9 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Sat, 17 Feb 2024 21:40:14 +0100 Subject: [PATCH 1/4] Setup bun testing --- bun.lockb | Bin 229628 -> 230398 bytes bunfig.toml | 5 +++ jest.config.js | 6 ---- package.json | 86 +++++++++++++++++++++++-------------------------- tsconfig.json | 6 ++-- 5 files changed, 50 insertions(+), 53 deletions(-) create mode 100644 bunfig.toml delete mode 100644 jest.config.js diff --git a/bun.lockb b/bun.lockb index 32190ebbc4bea74cf0b5aeee7ed180f5646c1739..af0475a1ae79c8dc693341a48bb163089e890018 100755 GIT binary patch delta 33416 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 VvD)Mo7u~XpmD=;n#A2Dg{|7f<8GHZ$ delta 32812 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 cJUIHQv3iSG7F?)^SU", - "license": "MIT", - "dependencies": { - "jest": "^29.7.0" - } + "name": "optimistron", + "packageManager": "yarn@1.22.19", + "devDependencies": { + "@types/lodash": "^4.14.202", + "@types/react-dom": "^18.2.14", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "bun-types": "^1.0.26", + "clsx": "^2.0.0", + "eslint": "^8.56.0", + "formik": "^2.4.5", + "idb": "^8.0.0", + "imask": "^7.3.0", + "lodash": "^4.17.21", + "loglevel": "^1.9.1", + "mermaid": "^10.7.0", + "otpauth": "^9.2.2", + "papaparse": "^5.4.1", + "prettier": "^3.2.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-redux": "^9.1.0", + "react-router-dom": "^6.21.1", + "redux-saga": "^1.3.0", + "redux-thunk": "^3.1.0", + "serve": "^14.2.1", + "typescript": "^5.2.2" + }, + "peerDependencies": { + "@reduxjs/toolkit": "^2.1.0", + "redux": "^5.0.1" + }, + "scripts": { + "build:usecases": "bun build ./usecases/index.tsx --outdir ./usecases/dist --entry-naming [dir]/[name].[ext] --asset-naming [name].[ext] --target browser", + "serve:usecases": "bun serve ./usecases/", + "watch:usecases": "bun build:usecases --watch" + }, + "version": "1.0.0", + "main": "index.js", + "author": "Edvin CANDON ", + "license": "MIT" } diff --git a/tsconfig.json b/tsconfig.json index 83e39da..9a8e67f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,10 @@ "skipLibCheck": true, "noEmit": true, "paths": { + "~test/*": ["./test/*"], "~usecases/*": ["./usecases/*"], - "~*": ["./src/*"], + "~*": ["./src/*"] }, - }, + "types": ["bun-types"] + } } From 891df1597ffd80404a8646a3f852d9ef12cf2d3a Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Sat, 17 Feb 2024 21:40:44 +0100 Subject: [PATCH 2/4] Core improvements --- src/actions.ts | 47 ++++------ src/constants.ts | 1 - src/optimistron.spec.ts | 115 ----------------------- src/optimistron.ts | 32 +++---- src/selectors.ts | 15 +-- src/state.ts | 37 +++++--- src/state/{record.ts => indexed.ts} | 47 ++++------ src/state/record.spec.ts | 83 ----------------- src/transitions.spec.ts | 138 ---------------------------- src/transitions.ts | 134 ++++++++++++++++----------- 10 files changed, 160 insertions(+), 489 deletions(-) delete mode 100644 src/optimistron.spec.ts rename src/state/{record.ts => indexed.ts} (73%) delete mode 100644 src/state/record.spec.ts delete mode 100644 src/transitions.spec.ts diff --git a/src/actions.ts b/src/actions.ts index dcbece7..29124e4 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -3,18 +3,16 @@ import { createAction } from '@reduxjs/toolkit'; import type { MetaKey } from '~constants'; import type { TransitionMeta, TransitionNamespace } from '~transitions'; -import { - TransitionDedupeMode, - TransitionOperation, - getTransitionMeta, - isTransitionForNamespace, - withTransitionMeta, -} from '~transitions'; +import { DedupeMode, Operation, getTransitionMeta, isTransitionForNamespace, prepareTransition } from '~transitions'; + +type EmptyPayload = { payload: never }; +type PA_Empty = () => EmptyPayload; +type PA_Error = (error: unknown) => EmptyPayload & { error: Error }; /** Helper action matcher function that will match the supplied * namespace when the transition operation is of type COMMIT */ const createMatcher = - >(namespace: NS) => + >(namespace: NS) => < Result extends ReturnType, Error = Result extends { error: infer Err } ? Err : never, @@ -22,14 +20,13 @@ const createMatcher = >( action: Action, ): action is PayloadAction => - isTransitionForNamespace(action, namespace) && - getTransitionMeta(action).operation === TransitionOperation.COMMIT; + isTransitionForNamespace(action, namespace) && getTransitionMeta(action).operation === Operation.COMMIT; -export const createTransition = - ( +const createTransition = + ( type: Type, - operation: TransitionOperation, - dedupe: TransitionDedupeMode = TransitionDedupeMode.OVERWRITE, + operation: Op, + dedupe: DedupeMode = DedupeMode.OVERWRITE, ) => < PA extends PrepareAction, @@ -41,24 +38,18 @@ export const createTransition = prepare: PA, ): ActionCreatorWithPreparedPayload<[transitionId: string, ...Params], Action['payload'], Type, Err, Meta> => createAction(type, (transitionId, ...params) => - withTransitionMeta(prepare(...params), { - conflict: false, - failed: false, + prepareTransition(prepare(...params), { id: transitionId, operation, dedupe, }), ); -type EmptyPayload = { payload: never }; -type PA_Empty = () => EmptyPayload; -type PA_Error = (error: unknown) => EmptyPayload & { error: Error }; - export const createTransitions = - (type: Type, dedupe: TransitionDedupeMode = TransitionDedupeMode.OVERWRITE) => + (type: Type, dedupe: DedupeMode = DedupeMode.OVERWRITE) => < PA_Stage extends PrepareAction, - PA_Commit extends PA_Stage | PA_Empty = PA_Empty, + PA_Commit extends PrepareAction = PA_Empty, PA_Stash extends PrepareAction = PA_Empty, PA_Fail extends PrepareAction = PA_Error, >( @@ -85,11 +76,11 @@ export const createTransitions = const stashPA = noOptions ? emptyPA : options.stash ?? emptyPA; return { - amend: createTransition(`${type}::amend`, TransitionOperation.AMEND, dedupe)(stagePA), - stage: createTransition(`${type}::stage`, TransitionOperation.STAGE, dedupe)(stagePA), - commit: createTransition(`${type}::commit`, TransitionOperation.COMMIT, dedupe)(commitPA as PA_Commit), - fail: createTransition(`${type}::fail`, TransitionOperation.FAIL, dedupe)(failPA as PA_Fail), - stash: createTransition(`${type}::stash`, TransitionOperation.STASH, dedupe)(stashPA as PA_Stash), + amend: createTransition(`${type}::amend`, Operation.AMEND, dedupe)(stagePA), + stage: createTransition(`${type}::stage`, Operation.STAGE, dedupe)(stagePA), + commit: createTransition(`${type}::commit`, Operation.COMMIT, dedupe)(commitPA as PA_Commit), + fail: createTransition(`${type}::fail`, Operation.FAIL, dedupe)(failPA as PA_Fail), + stash: createTransition(`${type}::stash`, Operation.STASH, dedupe)(stashPA as PA_Stash), match: createMatcher(type), }; }; diff --git a/src/constants.ts b/src/constants.ts index 0e027bc..e26cdb9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,2 @@ export const MetaKey = '__OPTIMISTRON_META__' as const; export const ReducerIdKey = '__OPTIMISTRON_REF_ID__' as const; -export const InitAction = { type: '__OPTIMISTRON_INIT__' } as const; diff --git a/src/optimistron.spec.ts b/src/optimistron.spec.ts deleted file mode 100644 index 547d008..0000000 --- a/src/optimistron.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { createTransitions } from '~actions'; -import { optimistron } from '~optimistron'; -import { ReducerMap } from '~reducer'; -import { selectIsConflicting, selectIsFailed, selectIsOptimistic } from '~selectors'; -import { recordHandlerFactory } from '~state/record'; -import { updateTransition } from '~transitions'; - -describe('optimistron', () => { - beforeEach(() => ReducerMap.clear()); - - describe('RecordState', () => { - type Item = { id: string; value: string; revision: number }; - - const createItem = createTransitions('items::add')({ stage: (item: Item) => ({ payload: { item } }) }); - const editItem = createTransitions('items::edit')({ stage: (item: Item) => ({ payload: { item } }) }); - - const compare = (a: Item) => (b: Item) => { - if (a.revision > b.revision) return 1; - if (a.revision === b.revision) return 0; - return -1; - }; - - const eq = (a: Item) => (b: Item) => a.id === b.id && a.value === b.value; - - const reducer = optimistron( - 'items', - {}, - recordHandlerFactory({ itemIdKey: 'id', compare, eq }), - ({ getState, create, update }, action) => { - if (createItem.match(action)) return create(action.payload.item); - if (editItem.match(action)) return update(action.payload.item.id, action.payload.item); - return getState(); - }, - ); - - const initial = reducer(undefined, { type: 'INIT' }); - - describe('create', () => { - test('stage', () => { - const stage = createItem.stage('001', { id: '001', value: '1', revision: 0 }); - const nextState = reducer(initial, stage); - - expect(nextState).toStrictEqual(initial); - expect(nextState.transitions).toEqual([stage]); - expect(selectIsOptimistic('001')(nextState)).toBe(true); - expect(selectIsFailed('001')(nextState)).toBe(false); - }); - - test('stage > fail', () => { - const stage = createItem.stage('001', { id: '001', value: '1', revision: 0 }); - const fail = createItem.fail('001', new Error()); - const nextState = [stage, fail].reduce(reducer, initial); - - expect(nextState).toStrictEqual(initial); - expect(nextState.transitions).toEqual([updateTransition(stage, { failed: true })]); - expect(selectIsOptimistic('001')(nextState)).toBe(true); - expect(selectIsFailed('001')(nextState)).toBe(true); - }); - - test('stage > fail > stage', () => { - const stage = createItem.stage('001', { id: '001', value: '1', revision: 0 }); - const fail = createItem.fail('001', new Error()); - const nextState = [stage, fail, stage].reduce(reducer, initial); - - expect(nextState).toStrictEqual(initial); - expect(nextState.transitions).toEqual([stage]); - expect(selectIsOptimistic('001')(nextState)).toBe(true); - expect(selectIsFailed('001')(nextState)).toBe(false); - }); - - test('stage > stash', () => { - const stage = createItem.stage('001', { id: '001', value: '1', revision: 0 }); - const stash = createItem.stash('001'); - const nextState = [stage, stash].reduce(reducer, initial); - - expect(nextState).toStrictEqual(initial); - expect(nextState.transitions).toEqual([]); - expect(selectIsOptimistic('001')(nextState)).toBe(false); - expect(selectIsFailed('001')(nextState)).toBe(false); - }); - - test('stage > commit', () => { - const stage = createItem.stage('001', { id: '001', value: '1', revision: 0 }); - const commit = createItem.commit('001'); - const nextState = [stage, commit].reduce(reducer, initial); - - expect(nextState).toEqual({ state: { ['001']: stage.payload.item } }); - expect(nextState.transitions).toEqual([]); - expect(selectIsOptimistic('001')(nextState)).toBe(false); - expect(selectIsFailed('001')(nextState)).toBe(false); - }); - }); - - describe('update', () => { - test('update > noop', () => { - const update = editItem.stage('002', { id: '002', value: '2', revision: 2 }); - const nextState = [update].reduce(reducer, initial); - - expect(nextState).toStrictEqual(initial); - expect(nextState.transitions).toEqual([]); - }); - - test('update > conflict', () => { - const stage = createItem.stage('001', { id: '001', value: '1', revision: 2 }); - const commit = createItem.commit('001'); - const update = editItem.stage('001', { id: '001', value: '2', revision: 1 }); - const nextState = [stage, commit, update].reduce(reducer, initial); - - expect(selectIsOptimistic('001')(nextState)).toBe(true); - expect(selectIsFailed('001')(nextState)).toBe(false); - expect(selectIsConflicting('001')(nextState)).toBe(true); - }); - }); - }); -}); diff --git a/src/optimistron.ts b/src/optimistron.ts index 363515e..16ce3e5 100644 --- a/src/optimistron.ts +++ b/src/optimistron.ts @@ -4,13 +4,13 @@ import { ReducerMap, bindReducer, type HandlerReducer } from '~reducer'; import type { StateHandler, TransitionState } from '~state'; import { bindStateFactory, buildTransitionState, transitionStateFactory } from '~state'; import { - TransitionOperation, + Operation, getTransitionID, getTransitionMeta, isTransitionForNamespace, processTransition, sanitizeTransitions, - updateTransition, + toCommit, } from '~transitions'; export const optimistron = ( @@ -40,24 +40,20 @@ export const optimistron = id === getTransitionID(entry)); - if (!staged) return next(state, nextTransitions); + if (operation === Operation.COMMIT) { + /* Find the matching staged action in the transition list. + * Treat it as a commit if it exists - noop otherwise */ + const staged = transitions.find((entry) => id === getTransitionID(entry)); + if (!staged) return next(state, nextTransitions); - /* Comitting will apply the action to the reducer */ - const commit = updateTransition(staged, { operation: TransitionOperation.COMMIT }); - return next(boundReducer(transitionState, commit), nextTransitions); - } - default: { - /* Every other transition actions will not be applied. - * If you need to get the optimistic state use the provided - * selectors which will apply the optimistic transitions */ - return next(state, nextTransitions); - } + /* Comitting will apply the action to the reducer */ + return next(boundReducer(transitionState, toCommit(staged)), nextTransitions); } + + /* Every other transition actions will not be applied. + * If you need to get the optimistic state use the provided + * selectors which will apply the optimistic transitions */ + return next(state, nextTransitions); } return next(boundReducer(transitionState, action), transitions); diff --git a/src/selectors.ts b/src/selectors.ts index 6b19e57..2844f5f 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -1,7 +1,7 @@ import { ReducerIdKey } from '~constants'; import { ReducerMap } from '~reducer'; -import { cloneTransitionState, type TransitionState } from '~state'; -import { getTransitionMeta, TransitionOperation, updateTransition } from '~transitions'; +import { type TransitionState } from '~state'; +import { getTransitionMeta, toCommit } from '~transitions'; export const selectOptimistic = (selector: (state: TransitionState) => Slice) => @@ -9,10 +9,13 @@ export const selectOptimistic = const boundReducer = ReducerMap.get(state[ReducerIdKey]); if (!boundReducer) return selector(state); - const optimisticState = state.transitions.reduce((acc, transition) => { - acc.state = boundReducer(acc, updateTransition(transition, { operation: TransitionOperation.COMMIT })); - return acc; - }, cloneTransitionState(state)); + const optimisticState = state.transitions.reduce( + (acc, transition) => { + acc.state = boundReducer(acc, toCommit(transition)); + return acc; + }, + Object.assign({}, state), + ); return selector(optimisticState); }; diff --git a/src/state.ts b/src/state.ts index 584f98c..a11cd15 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,12 +1,29 @@ import { ReducerIdKey } from '~constants'; -import type { TransitionAction } from '~transitions'; +import type { StagedAction, TransitionAction } from '~transitions'; export type TransitionState = { state: T; - transitions: TransitionAction[]; + transitions: StagedAction[]; [ReducerIdKey]: string; }; +type ItemIdKeys = { + [K in keyof T]: T[K] extends string ? K : never; +}[keyof T]; + +export type StateHandlerOptions = { + itemIdKey: ItemIdKeys; + /** Given two items returns a sorting result. + * This allows checking for valid updates or conflicts. + * Return -1 if `a` is "smaller" than `b` + * Return 0 if `a` equals `b` + * Return 1 if `b` is "greater" than `a`*/ + compare: (a: T) => (b: T) => 0 | 1 | -1; + /** Equality checker - it can potentially be different + * than comparing. */ + eq: (a: T) => (b: T) => boolean; +}; + export interface StateHandler< State, CreateParams extends any[], @@ -46,13 +63,11 @@ export const bindStateFactory = export const isTransitionState = (state: any): state is TransitionState => ReducerIdKey in state; -export const buildTransitionState = ( - state: State, - transitions: TransitionAction[], - namespace: string, -): TransitionState => { +type UnwrapTransitionState = T extends TransitionState ? T : TransitionState; + +export const buildTransitionState = (state: State, transitions: TransitionAction[], namespace: string) => { const transitionState = isTransitionState(state) - ? { ...state } + ? Object.assign({}, state) : { state, transitions, [ReducerIdKey]: namespace }; /* make internal properties non-enumerable to avoid consumers @@ -62,7 +77,7 @@ export const buildTransitionState = ( [ReducerIdKey]: { value: namespace, enumerable: false }, }); - return transitionState; + return transitionState as UnwrapTransitionState; }; export const transitionStateFactory = @@ -71,7 +86,3 @@ export const transitionStateFactory = if (state === prev.state && transitions === prev.transitions) return prev; return buildTransitionState(state, transitions, prev[ReducerIdKey]); }; - -export const cloneTransitionState = (transitionState: TransitionState): TransitionState => ({ - ...transitionState, -}); diff --git a/src/state/record.ts b/src/state/indexed.ts similarity index 73% rename from src/state/record.ts rename to src/state/indexed.ts index 06c85cd..54137f5 100644 --- a/src/state/record.ts +++ b/src/state/indexed.ts @@ -1,51 +1,37 @@ -import type { StateHandler } from '~state'; +import type { StateHandler, StateHandlerOptions } from '~state'; import { OptimisticMergeResult } from '~transitions'; -export type RecordState = Record; - -/** implement ord and eq */ -export type RecordStateOptions = { - itemIdKey: keyof T; - /** Given two items returns a sorting result. - * This allows checking for valid updates or conflicts. - * Return -1 if `a` is "smaller" than `b` - * Return 0 if `a` equals `b` - * Return 1 if `b` is "greater" than `a`*/ - compare: (a: T) => (b: T) => 0 | 1 | -1; - /** Equality checker - it can potentially be different - * than comparing. */ - eq: (a: T) => (b: T) => boolean; -}; +export type IndexedState = Record; /** - * Creates a `StateHandler` for a record based state. + * Creates a `StateHandler` for a indexed record based state with depth 1. * - `itemIdKey` parameter is used for determining which key should be used for indexing the record state. * - `compare` function allows determining if an incoming item is conflicting with its previous value. Your item * data structure must hence support some kind of versioning or timestamping in order to leverage this. */ -export const recordHandlerFactory = ({ +export const indexedStateFactory = >({ itemIdKey, compare, eq, -}: RecordStateOptions): StateHandler< - RecordState, +}: StateHandlerOptions): StateHandler< + IndexedState, [item: T], [itemId: string, partialItem: Partial], [itemId: string] > => { return { /* Handles creating a new item in the state */ - create: (state: RecordState, item: T) => ({ ...state, [item[itemIdKey]]: item }), + create: (state: IndexedState, item: T) => ({ ...state, [item[itemIdKey]]: item }), /* Handles updating an existing item in the state. Ensures the item exists to * correctly treat optimistic edits as no-ops when editing a non-existing item, * important for resolving noop edits as skippable mutations */ - update: (state: RecordState, itemId: string, partialItem: Partial) => + update: (state: IndexedState, itemId: string, partialItem: Partial) => state[itemId] ? { ...state, [itemId]: { ...state[itemId], ...partialItem } } : state, /* Handles deleting an item from state. Checks if the item exists in the state or * else no-ops. Important for resolving noop deletes as skippable mutations */ - remove: (state: RecordState, itemId: string) => { + remove: (state: IndexedState, itemId: string) => { if (state[itemId]) { const nextState = { ...state }; delete nextState[itemId]; @@ -67,7 +53,7 @@ export const recordHandlerFactory = ({ * * Important: If your state is very large, be aware that the strategy employed by * optimistron may not be well-suited for such scenarios */ - merge: (existing: RecordState, incoming: RecordState) => { + merge: (existing: IndexedState, incoming: IndexedState) => { const mergedState = { ...existing }; let mutated = false; /* keep track of mutations */ @@ -87,6 +73,8 @@ export const recordHandlerFactory = ({ const existingItem = existing[itemId]; const incomingItem = incoming[itemId]; + if (existingItem === incomingItem) continue; + if (!existingItem) { mutated = true; mergedState[itemId] = incomingItem; @@ -95,6 +83,7 @@ export const recordHandlerFactory = ({ const check = compare(incomingItem)(existingItem); + if (check === -1) throw OptimisticMergeResult.CONFLICT; if (check === 0) { /** If items are equal according to the `compare` function * but do not pass the `eq` check, then we have a conflict */ @@ -102,13 +91,9 @@ export const recordHandlerFactory = ({ else throw OptimisticMergeResult.CONFLICT; } - if (check === 1) { - mutated = true; /* valid update */ - mergedState[itemId] = incomingItem; - continue; - } - - throw OptimisticMergeResult.CONFLICT; + /* valid update */ + mutated = true; + mergedState[itemId] = incomingItem; } /** If no mutation has been detected at this point then diff --git a/src/state/record.spec.ts b/src/state/record.spec.ts deleted file mode 100644 index f13e932..0000000 --- a/src/state/record.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { OptimisticMergeResult } from '~transitions'; -import { recordHandlerFactory } from './record'; - -type TestItem = { id: string; version: number; value: string }; - -describe('RecordState', () => { - const compare = (a: TestItem) => (b: TestItem) => { - if (a.version > b.version) return 1; - if (a.version === b.version) return 0; - return -1; - }; - - const eq = (a: TestItem) => (b: TestItem) => a.id === b.id && a.value === b.value; - - const testHandler = recordHandlerFactory({ itemIdKey: 'id', compare, eq }); - - test('create', () => { - const item = { id: '1', version: 0, value: 'test' }; - const next = testHandler.create({}, item); - expect(next[1]).toEqual(item); - }); - - test('update', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const update: Partial = { value: 'newvalue', version: 1 }; - - const next = testHandler.update({ [item.id]: item }, item.id, update); - expect(next[1]).toEqual({ id: '1', version: 1, value: 'newvalue' }); - }); - - describe('remove', () => { - test('should delete entry', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const next = testHandler.remove({ [item.id]: item }, item.id); - expect(next).toEqual({}); - }); - - test('should noop if item does not exist', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const state = { [item.id]: item }; - const next = testHandler.remove(state, 'non-existing'); - expect(next).toEqual(state); - }); - }); - - describe('merge', () => { - test('should allow creations', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const next = testHandler.merge({}, { [item.id]: item }); - expect(next).toEqual({ [item.id]: item }); - }); - - test('should allow valid deletions', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const next = testHandler.merge({ [item.id]: item }, {}); - expect(next).toEqual({}); - }); - - test('shoud allow valid updates', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const update: TestItem = { id: '1', version: 2, value: 'test-update' }; - const existing = { [item.id]: item }; - const incoming = { [item.id]: update }; - expect(testHandler.merge(existing, incoming)).toEqual(incoming); - }); - - test('should detect noops and throw `SKIP`', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const existing = { [item.id]: item }; - const incoming = { [item.id]: item }; - expect(() => testHandler.merge(existing, incoming)).toThrow(OptimisticMergeResult.SKIP); - }); - - test('should detect conflicts and throw `CONFLICT`', () => { - const item: TestItem = { id: '1', version: 0, value: 'test' }; - const conflicting: TestItem = { id: '1', version: 0, value: 'test-conflict' }; - const existing = { [item.id]: item }; - const incoming = { [item.id]: conflicting }; - expect(() => testHandler.merge(existing, incoming)).toThrow(OptimisticMergeResult.CONFLICT); - expect(() => testHandler.merge(incoming, existing)).toThrow(OptimisticMergeResult.CONFLICT); - }); - }); -}); diff --git a/src/transitions.spec.ts b/src/transitions.spec.ts deleted file mode 100644 index f8dc0f0..0000000 --- a/src/transitions.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createTransitions } from '~actions'; -import type { TransitionAction } from '~transitions'; -import { TransitionDedupeMode, getTransitionMeta, processTransition } from '~transitions'; - -const TestTransitionID = `${Math.random()}`; - -const transition = createTransitions( - 'test::transition', - TransitionDedupeMode.OVERWRITE, -)((revision: number) => ({ payload: { revision } })); - -const transitionTrailing = createTransitions( - 'test::transition_with_history', - TransitionDedupeMode.TRAILING, -)((revision: number) => ({ - payload: { revision }, -})); - -const applyTransitions = (...tansitions: TransitionAction[]) => - tansitions.reduce[]>((next, curr) => processTransition(curr, next), []); - -describe('Transitions', () => { - describe('stage', () => { - test('should push staging transition', () => { - const stage = transition.stage(TestTransitionID, 1); - const processed = processTransition(stage, []); - - expect(processed.length).toEqual(1); - expect(processed[0]).toEqual(stage); - }); - - test('should replace staging transition if already in transition list', () => { - const existing = transition.stage(TestTransitionID, 1); - const stage = transition.stage(TestTransitionID, 2); - - const processed = applyTransitions(existing, stage); - - expect(processed.length).toEqual(1); - expect(processed[0]).toEqual(stage); - }); - - test('should keep trailing transition', () => { - const stage = transition.stage(TestTransitionID, 1); - const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); - - const processed = applyTransitions(stage, stageTrailing); - - expect(processed.length).toEqual(1); - expect(getTransitionMeta(processed[0]).trailing).toEqual(stage); - expect(processed[0].type).toEqual(stageTrailing.type); - expect(processed[0].payload).toEqual(stageTrailing.payload); - }); - - test('should maintain trailing transition on replicated action', () => { - const stage = transition.stage(TestTransitionID, 1); - const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); - - const processed = applyTransitions(stage, stageTrailing, stageTrailing); - - expect(processed.length).toEqual(1); - expect(getTransitionMeta(processed[0]).trailing).toEqual(stage); - expect(processed[0].type).toEqual(stageTrailing.type); - expect(processed[0].payload).toEqual(stageTrailing.payload); - }); - - test('should not keep trailing transition if new overwriting transition', () => { - const stage = transition.stage(TestTransitionID, 1); - const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); - - const processed = applyTransitions(stage, stageTrailing, stage); - - expect(processed.length).toEqual(1); - expect(getTransitionMeta(processed[0]).trailing).toBeUndefined(); - expect(processed[0].type).toEqual(stage.type); - expect(processed[0].payload).toEqual(stage.payload); - }); - }); - - describe('fail', () => { - test('should flag transition as failed', () => { - const stage = transition.stage(TestTransitionID, 1); - const fail = transition.fail(TestTransitionID, new Error()); - - const processed = applyTransitions(stage, fail); - - expect(processed.length).toEqual(1); - expect(getTransitionMeta(processed[0]).failed).toEqual(true); - expect(processed[0].type).toEqual(stage.type); - expect(processed[0].payload).toEqual(stage.payload); - }); - - test('should noop if no matching transition to fail', () => { - const stage = transition.stage(TestTransitionID, 1); - const fail = transition.fail(`${Math.random()}`, new Error()); - - const processed = applyTransitions(stage, fail); - expect(processed).toEqual([stage]); - }); - }); - - describe('stash', () => { - test('should remove staged transition matching transitionId', () => { - const stage = transition.stage(TestTransitionID, 1); - const stash = transition.stash(TestTransitionID); - - const processed = applyTransitions(stage, stash); - expect(processed).toEqual([]); - }); - - test('should noop if no matching transition to stash', () => { - const stage = transition.stage(TestTransitionID, 1); - const stash = transition.stash(`${Math.random()}`); - - const processed = applyTransitions(stage, stash); - expect(processed).toEqual([stage]); - }); - - test('should revert to trailing transition on stash if trailing', () => { - const stage = transition.stage(TestTransitionID, 1); - const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); - const stashTrailing = transitionTrailing.stash(TestTransitionID); - - const processed = applyTransitions(stage, stageTrailing, stashTrailing); - expect(processed).toEqual([stage]); - }); - }); - - describe('commit', () => { - test('should remove transition matching transitionId from transition list', () => { - const stageA = transition.stage(TestTransitionID, 1); - const stageB = transition.stage(`${Math.random()}`, 1); - const commitA = transition.commit(TestTransitionID); - - const processed = applyTransitions(stageA, stageB, commitA); - expect(processed).toEqual([stageB]); - }); - }); -}); diff --git a/src/transitions.ts b/src/transitions.ts index 0a737a3..1a02175 100644 --- a/src/transitions.ts +++ b/src/transitions.ts @@ -2,47 +2,51 @@ import type { Action, PrepareAction } from '@reduxjs/toolkit'; import { MetaKey } from '~constants'; import { type BoundReducer } from '~reducer'; import type { bindStateFactory } from '~state'; -import { cloneTransitionState, type TransitionState } from '~state'; +import { type TransitionState } from '~state'; export enum OptimisticMergeResult { SKIP = 'SKIP', CONFLICT = 'CONFLICT', } -export enum TransitionOperation { - AMEND, - COMMIT, - FAIL, - STAGE, - STASH, +export enum Operation { + AMEND = 'amend', + COMMIT = 'commit', + FAIL = 'fail', + STAGE = 'stage', + STASH = 'stash', } -export enum TransitionDedupeMode { +export enum DedupeMode { OVERWRITE, TRAILING, } -export type TransitionNamespace = `${string}::${string}`; -export type TransitionAction = A & { meta: { [MetaKey]: TransitionMeta } }; +export type TransitionNamespace = `${string}::${T}`; +export type WithTransition = T & { meta: { [MetaKey]: TransitionMeta } }; +export type TransitionPreparator>> = WithTransition; +export type TransitionAction = WithTransition>>; +export type StagedAction = TransitionAction; +export type CommittedAction = TransitionAction; export type TransitionMeta = { - conflict: boolean; - dedupe: TransitionDedupeMode; - failed: boolean; id: string; - operation: TransitionOperation; - trailing?: TransitionAction; + operation: Operation; + dedupe: DedupeMode; + conflict?: boolean; + failed?: boolean; + trailing?: StagedAction; }; /** Extracts the transition meta definitions on an action */ export const getTransitionMeta = (action: TransitionAction) => action.meta[MetaKey]; export const getTransitionID = (action: TransitionAction) => action.meta[MetaKey].id; -/** Hydrates an action's transition meta definition */ -export const withTransitionMeta = ( +/** Hydrates an action's transition meta definition */ +export const prepareTransition = ( action: ReturnType>, options: TransitionMeta, -): TransitionAction => ({ +): TransitionPreparator => ({ ...action, meta: { ...('meta' in action ? action.meta : {}), @@ -54,59 +58,79 @@ export const isTransition = (action: Action): action is TransitionAction => 'meta' in action && typeof action.meta === 'object' && action.meta !== null && MetaKey in action.meta; /** Checks wether an action is a transition for the supplied namespace */ -export const isTransitionForNamespace = ( - action: Action, - namespace: string, -): action is TransitionAction => isTransition(action) && action.type.startsWith(`${namespace}::`); +export const isTransitionForNamespace = (action: Action, namespace: string): action is TransitionAction => + isTransition(action) && action.type.startsWith(`${namespace}::`); + +export const toType = (type: TransitionNamespace, operation: T): TransitionNamespace => { + const parts = type.split('::'); + const base = parts.slice(0, parts.length - 1).join('::'); + + return `${base}::${operation}`; +}; /** Updates the transition meta of a transition action */ -export const updateTransition = ( - action: TransitionAction, - update: Partial, -): TransitionAction => ({ - ...action, - meta: { - ...action.meta, - [MetaKey]: { - ...action.meta[MetaKey], - ...update, +export const updateTransition = >(action: A, update: T) => + ({ + ...action, + meta: { + ...action.meta, + [MetaKey]: { + ...action.meta[MetaKey], + ...update, + }, }, - }, -}); - -export const processTransition = ( - transition: TransitionAction, - transitions: TransitionAction[], -): TransitionAction[] => { + }) satisfies TransitionAction as T['operation'] extends Operation ? TransitionAction : A; + +/** Maps a transition to a staged transition */ +export const toStaged = (action: TransitionAction, update: Partial = {}): StagedAction => + updateTransition( + { ...action, type: toType(action.type, Operation.STAGE) }, + { ...update, operation: Operation.STAGE }, + ); + +/** Maps a transition to a comitted transition */ +export const toCommit = (action: TransitionAction, update: Partial = {}): CommittedAction => + updateTransition( + { ...action, type: toType(action.type, Operation.COMMIT) }, + { ...update, operation: Operation.COMMIT }, + ); + +export const processTransition = (transition: TransitionAction, transitions: StagedAction[]): StagedAction[] => { const { operation, id, dedupe } = getTransitionMeta(transition); + const matchIdx = transitions.findIndex((entry) => id === getTransitionID(entry)); + const existing = transitions[matchIdx]; switch (operation) { /* During the `stage` or `amend` transition, check for an existing transition with the same ID. * If found, replace it; otherwise, add the new transition to the list */ - case TransitionOperation.STAGE: - case TransitionOperation.AMEND: { + case Operation.STAGE: + case Operation.AMEND: { + /** if no staging operation to amend return transitions in-place */ + if (matchIdx === -1 && operation === Operation.AMEND) return transitions; + + const stage = toStaged(transition, operation === Operation.AMEND ? getTransitionMeta(existing) : {}); const nextTransitions = [...transitions]; - const matchIdx = transitions.findIndex((entry) => id === getTransitionID(entry)); if (matchIdx !== -1) { - const existing = nextTransitions[matchIdx]; const trailing = existing.type === transition.type ? getTransitionMeta(existing).trailing : existing; /* When dedupe mode is set to `TRAILING`, store the previous transition as a * trailing transition. This helps in handling reversion to the previous * transition when stashing the current one. */ - if (dedupe === TransitionDedupeMode.TRAILING) { - nextTransitions[matchIdx] = updateTransition(transition, { trailing }); - } else nextTransitions[matchIdx] = transition; + if (dedupe === DedupeMode.TRAILING) { + nextTransitions[matchIdx] = updateTransition(stage, { trailing }); + } else nextTransitions[matchIdx] = stage; /* new transition */ - } else nextTransitions.push(transition); + } else nextTransitions.push(stage); return nextTransitions; } /* During the 'fail' transition, we flag the matching transition as failed */ - case TransitionOperation.FAIL: { + case Operation.FAIL: { + if (matchIdx === -1) return transitions; + return transitions.map((entry) => getTransitionID(entry) === id ? updateTransition(entry, { failed: true }) : entry, ); @@ -115,8 +139,7 @@ export const processTransition = ( /* During a 'stash' transition, check for trailing transitions related to the transition to * be stashed. If a trailing transition is found, replace the stashed transition, allowing * reversion to any trailing transitions. */ - case TransitionOperation.STASH: { - const matchIdx = transitions.findIndex((entry) => id === getTransitionID(entry)); + case Operation.STASH: { const existing = transitions[matchIdx]; if (existing) { @@ -133,7 +156,8 @@ export const processTransition = ( /* In the 'commit' transitions, remove the transition with the specified ID * from the list of transitions. */ - case TransitionOperation.COMMIT: { + case Operation.COMMIT: { + if (!transitions.length) return transitions; return transitions.filter((entry) => id !== getTransitionID(entry)); } } @@ -150,7 +174,7 @@ export const sanitizeTransitions = (state: TransitionState) => { const sanitized = state.transitions.reduce<{ mutated: boolean; - transitions: TransitionAction[]; + transitions: StagedAction[]; transitionState: TransitionState; }>( (acc, action) => { @@ -158,7 +182,7 @@ export const sanitizeTransitions = /* apply the transition action as if it had been committed in order * to detect if this action can still be applied or if - depending on * the use-case - it should be flagged as `conflicting` */ - const asIfCommitted = updateTransition(action, { operation: TransitionOperation.COMMIT }); + const asIfCommitted = toCommit(action); const nextState = boundReducer(acc.transitionState, asIfCommitted); const noop = nextState === acc.transitionState; @@ -184,8 +208,6 @@ export const sanitizeTransitions = break; /* Discard the optimistic transition */ case OptimisticMergeResult.CONFLICT: - /** FIXME: should we process the state update here ? */ - /* flag the optimistic transition as conflicting */ acc.transitions.push(updateTransition(action, { conflict: true })); break; } @@ -196,7 +218,7 @@ export const sanitizeTransitions = { mutated: false, transitions: [], - transitionState: cloneTransitionState(state), + transitionState: Object.assign({}, state), }, ); From 3d157bf6e4917638033b479157ec8b614ee2c4c5 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Sat, 17 Feb 2024 21:41:06 +0100 Subject: [PATCH 3/4] Add more test cases --- test/integration/indexed.spec.ts | 139 ++++++++++++++++++++ test/unit/optimistron.spec.ts | 67 ++++++++++ test/unit/reducer.spec.ts | 45 +++++++ test/unit/selectors.spec.ts | 113 ++++++++++++++++ test/unit/state.spec.ts | 101 +++++++++++++++ test/unit/state/indexed.spec.ts | 87 +++++++++++++ test/unit/transitions.spec.ts | 213 +++++++++++++++++++++++++++++++ test/utils/index.ts | 49 +++++++ 8 files changed, 814 insertions(+) create mode 100644 test/integration/indexed.spec.ts create mode 100644 test/unit/optimistron.spec.ts create mode 100644 test/unit/reducer.spec.ts create mode 100644 test/unit/selectors.spec.ts create mode 100644 test/unit/state.spec.ts create mode 100644 test/unit/state/indexed.spec.ts create mode 100644 test/unit/transitions.spec.ts create mode 100644 test/utils/index.ts diff --git a/test/integration/indexed.spec.ts b/test/integration/indexed.spec.ts new file mode 100644 index 0000000..a7243ee --- /dev/null +++ b/test/integration/indexed.spec.ts @@ -0,0 +1,139 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { optimistron } from '~optimistron'; +import { ReducerMap } from '~reducer'; +import { selectIsConflicting, selectIsFailed, selectIsOptimistic, selectOptimistic } from '~selectors'; +import { create, createItem, indexedState, reducer, selectState } from '~test/utils'; +import { toStaged, updateTransition } from '~transitions'; + +describe('optimistron', () => { + afterAll(() => ReducerMap.clear()); + + const optimisticReducer = optimistron('test', {}, indexedState, reducer); + const initial = optimisticReducer(undefined, { type: 'INIT' }); + + describe('IndexedState', () => { + describe('create', () => { + describe('stage', () => { + const item = createItem(); + const conflictItem = { ...item, revision: -1 }; + const amendedItem = { ...item, value: 'amended value' }; + + const stage = create.stage(item.id, item); + const amend = create.amend(item.id, amendedItem); + const fail = create.fail(item.id, new Error()); + const stash = create.stash(item.id); + const commit = create.commit(item.id); + const conflict = create.stage(item.id, conflictItem); + + const state = optimisticReducer(initial, stage); + + expect(state.state).toStrictEqual(initial.state); + expect(state.transitions).toStrictEqual([stage]); + expect(selectOptimistic(selectState)(state)).toEqual({ [item.id]: item }); + expect(selectIsOptimistic(item.id)(state)).toBe(true); + expect(selectIsFailed(item.id)(state)).toBe(false); + expect(selectIsConflicting(item.id)(state)).toBe(false); + + test('amend', () => { + const next = optimisticReducer(state, amend); + + expect(next.state).toStrictEqual(initial.state); + expect(next.transitions).toStrictEqual([toStaged(amend)]); + expect(selectOptimistic(selectState)(next)).toEqual({ [item.id]: amendedItem }); + expect(selectIsOptimistic(item.id)(next)).toBe(true); + expect(selectIsFailed(item.id)(next)).toBe(false); + expect(selectIsConflicting(item.id)(next)).toBe(false); + }); + + test('commit', () => { + const next = optimisticReducer(state, commit); + + expect(next.state).toStrictEqual({ [item.id]: item }); + expect(next.transitions).toStrictEqual([]); + expect(selectOptimistic(selectState)(next)).toEqual({ [item.id]: item }); + expect(selectIsOptimistic(item.id)(next)).toBe(false); + expect(selectIsFailed(item.id)(next)).toBe(false); + expect(selectIsConflicting(item.id)(next)).toBe(false); + }); + + test('stash', () => { + const next = optimisticReducer(state, stash); + + expect(next.state).toStrictEqual({}); + expect(next.transitions).toStrictEqual([]); + expect(selectOptimistic(selectState)(next)).toEqual({}); + expect(selectIsOptimistic(item.id)(next)).toBe(false); + expect(selectIsFailed(item.id)(next)).toBe(false); + expect(selectIsConflicting(item.id)(next)).toBe(false); + }); + + test('conflict', () => { + const next = [commit, conflict].reduce((prev, action) => optimisticReducer(prev, action), state); + + expect(next.state).toStrictEqual({ [item.id]: item }); + expect(next.transitions).toStrictEqual([updateTransition(conflict, { conflict: true })]); + expect(selectOptimistic(selectState)(next)).toEqual({ [item.id]: conflictItem }); + expect(selectIsOptimistic(item.id)(next)).toBe(true); + expect(selectIsFailed(item.id)(next)).toBe(false); + expect(selectIsConflicting(item.id)(next)).toBe(true); + }); + + describe('fail', () => { + const next = optimisticReducer(state, fail); + + expect(next.state).toStrictEqual(initial.state); + expect(next.transitions).toStrictEqual([updateTransition(stage, { failed: true })]); + expect(selectOptimistic(selectState)(next)).toEqual({ [item.id]: item }); + expect(selectIsOptimistic(item.id)(next)).toBe(true); + expect(selectIsFailed(item.id)(next)).toBe(true); + expect(selectIsConflicting(item.id)(next)).toBe(false); + + test('stage', () => { + const nextAfterRestage = optimisticReducer(next, stage); + + expect(nextAfterRestage.state).toStrictEqual(initial.state); + expect(nextAfterRestage.transitions).toStrictEqual([stage]); + expect(selectOptimistic(selectState)(nextAfterRestage)).toStrictEqual({ [item.id]: item }); + expect(selectIsOptimistic(item.id)(nextAfterRestage)).toBe(true); + expect(selectIsFailed(item.id)(nextAfterRestage)).toBe(false); + expect(selectIsConflicting(item.id)(nextAfterRestage)).toBe(false); + }); + + test('amend', () => { + const nextAfterAmend = optimisticReducer(next, amend); + + expect(nextAfterAmend.state).toStrictEqual(initial.state); + expect(nextAfterAmend.transitions).toStrictEqual([toStaged(amend, { failed: true })]); + expect(selectOptimistic(selectState)(nextAfterAmend)).toStrictEqual({ [item.id]: amendedItem }); + expect(selectIsOptimistic(item.id)(nextAfterAmend)).toBe(true); + expect(selectIsFailed(item.id)(nextAfterAmend)).toBe(true); + expect(selectIsConflicting(item.id)(nextAfterAmend)).toBe(false); + }); + + test('stash', () => { + const nextAfterStash = optimisticReducer(next, stash); + + expect(nextAfterStash.state).toStrictEqual({}); + expect(nextAfterStash.transitions).toStrictEqual([]); + expect(selectOptimistic(selectState)(nextAfterStash)).toEqual({}); + expect(selectIsOptimistic(item.id)(nextAfterStash)).toBe(false); + expect(selectIsFailed(item.id)(nextAfterStash)).toBe(false); + expect(selectIsConflicting(item.id)(nextAfterStash)).toBe(false); + }); + + test('commit', () => { + const nextAfterCommit = optimisticReducer(state, commit); + + expect(nextAfterCommit.state).toStrictEqual({ [item.id]: item }); + expect(nextAfterCommit.transitions).toStrictEqual([]); + expect(selectOptimistic(selectState)(nextAfterCommit)).toEqual({ [item.id]: item }); + expect(selectIsOptimistic(item.id)(nextAfterCommit)).toBe(false); + expect(selectIsFailed(item.id)(nextAfterCommit)).toBe(false); + expect(selectIsConflicting(item.id)(nextAfterCommit)).toBe(false); + }); + }); + }); + }); + }); +}); diff --git a/test/unit/optimistron.spec.ts b/test/unit/optimistron.spec.ts new file mode 100644 index 0000000..fca1b38 --- /dev/null +++ b/test/unit/optimistron.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +import { optimistron } from '~optimistron'; +import { ReducerMap } from '~reducer'; +import { create, createItem, indexedState, reducer } from '~test/utils'; +import { toCommit } from '~transitions'; + +describe('optimistron', () => { + afterEach(() => ReducerMap.clear()); + + const item = createItem(); + + test('should register reducer on `ReducerMap`', () => { + optimistron('test', {}, indexedState, reducer); + expect(ReducerMap.get('test')).toBeDefined(); + }); + + test('should throw if re-registering same action namespace', () => { + optimistron('test', {}, indexedState, reducer); + expect(() => optimistron('test', {}, indexedState, reducer)).toThrow(); + }); + + test('should support action sanitization', () => { + const sanitizeAction = mock((action) => action); + const optimisticReducer = optimistron('test', {}, indexedState, reducer, { sanitizeAction }); + const initial = optimisticReducer(undefined, { type: 'init' }); + const stage = create.stage(item.id, item); + + optimisticReducer(initial, stage); + expect(sanitizeAction).toHaveBeenCalledWith(stage); + }); + + test('should handle non-transition actions', () => { + const optimisticReducer = optimistron('test', {}, indexedState, reducer); + const initial = optimisticReducer(undefined, { type: 'init' }); + const nextState = optimisticReducer(initial, { type: 'any-action' }); + + expect(nextState).toStrictEqual(initial); + expect(nextState === initial).toBe(true); + }); + + test('comitting a non-staged action should noop', () => { + const optimisticReducer = optimistron('test', {}, indexedState, reducer); + const initial = optimisticReducer(undefined, { type: 'init' }); + const commit = create.commit(item.id); + const nextState = optimisticReducer(initial, commit); + + expect(nextState).toStrictEqual(initial); + expect(nextState === initial).toBe(true); + }); + + test('comitting should resolve staged transition and apply as if committed', () => { + const testReducerSpy = mock(reducer); + const optimisticReducer = optimistron('test', {}, indexedState, testReducerSpy); + const initial = optimisticReducer(undefined, { type: 'init' }); + const staged = create.stage(item.id, item); + const commit = create.commit(item.id); + [staged, commit].reduce(optimisticReducer, initial); + + /* The reducer is expected to be called three times: + * - Once for the initial 'init' action. + * - Once when committing the staged action. + * - Once when sanitizing the transition state to check for conflicts. + * (This re-application of the reducer ensures conflict detection.) */ + expect(testReducerSpy.mock.calls[1][1]).toEqual(toCommit(staged)); + }); +}); diff --git a/test/unit/reducer.spec.ts b/test/unit/reducer.spec.ts new file mode 100644 index 0000000..794044b --- /dev/null +++ b/test/unit/reducer.spec.ts @@ -0,0 +1,45 @@ +import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; + +import { ReducerIdKey } from '~constants'; +import { bindReducer } from '~reducer'; +import type { TransitionState } from '~state'; +import { bindStateFactory } from '~state'; +import type { TestIndexedState } from '~test/utils'; +import { createItem, indexedState, reducer, throwAction } from '~test/utils'; + +describe('bindReducer', () => { + const item = createItem(); + const bindState = bindStateFactory(indexedState); + const innerReducer = mock(reducer); + const boundReducer = bindReducer(innerReducer, bindState); + const action = { type: 'any-action' }; + + const transitionState: TransitionState = { + transitions: [], + state: { [item.id]: item }, + [ReducerIdKey]: 'test-reducer', + }; + + const warn = spyOn(console, 'warn').mockImplementation(mock()); + + beforeEach(() => innerReducer.mockClear()); + afterAll(() => warn.mockReset()); + + test('should return a bound reducer over the provided state handler', () => { + boundReducer(transitionState, action); + + expect(innerReducer).toHaveBeenCalledTimes(1); + expect(innerReducer.mock.calls[0][0]).toMatchObject(bindState(transitionState.state)); + expect(innerReducer.mock.calls[0][1]).toEqual(action); + }); + + describe('bound reducer', () => { + test('should return the unwrapped next transition state', () => { + expect(boundReducer(transitionState, action)).toEqual(transitionState.state); + }); + + test('should return the unwrapped transition state on error', () => { + expect(boundReducer(transitionState, throwAction)).toEqual(transitionState.state); + }); + }); +}); diff --git a/test/unit/selectors.spec.ts b/test/unit/selectors.spec.ts new file mode 100644 index 0000000..aac7f2c --- /dev/null +++ b/test/unit/selectors.spec.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { ReducerIdKey } from '~constants'; +import { optimistron } from '~optimistron'; +import { ReducerMap } from '~reducer'; +import { + selectConflictingTransition, + selectFailedTransition, + selectFailedTransitions, + selectIsConflicting, + selectIsFailed, + selectIsOptimistic, + selectOptimistic, +} from '~selectors'; +import { create, createIndexedState, createItem, indexedState, reducer, selectState } from '~test/utils'; +import { updateTransition } from '~transitions'; + +describe('selectors', () => { + beforeEach(() => optimistron('test', {}, indexedState, reducer)); + afterEach(() => ReducerMap.clear()); + + const item = createItem(); + const stage = create.stage(item.id, item); + + describe('selectOptimistic', () => { + const state = createIndexedState([stage]); + + test('should apply default selector if no registered reducer', () => { + expect( + selectOptimistic(() => 1337)({ + transitions: [], + [ReducerIdKey]: 'unknown', + state: 42, + }), + ).toEqual(1337); + }); + + test('should apply transitions as if committed and run selector', () => + expect(selectOptimistic(selectState)(state)).toEqual({ [item.id]: item })); + }); + + describe('selectFailedTransitions', () => { + const failed = updateTransition(stage, { failed: true }); + const state = createIndexedState([stage, failed]); + + test('should return transitions flagged as `failed`', () => + expect(selectFailedTransitions(state)).toEqual([failed])); + }); + + describe('selectFailedTransition', () => { + const failed = updateTransition(stage, { failed: true }); + const state = createIndexedState([failed]); + + test('should return transition flagged as `failed` for `transitionId`', () => + expect(selectFailedTransition(item.id)(state)).toEqual(failed)); + + test('should return empty if no failed transitions matching `transitionId`', () => + expect(selectFailedTransition('unknown')(state)).toBeUndefined()); + }); + + describe('selectConflictingTransition', () => { + const conflict = updateTransition(stage, { conflict: true }); + const state = createIndexedState([conflict]); + + test('should return transitions flagged as `conflict` for `transitionId`', () => + expect(selectConflictingTransition(item.id)(state)).toEqual(conflict)); + + test('should return empty if no conflicting transitions matching `transitionId`', () => + expect(selectConflictingTransition('unknown')(state)).toBeUndefined()); + }); + + describe('selectIsOptimistic', () => { + const state = createIndexedState([stage]); + + test('should return `true` if `transitionId` in transition list', () => + expect(selectIsOptimistic(item.id)(state)).toEqual(true)); + + test('should return `false` if not', () => { + const committedState = createIndexedState(); + committedState.state = { [item.id]: item }; + + expect(selectIsOptimistic(item.id)(createIndexedState())).toEqual(false); + expect(selectIsOptimistic(item.id)(committedState)).toEqual(false); + }); + }); + + describe('selectIsFailed', () => { + const failed = updateTransition(stage, { failed: true }); + const state = createIndexedState([stage]); + const failedState = createIndexedState([failed]); + + test('should return `true` if failed transition for `transitionId` exists', () => + expect(selectIsFailed(item.id)(failedState)).toEqual(true)); + + test('should return `false` if not', () => { + expect(selectIsFailed('unknown')(failedState)).toEqual(false); + expect(selectIsFailed(item.id)(state)).toEqual(false); + }); + }); + + describe('selectIsConflicting', () => { + const conflict = updateTransition(stage, { conflict: true }); + const state = createIndexedState([stage]); + const conflictingState = createIndexedState([conflict]); + + test('should return `true` if conflicting transition for `transitionId` exists', () => + expect(selectIsConflicting(item.id)(conflictingState)).toEqual(true)); + + test('should return `false` if not', () => { + expect(selectIsConflicting('unknown')(conflictingState)).toEqual(false); + expect(selectIsConflicting(item.id)(state)).toEqual(false); + }); + }); +}); diff --git a/test/unit/state.spec.ts b/test/unit/state.spec.ts new file mode 100644 index 0000000..84a0662 --- /dev/null +++ b/test/unit/state.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { ReducerIdKey } from '~constants'; +import type { StateHandler } from '~state'; +import { bindStateFactory, buildTransitionState, isTransitionState, transitionStateFactory } from '~state'; +import { create, createIndexedState, createItem } from '~test/utils'; + +describe('state', () => { + describe('bindStateFactory', () => { + describe('should bind', () => { + const create = mock(); + const update = mock(); + const remove = mock(); + const merge = mock(); + + const handler: StateHandler = { create, update, remove, merge }; + const bindState = bindStateFactory(handler); + + const state = Symbol('state'); + const nextState = Symbol('next_state'); + const boundState = bindState(state); + + const mockParams = Array.from({ length: 5 }, () => Math.random()); + + test('create', () => { + boundState.create(...mockParams); + expect(create).toHaveBeenCalledWith(state, ...mockParams); + }); + + test('update', () => { + boundState.update(...mockParams); + expect(update).toHaveBeenCalledWith(state, ...mockParams); + }); + + test('remove', () => { + boundState.remove(...mockParams); + expect(remove).toHaveBeenCalledWith(state, ...mockParams); + }); + + test('merge', () => { + boundState.merge(nextState); + expect(merge).toHaveBeenCalledWith(state, nextState); + }); + + test('getState', () => expect(boundState.getState()).toEqual(state)); + }); + }); + + describe('isTransitionState', () => { + test('should return `true` if `ReducerIdKey` in parameter', () => { + expect(isTransitionState({ [ReducerIdKey]: 'test' })).toBe(true); + }); + + test('should return `false` otherwise', () => { + expect(isTransitionState({})).toBe(false); + }); + }); + + describe('buildTransitionState', () => { + test('should return state clone if already is a transition state', () => { + const state = createIndexedState(); + const result = buildTransitionState(state, [], 'test'); + + expect(isTransitionState(result)).toBe(true); + expect(state).toMatchObject(result); + }); + + test('should build transition state otherwise', () => { + const result = buildTransitionState({}, [], 'test'); + + expect(isTransitionState(result)).toBe(true); + expect(result).toMatchObject(createIndexedState()); + }); + }); + + describe('transitionStateFactory', () => { + test('should return reference if nothing changed', () => { + const state = createIndexedState(); + const next = transitionStateFactory(state)(state.state, state.transitions); + + expect(state === next).toBe(true); + }); + + test('should return updated copy if state changed', () => { + const item = createItem(); + const state = createIndexedState(); + const next = transitionStateFactory(state)({ [item.id]: item }, state.transitions); + + expect(state !== next).toBe(true); + expect(next.state).toEqual({ [item.id]: item }); + }); + + test('should return updated copy if transitions changed', () => { + const item = createItem(); + const state = createIndexedState(); + const next = transitionStateFactory(state)({}, [create.stage(item.id, item)]); + + expect(state !== next).toBe(true); + expect(next.transitions).toEqual([create.stage(item.id, item)]); + }); + }); +}); diff --git a/test/unit/state/indexed.spec.ts b/test/unit/state/indexed.spec.ts new file mode 100644 index 0000000..c95834c --- /dev/null +++ b/test/unit/state/indexed.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; + +import { createItem, indexedState, type TestItem } from '~test/utils'; +import { OptimisticMergeResult } from '~transitions'; + +describe('IndexedState', () => { + const item = createItem(); + + describe('create', () => { + test('should add a new entry', () => { + const next = indexedState.create({}, item); + expect(next[item.id]).toEqual(item); + }); + }); + + describe('update', () => { + const update: Partial = { value: 'newvalue', revision: 1 }; + + test('should edit entry if it exists', () => { + const next = indexedState.update({ [item.id]: item }, item.id, update); + expect(next[item.id]).toEqual({ ...item, ...update }); + }); + + test('should return state in-place otherwise', () => { + const initial = { [item.id]: item }; + const next = indexedState.update(initial, 'unknown', update); + expect(next).toEqual(initial); + }); + }); + + describe('remove', () => { + test('should delete entry if it exists', () => { + const next = indexedState.remove({ [item.id]: item }, item.id); + expect(next).toEqual({}); + }); + + test('should return state in-place otherwise', () => { + const state = { [item.id]: item }; + const next = indexedState.remove(state, 'non-existing'); + expect(next).toEqual(state); + }); + }); + + describe('merge', () => { + test('should allow creations', () => { + const next = indexedState.merge({}, { [item.id]: item }); + expect(next).toEqual({ [item.id]: item }); + }); + + test('should allow valid deletions', () => { + const next = indexedState.merge({ [item.id]: item }, {}); + expect(next).toEqual({}); + }); + + test('shoud allow valid updates', () => { + const update: TestItem = { ...item, revision: 2, value: 'test-update' }; + const existing = { [item.id]: item }; + const incoming = { [item.id]: update }; + + expect(indexedState.merge(existing, incoming)).toEqual(incoming); + }); + + test('should detect noops and throw `SKIP`', () => { + const existing = { [item.id]: item }; + const incoming = { [item.id]: item }; + + expect(() => indexedState.merge(existing, incoming)).toThrow(OptimisticMergeResult.SKIP); + }); + + test('should detect conflicts throw `CONFLICT` if compare check fails', () => { + const conflicting: TestItem = { ...item, revision: -1 }; + const existing = { [item.id]: item }; + const incoming = { [item.id]: conflicting }; + + expect(() => indexedState.merge(existing, incoming)).toThrow(OptimisticMergeResult.CONFLICT); + }); + + test('should detect conflicts and throw `CONFLICT` if equality check fails', () => { + const conflicting: TestItem = { ...item, value: 'test-conflict' }; + const existing = { [item.id]: item }; + const incoming = { [item.id]: conflicting }; + + expect(() => indexedState.merge(existing, incoming)).toThrow(OptimisticMergeResult.CONFLICT); + expect(() => indexedState.merge(incoming, existing)).toThrow(OptimisticMergeResult.CONFLICT); + }); + }); +}); diff --git a/test/unit/transitions.spec.ts b/test/unit/transitions.spec.ts new file mode 100644 index 0000000..dee38a2 --- /dev/null +++ b/test/unit/transitions.spec.ts @@ -0,0 +1,213 @@ +import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { createTransitions } from '~actions'; +import { bindReducer } from '~reducer'; +import { bindStateFactory } from '~state'; +import { create, createIndexedState, createItem, edit, indexedState, reducer } from '~test/utils'; +import type { StagedAction, TransitionAction } from '~transitions'; +import { + DedupeMode, + OptimisticMergeResult, + getTransitionMeta, + processTransition, + sanitizeTransitions, + toCommit, + updateTransition, +} from '~transitions'; + +const TestTransitionID = `${Math.random()}`; + +const transition = createTransitions( + 'test::transition', + DedupeMode.OVERWRITE, +)((revision: number) => ({ payload: { revision } })); + +const transitionTrailing = createTransitions( + 'test::transition_with_history', + DedupeMode.TRAILING, +)((revision: number) => ({ + payload: { revision }, +})); + +const applyTransitions = (...tansitions: TransitionAction[]) => + tansitions.reduce((next, curr) => processTransition(curr, next), []); + +describe('processTransition', () => { + describe('stage', () => { + test('should push staging transition', () => { + const stage = transition.stage(TestTransitionID, 1); + const processed = processTransition(stage, []); + + expect(processed.length).toEqual(1); + expect(processed[0]).toEqual(stage); + }); + + test('should replace staging transition if already in transition list', () => { + const existing = transition.stage(TestTransitionID, 1); + const stage = transition.stage(TestTransitionID, 2); + const processed = applyTransitions(existing, stage); + + expect(processed.length).toEqual(1); + expect(processed[0]).toEqual(stage); + }); + + test('should keep trailing transition', () => { + const stage = transition.stage(TestTransitionID, 1); + const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); + const processed = applyTransitions(stage, stageTrailing); + + expect(processed.length).toEqual(1); + expect(getTransitionMeta(processed[0]).trailing).toEqual(stage); + expect(processed[0].type).toEqual(stageTrailing.type); + expect('payload' in processed[0] && processed[0].payload).toEqual(stageTrailing.payload); + }); + + test('should maintain trailing transition on replicated action', () => { + const stage = transition.stage(TestTransitionID, 1); + const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); + const processed = applyTransitions(stage, stageTrailing, stageTrailing); + + expect(processed.length).toEqual(1); + expect(getTransitionMeta(processed[0]).trailing).toEqual(stage); + expect(processed[0].type).toEqual(stageTrailing.type); + expect('payload' in processed[0] && processed[0].payload).toEqual(stageTrailing.payload); + }); + + test('should not keep trailing transition if new overwriting transition', () => { + const stage = transition.stage(TestTransitionID, 1); + const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); + const processed = applyTransitions(stage, stageTrailing, stage); + + expect(processed.length).toEqual(1); + expect(getTransitionMeta(processed[0]).trailing).toBeUndefined(); + expect(processed[0].type).toEqual(stage.type); + expect('payload' in processed[0] && processed[0].payload).toEqual(stage.payload); + }); + }); + + describe('fail', () => { + test('should flag transition as failed', () => { + const stage = transition.stage(TestTransitionID, 1); + const fail = transition.fail(TestTransitionID, new Error()); + const processed = applyTransitions(stage, fail); + + expect(processed.length).toEqual(1); + expect(getTransitionMeta(processed[0]).failed).toEqual(true); + expect(processed[0].type).toEqual(stage.type); + expect('payload' in processed[0] && processed[0].payload).toEqual(stage.payload); + }); + + test('should noop if no matching transition to fail', () => { + const stage = transition.stage(TestTransitionID, 1); + const fail = transition.fail(`${Math.random()}`, new Error()); + const processed = applyTransitions(stage, fail); + + expect(processed).toEqual([stage]); + }); + }); + + describe('stash', () => { + test('should remove staged transition matching transitionId', () => { + const stage = transition.stage(TestTransitionID, 1); + const stash = transition.stash(TestTransitionID); + const processed = applyTransitions(stage, stash); + + expect(processed).toEqual([]); + }); + + test('should noop if no matching transition to stash', () => { + const stage = transition.stage(TestTransitionID, 1); + const stash = transition.stash(`${Math.random()}`); + const processed = applyTransitions(stage, stash); + + expect(processed).toEqual([stage]); + }); + + test('should revert to trailing transition on stash if trailing', () => { + const stage = transition.stage(TestTransitionID, 1); + const stageTrailing = transitionTrailing.stage(TestTransitionID, 2); + const stashTrailing = transitionTrailing.stash(TestTransitionID); + const processed = applyTransitions(stage, stageTrailing, stashTrailing); + + expect(processed).toEqual([stage]); + }); + }); + + describe('commit', () => { + test('should remove transition matching transitionId from transition list', () => { + const stageA = transition.stage(TestTransitionID, 1); + const stageB = transition.stage(`${Math.random()}`, 1); + const commitA = transition.commit(TestTransitionID); + const processed = applyTransitions(stageA, stageB, commitA); + + expect(processed).toEqual([stageB]); + }); + }); +}); + +describe('sanitizeTransition', () => { + const item = createItem(); + + const stage = create.stage(item.id, item); + const commit = toCommit(stage); + const noop = edit.stage(item.id, item); /* noops because no matching item to update */ + const conflict = edit.stage(item.id, { ...item, revision: item.revision - 1 }); + + const innerReducer = mock(reducer); + const bindState = bindStateFactory(indexedState); + const boundReducer = bindReducer(innerReducer, bindState); + + let mergeError: unknown; + + const baseMerge = indexedState.merge; + + const mergeSpy = spyOn(indexedState, 'merge').mockImplementation((...args) => { + try { + return baseMerge(...args); + } catch (err) { + mergeError = err; + throw err; + } + }); + + afterEach(() => { + innerReducer.mockClear(); + mergeSpy.mockClear(); + mergeError = undefined; + }); + + afterAll(() => mergeSpy.mockRestore()); + + test('should apply transitions as if they were committed', () => { + const result = sanitizeTransitions(boundReducer, bindState)(createIndexedState([stage])); + + expect(result).toEqual([stage]); + expect(innerReducer).toHaveBeenCalledTimes(1); + expect(innerReducer.mock.calls[0][1]).toEqual(commit); + }); + + test('should keep transition if it mutates state', () => { + const result = sanitizeTransitions(boundReducer, bindState)(createIndexedState([stage])); + expect(result).toEqual([stage]); + }); + + test('should discard transitions that do not mutate state', () => { + const result = sanitizeTransitions(boundReducer, bindState)(createIndexedState([noop])); + expect(result).toEqual([]); + }); + + test('should discard transitions which trigger a `SKIP` error', () => { + const result = sanitizeTransitions(boundReducer, bindState)(createIndexedState([noop])); + + expect(mergeError).toEqual(OptimisticMergeResult.SKIP); + expect(result).toEqual([]); + }); + + test('should keep transitions which trigger a `CONFLICT` error', () => { + const initial = createIndexedState([conflict]); + initial.state[item.id] = item; + const result = sanitizeTransitions(boundReducer, bindState)(initial); + + expect(mergeError).toEqual(OptimisticMergeResult.CONFLICT); + expect(result).toEqual([updateTransition(conflict, { conflict: true })]); + }); +}); diff --git a/test/utils/index.ts b/test/utils/index.ts new file mode 100644 index 0000000..22089cd --- /dev/null +++ b/test/utils/index.ts @@ -0,0 +1,49 @@ +import { createTransitions } from '~actions'; +import { ReducerIdKey } from '~constants'; +import type { HandlerReducer } from '~reducer'; +import type { TransitionState } from '~state'; +import { indexedStateFactory } from '~state/indexed'; +import type { StagedAction } from '~transitions'; + +export type TestItem = { id: string; revision: number; value: string }; +export type TestIndexedState = Record; + +export const createItem = (data?: Partial): TestItem => ({ + id: data?.id ?? Math.round(Math.random() * 1000).toString(), + revision: data?.revision ?? 0, + value: data?.value ?? 'test_value', +}); + +/** testing actions */ +export const create = createTransitions('test::add')((item: TestItem) => ({ payload: { item } })); +export const edit = createTransitions('test::edit')((item: TestItem) => ({ payload: { item } })); +export const throwAction = { type: 'throw ' }; + +export const selectState = ({ state }: TransitionState) => state; + +export const indexedState = indexedStateFactory({ + itemIdKey: 'id', + compare: (a: TestItem) => (b: TestItem) => { + if (a.revision > b.revision) return 1; + if (a.revision === b.revision) return 0; + return -1; + }, + eq: (a: TestItem) => (b: TestItem) => a.id === b.id && a.value === b.value, +}); + +export const reducer: HandlerReducer = ( + handler, + action, +) => { + if (action.type === throwAction.type) throw new Error('test error'); + if (create.match(action)) return handler.create(action.payload.item); + if (edit.match(action)) return handler.update(action.payload.item.id, action.payload.item); + + return handler.getState(); +}; + +export const createIndexedState = (transitions: StagedAction[] = []): TransitionState => ({ + [ReducerIdKey]: 'test', + state: {}, + transitions, +}); From 10f0eeb3ea4a7ea65b9515528fd8b53067564aef Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Sat, 17 Feb 2024 21:41:25 +0100 Subject: [PATCH 4/4] Update usecases --- README.md | 2 +- usecases/lib/components/graph/TransitionHistoryProvider.tsx | 4 ++-- usecases/lib/store/actions.ts | 4 ++-- usecases/lib/store/reducer.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3aa5088..8432ea2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Next, create an optimistic reducer : export const todosReducer = optimistron( 'todos', initial, - recordHandlerFactory({ itemIdKey: 'id', compare, eq }) // see section about state handlers + indexedStateFactory({ itemIdKey: 'id', compare, eq }) // see section about state handlers ({ getState, create, update, remove }, action) => { if (createTodo.match(action)) return create(action.payload.todo); if (editTodo.match(action)) return update(action.payload.id, action.payload.update); diff --git a/usecases/lib/components/graph/TransitionHistoryProvider.tsx b/usecases/lib/components/graph/TransitionHistoryProvider.tsx index c9908c1..342680f 100644 --- a/usecases/lib/components/graph/TransitionHistoryProvider.tsx +++ b/usecases/lib/components/graph/TransitionHistoryProvider.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import type { TransitionAction } from '~transitions'; -import { TransitionOperation, getTransitionMeta } from '~transitions'; +import { Operation, getTransitionMeta } from '~transitions'; import type { TransitionEventBus } from '~usecases/lib/store/middleware'; import { selectTransitions } from '~usecases/lib/store/selectors'; @@ -22,7 +22,7 @@ export const TransitionHistoryProvider: FC = ({ children, eventBus }) => eventBus.subscribe((transition) => { setCommitted((history) => { const meta = getTransitionMeta(transition); - if (meta.operation === TransitionOperation.COMMIT) return [...history, transition]; + if (meta.operation === Operation.COMMIT) return [...history, transition]; return history; }); }), diff --git a/usecases/lib/store/actions.ts b/usecases/lib/store/actions.ts index 4ed4ca1..65a9abf 100644 --- a/usecases/lib/store/actions.ts +++ b/usecases/lib/store/actions.ts @@ -1,6 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; import { createTransitions } from '~actions'; -import { TransitionDedupeMode } from '~transitions'; +import { DedupeMode } from '~transitions'; import type { Todo } from '~usecases/lib/store/types'; const create = (todo: Todo) => ({ payload: { todo } }); @@ -9,7 +9,7 @@ const remove = (id: string) => ({ payload: { id } }); export const createTodo = createTransitions('todos::add')(create); export const editTodo = createTransitions('todos::edit')(edit); -export const deleteTodo = createTransitions('todos::delete', TransitionDedupeMode.TRAILING)(remove); +export const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)(remove); export type OptimisticActions = | ReturnType diff --git a/usecases/lib/store/reducer.ts b/usecases/lib/store/reducer.ts index 113bc79..2fa348f 100644 --- a/usecases/lib/store/reducer.ts +++ b/usecases/lib/store/reducer.ts @@ -1,5 +1,5 @@ import { optimistron } from '~optimistron'; -import { recordHandlerFactory } from '~state/record'; +import { indexedStateFactory } from '~state/indexed'; import { createTodo, deleteTodo, editTodo, sync } from '~usecases/lib/store/actions'; import type { Todo } from '~usecases/lib/store/types'; @@ -29,7 +29,7 @@ const eq = (a: Todo) => (b: Todo) => a.done === b.done && a.value === b.value; export const todos = optimistron( 'todos', initial, - recordHandlerFactory({ itemIdKey: 'id', compare, eq }), + indexedStateFactory({ itemIdKey: 'id', compare, eq }), ({ getState, create, update, remove }, action) => { if (createTodo.match(action)) return create(action.payload.todo); if (editTodo.match(action)) return update(action.payload.id, action.payload.todo);