Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 55 additions & 323 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

312 changes: 157 additions & 155 deletions README.md

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,31 @@
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.js"
},
"./core": {
"types": "./lib/core/index.d.ts",
"import": "./lib/core/index.js"
},
"./saga": {
"types": "./lib/saga/index.d.ts",
"import": "./lib/saga/index.js"
}
},
"typesVersions": {
"*": {
"core": ["lib/core/index.d.ts"],
"saga": ["lib/saga/index.d.ts"]
}
},
"peerDependencies": {
"@reduxjs/toolkit": "^2.11.2",
"redux": "^5.0.1"
"redux": "^5.0.1",
"redux-saga": "^1.4.2"
},
"peerDependenciesMeta": {
"redux-saga": {
"optional": true
}
},
"devDependencies": {
"@types/lodash": "^4.17.23",
Expand All @@ -40,7 +60,6 @@
"react-redux": "^9.2.0",
"react-router-dom": "^6.30.3",
"redux-saga": "^1.4.2",
"redux-thunk": "^3.1.0",
"serve": "^14.2.5",
"typescript": "^5.9.3"
},
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
23 changes: 22 additions & 1 deletion src/actions/types.ts → src/core/actions/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ActionCreatorWithPreparedPayload, PayloadAction, PrepareAction } from '@reduxjs/toolkit';
import type { Action, ActionCreatorWithPreparedPayload, PayloadAction, PrepareAction } from '@reduxjs/toolkit';

import type { TransitionMeta, TransitionNamespace } from '~/transitions';
import { type Operation } from '~/transitions';
Expand Down Expand Up @@ -38,6 +38,27 @@ export type TransitionPayloadAction<Type extends string, Op extends Operation, P
PrepareError<PA>
>;

/** Structural constraint for the action map returned by `createTransitions`.
* Only constrains what the saga effects actually consume: `stage.match` for
* the watcher, lifecycle methods for dispatch. Rest params use `any` —
* required for contravariant compatibility with RTK's prepared action creators
* (a fn accepting `Item` is not assignable to one accepting `unknown`).
* Payload type safety flows through `InferPayload`, not here. */
export type TransitionActions = {
stage: { match(action: Action): boolean };
amend: (...args: any[]) => Action;
commit: (...args: any[]) => Action;
fail: (...args: any[]) => Action;
stash: (...args: any[]) => Action;
};

/** Extracts the stage payload type from a `createTransitions` result.
* Works via the callable signature on `stage` (which exists on the actual
* `ActionCreatorWithPreparedPayload` even though the constraint only
* requires `.match`). */
export type InferPayload<A extends TransitionActions> =
A extends { stage: (...args: any[]) => { payload: infer P } } ? P : unknown;

export type { PathMap as PathIds } from '~/utils/types';

/** Picks the identity keys from T — the "address" of an entity */
Expand Down
File renamed without changes.
18 changes: 18 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export { createCrudTransitions, createTransition, createTransitions, crudPrepare } from './actions';
export { optimistron } from './optimistron';
export { listState } from './state/list';
export { nestedRecordState, recordState } from './state/record';
export { singularState } from './state/singular';
export { getTransitionID, getTransitionMeta, isTransition, Operation, OptimisticMergeResult, TransitionMode } from './transitions';

export type { HandlerReducer, ReducerConfig } from './reducer';
export type { ActionMatcher, BoundStateHandler, CrudActionMap, StateHandler, TransitionState, VersioningOptions, WiredStateHandler } from './state/types';

export type { DeleteDTO, InferPayload, ItemPath, TransitionActions, UpdateDTO } from './actions/types';
export type { ListStateOptions } from './state/list';
export type { NestedRecordStateOptions, RecordStateOptions } from './state/record';
export type { SingularStateOptions } from './state/singular';
export type { MaybeNull, PathMap as PathIds, StringKeys } from './utils/types';

