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/bun.lockb b/bun.lockb index 32190eb..af0475a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..c1eae41 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] + +coverage = true +coverageThreshold = 0.9 +coverageSkipTestFiles = true \ No newline at end of file diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 00990b9..0000000 --- a/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - globals: { crypto: require('crypto') }, -}; diff --git a/package.json b/package.json index 92fa1af..6355e90 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,43 @@ { - "name": "optimistron", - "packageManager": "yarn@1.22.19", - "devDependencies": { - "@types/jest": "^29.5.5", - "@types/lodash": "^4.14.202", - "@types/react-dom": "^18.2.14", - "@typescript-eslint/eslint-plugin": "^6.19.1", - "@typescript-eslint/parser": "^6.19.1", - "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", - "ts-jest": "^29.1.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", - "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/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.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), }, ); 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/src/transitions.spec.ts b/test/unit/transitions.spec.ts similarity index 59% rename from src/transitions.spec.ts rename to test/unit/transitions.spec.ts index f8dc0f0..dee38a2 100644 --- a/src/transitions.spec.ts +++ b/test/unit/transitions.spec.ts @@ -1,25 +1,37 @@ +import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; import { createTransitions } from '~actions'; -import type { TransitionAction } from '~transitions'; -import { TransitionDedupeMode, getTransitionMeta, processTransition } from '~transitions'; +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', - TransitionDedupeMode.OVERWRITE, + DedupeMode.OVERWRITE, )((revision: number) => ({ payload: { revision } })); const transitionTrailing = createTransitions( 'test::transition_with_history', - TransitionDedupeMode.TRAILING, + DedupeMode.TRAILING, )((revision: number) => ({ payload: { revision }, })); const applyTransitions = (...tansitions: TransitionAction[]) => - tansitions.reduce[]>((next, curr) => processTransition(curr, next), []); + tansitions.reduce((next, curr) => processTransition(curr, next), []); -describe('Transitions', () => { +describe('processTransition', () => { describe('stage', () => { test('should push staging transition', () => { const stage = transition.stage(TestTransitionID, 1); @@ -32,7 +44,6 @@ describe('Transitions', () => { 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); @@ -42,37 +53,34 @@ describe('Transitions', () => { 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); + 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(processed[0].payload).toEqual(stageTrailing.payload); + 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(processed[0].payload).toEqual(stage.payload); + expect('payload' in processed[0] && processed[0].payload).toEqual(stage.payload); }); }); @@ -80,20 +88,19 @@ describe('Transitions', () => { 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); + 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]); }); }); @@ -102,16 +109,16 @@ describe('Transitions', () => { 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]); }); @@ -119,8 +126,8 @@ describe('Transitions', () => { 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]); }); }); @@ -130,9 +137,77 @@ describe('Transitions', () => { 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, +}); 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"] + } } 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);