export type { RecordState as IndexedState, RecursiveRecordState as NestedRecord, PathOf } from './state/record';
export type { CommittedAction, StagedAction, Transition, TransitionAction, TransitionMeta, TransitionNamespace } from './transitions';
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 0 additions & 1 deletion src/state/record.ts → src/core/state/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { resolveCompare, type CrudActionMap, type VersioningOptions, type WiredS

export type RecordStateOptions<T> = VersioningOptions<T> & { key: StringKeys<T> };
export type NestedRecordStateOptions<T, Keys extends readonly StringKeys<T>[]> = VersioningOptions<T> & { keys: Keys };

export type RecordState<T> = Record<string, T>;

/** Recursively builds a nested Record type from a keys tuple.
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
21 changes: 3 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,3 @@
export { createCrudTransitions, createTransition, createTransitions, crudPrepare } from './actions';
export { optimistron } from './optimistron';
export { listState } from './state/list';
export { nestedRecordState, recordState } from './state/record';
export { singularState } from './state/singular';
export { getTransitionID, getTransitionMeta, isTransition, Operation, OptimisticMergeResult, TransitionMode } from './transitions';

export type { HandlerReducer, ReducerConfig } from './reducer';
export type { ActionMatcher, BoundStateHandler, CrudActionMap, StateHandler, TransitionState, VersioningOptions, WiredStateHandler } from './state/types';

export type { DeleteDTO, ItemPath, UpdateDTO } from './actions/types';
export type { ListStateOptions } from './state/list';
export type { NestedRecordStateOptions, RecordStateOptions } from './state/record';
export type { SingularStateOptions } from './state/singular';
export type { MaybeNull, PathMap as PathIds, StringKeys } from './utils/types';

export type { RecordState as IndexedState, RecursiveRecordState as NestedRecord, PathOf } from './state/record';
export type { CommittedAction, StagedAction, Transition, TransitionAction, TransitionMeta, TransitionNamespace } from './transitions';
export * from './core';
export { handleTransition, retryFailed, watchTransition } from './saga';
export type { TransitionSagaOptions } from './saga';
69 changes: 69 additions & 0 deletions src/saga/effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { call, put, select, takeEvery, takeLeading } from 'redux-saga/effects';
import type { Action } from '@reduxjs/toolkit';

import { getTransitionMeta, TransitionMode } from '../core/transitions';
import type { StagedAction } from '../core/transitions';
import type { InferPayload, TransitionActions } from '../core/actions/types';

export type TransitionSagaOptions<P, R = unknown> = {
/** Transform before commit. Return value is passed to `amend()`.
* If omitted, commits directly without amending. */
amend?: (payload: P, result: Awaited<R>) => P;
};

/** Inner generator that orchestrates a single transition lifecycle.
* Calls the effect, optionally amends, then commits or fails/stashes
* based on the `TransitionMode` already declared on the transition meta. */
export const handleTransition = <A extends TransitionActions, R>(
actions: A,
effect: (payload: InferPayload<A>, action: StagedAction<InferPayload<A>>) => R,
options?: TransitionSagaOptions<InferPayload<A>, R>,
) =>
function* (action: StagedAction<InferPayload<A>>) {
const { id, mode } = getTransitionMeta(action);
try {
const result: Awaited<R> = yield call(effect, action.payload, action);
if (options?.amend) yield put(actions.amend(id, options.amend(action.payload, result)));
yield put(actions.commit(id));
} catch (error) {
if (mode === TransitionMode.REVERTIBLE) yield put(actions.stash(id));
else yield put(actions.fail(id, error));
}
};

/** Returns a `takeEvery` effect that watches for staged transition
* actions and orchestrates the API call → commit/fail lifecycle.
*
* ```ts
* yield watchTransition(createEpic, api.create, {
* amend: (payload, result) => ({ ...payload, id: result.id }),
* });
* yield watchTransition(editEpic, api.update);
* yield watchTransition(deleteEpic, api.delete);
* // stash-on-fail is automatic for REVERTIBLE transitions
* ``` */
export const watchTransition = <A extends TransitionActions, R>(
actions: A,
effect: (payload: InferPayload<A>, action: StagedAction<InferPayload<A>>) => R,
options?: TransitionSagaOptions<InferPayload<A>, R>,
) => takeEvery(actions.stage.match, handleTransition(actions, effect, options));

/** Returns a `takeLeading` effect that retries all failed transitions
* when the trigger action is dispatched.
*
* ```ts
* const retryAll = createAction('optimistron::retryAll');
*
* yield retryFailed(retryAll, (state: RootState) => [
* ...epicsSelectors.selectFailures(state.epics),
* ...profileSelectors.selectFailures(state.profile),
* ]);
* ``` */
export const retryFailed = <RootState>(
trigger: { match(action: Action): boolean },
selectFailed: (state: RootState) => StagedAction[],
) =>
takeLeading(trigger.match, function* () {
const state: RootState = yield select();
for (const action of selectFailed(state)) yield put(action);
});
2 changes: 2 additions & 0 deletions src/saga/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { handleTransition, retryFailed, watchTransition } from './effects';
export type { TransitionSagaOptions } from './effects';
198 changes: 198 additions & 0 deletions test/unit/saga/effects.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, expect, test } from 'bun:test';
import { configureStore, createAction } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import { takeEvery } from 'redux-saga/effects';

import { createTransitions, optimistron, TransitionMode, getTransitionMeta } from '~index';
import { handleTransition, retryFailed, watchTransition } from '~saga/index';
import type { TransitionState } from '~state/types';
import { createItem, indexedState, selectState } from '~test/utils';
import type { TestItem, TestIndexedState } from '~test/utils';

const create = createTransitions('sagaFx::add', TransitionMode.DISPOSABLE)((item: TestItem) => ({ payload: item, transitionId: item.id }));
const edit = createTransitions('sagaFx::edit')((item: TestItem) => ({ payload: item, transitionId: item.id }));
const remove = createTransitions('sagaFx::remove', TransitionMode.REVERTIBLE)((dto: Pick<TestItem, 'id'>) => ({ payload: dto, transitionId: dto.id }));

const manualReducer = (handler: any, action: any): TestIndexedState => {
if (create.match(action)) return handler.create(action.payload);
if (edit.match(action)) return handler.update(action.payload);
if (remove.match(action)) return handler.remove(action.payload);
return handler.getState();
};

const { reducer, selectors } = optimistron('sagaFx', {} as TestIndexedState, indexedState, manualReducer);
const { selectOptimistic, selectFailures } = selectors;

type RootState = { sagaFx: TransitionState<TestIndexedState> };

const setupStore = (saga: () => Generator) => {
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({ reducer: { sagaFx: reducer }, middleware: (getDefault) => getDefault().concat(sagaMiddleware) });
sagaMiddleware.run(saga);
return store;
};

const flush = () => new Promise((r) => setTimeout(r, 50));

describe('watchTransition', () => {
test('simple commit — effect resolves, dispatches commit', async () => {
const effect = async () => {};
const store = setupStore(function* () {
yield watchTransition(create, effect);
});

const item = createItem({ id: 'a', value: 'hello' });
store.dispatch(create.stage(item));
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
expect(selectOptimistic(selectState)(state)).toEqual({ a: item });
});

test('amend before commit — transforms payload via amend option', async () => {
const effect = async () => ({ serverId: 'server-123' });
const store = setupStore(function* () {
yield watchTransition(create, effect, {
amend: (payload, result) => ({ ...payload, id: result.serverId }),
});
});

const item = createItem({ id: 'temp', value: 'hello' });
store.dispatch(create.stage(item));
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
expect(selectOptimistic(selectState)(state)).toEqual({
'server-123': { ...item, id: 'server-123' },
});
});

test('fail on error — DEFAULT mode dispatches fail', async () => {
const effect = async () => {
throw new Error('network error');
};
const store = setupStore(function* () {
yield watchTransition(edit, effect);
});

const seed = createItem({ id: 'a', value: 'original' });
store.dispatch(create.stage(seed));
store.dispatch(create.commit('a'));

const item = createItem({ id: 'a', value: 'updated' });
store.dispatch(edit.stage(item));
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(1);
expect(getTransitionMeta(state.transitions[0]).failed).toBe(true);
});

test('stash on error — REVERTIBLE mode dispatches stash', async () => {
const item = createItem({ id: 'a', value: 'existing' });
const effect = async () => {
throw new Error('network error');
};
const store = setupStore(function* () {
yield watchTransition(remove, effect);
});

store.dispatch(create.stage(item));
store.dispatch(create.commit('a'));

store.dispatch(remove.stage({ id: 'a' }));
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
expect(selectOptimistic(selectState)(state)).toEqual({ a: item });
});

test('DISPOSABLE mode — fail drops the transition', async () => {
const effect = async () => {
throw new Error('network error');
};
const store = setupStore(function* () {
yield watchTransition(create, effect);
});

const item = createItem({ id: 'a', value: 'hello' });
store.dispatch(create.stage(item));
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
expect(selectOptimistic(selectState)(state)).toEqual({});
});
});

describe('handleTransition', () => {
test('is independently callable as an inner generator', async () => {
const effect = async () => {};
const store = setupStore(function* () {
yield takeEvery(edit.stage.match, handleTransition(edit, effect));
});

const seed = createItem({ id: 'b', value: 'original' });
store.dispatch(create.stage(seed));
store.dispatch(create.commit('b'));

const item = createItem({ id: 'b', value: 'manual' });
store.dispatch(edit.stage(item));
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
expect(selectOptimistic(selectState)(state)).toEqual({ b: item });
});
});

describe('retryFailed', () => {
const retryAll = createAction('sagaFx::retryAll');

test('collects failed transitions and re-dispatches them', async () => {
let callCount = 0;
const effect = async () => {
callCount++;
if (callCount <= 1) throw new Error('fail first time');
};

const store = setupStore(function* () {
yield watchTransition(edit, effect);
yield retryFailed(retryAll, (state: RootState) => selectFailures(state.sagaFx));
});

const seed = createItem({ id: 'x', value: 'original' });
store.dispatch(create.stage(seed));
store.dispatch(create.commit('x'));

const item = createItem({ id: 'x', value: 'hello' });
store.dispatch(edit.stage(item));
await flush();

let state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(1);
expect(getTransitionMeta(state.transitions[0]).failed).toBe(true);

store.dispatch(retryAll());
await flush();

state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
expect(selectOptimistic(selectState)(state)).toEqual({ x: item });
});

test('handles empty failure list gracefully', async () => {
const store = setupStore(function* () {
yield retryFailed(retryAll, (state: RootState) => selectFailures(state.sagaFx));
});

store.dispatch(retryAll());
await flush();

const state = store.getState().sagaFx;
expect(state.transitions).toHaveLength(0);
});
});
Loading
Loading