From e3039d6a12541b5028100f2e1fd672ff6a6acfad Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Mon, 23 Feb 2026 08:04:35 +0100 Subject: [PATCH 1/5] feat: improve transitionID <-> entityID enforcement --- src/actions.ts | 101 ++++++++++++----- src/index.ts | 2 +- test/unit/actions.spec.ts | 204 ++++++++++++++++++++++++++++++++++ usecases/basic/App.tsx | 8 +- usecases/lib/store/actions.ts | 11 +- usecases/lib/store/reducer.ts | 4 +- usecases/sagas/App.tsx | 9 +- usecases/sagas/saga.ts | 2 +- usecases/thunks/thunk.ts | 8 +- 9 files changed, 295 insertions(+), 54 deletions(-) create mode 100644 test/unit/actions.spec.ts diff --git a/src/actions.ts b/src/actions.ts index 450f5d8..af88975 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -9,28 +9,45 @@ type EmptyPayload = { payload: never }; type PA_Empty = () => EmptyPayload; type PA_Error = (error: unknown) => EmptyPayload & { error: Error }; +const emptyPA = () => ({ payload: {} }); +const errorPA = (error: unknown) => ({ error: error instanceof Error ? error.message : error, payload: {} }); + +/** Extracts the payload type from a PrepareAction */ +type PreparePayload> = ReturnType['payload']; + +/** Extracts the error type from a PrepareAction, or `never` if none */ +type PrepareError> = ReturnType extends { error: infer E } ? E : never; + +/** Merges transition meta with any extra meta from a PrepareAction */ +type ActionMeta> = TransitionMeta & + (ReturnType extends { meta: infer M } ? M : object); + +/** Resolves the arguments signature for a transition action creator. + * STAGE auto-detects transitionId when prepare returns it; + * all other operations require explicit transitionId as first arg. */ +type TransitionArgs> = Op extends Operation.STAGE + ? ReturnType extends { transitionId: string } + ? Parameters + : [transitionId: string, ...Parameters] + : [transitionId: string, ...Parameters]; + export type TransitionWithPreparedPayload< ActionType extends TransitionNamespace, Op extends Operation, PA extends PrepareAction, > = ActionCreatorWithPreparedPayload< - [transitionId: string, ...Parameters], - ReturnType['payload'], + TransitionArgs, + PreparePayload, ActionType, - ReturnType extends { error: infer E } ? E : never, - TransitionMeta & (ReturnType extends { meta: infer M } ? M : object) + PrepareError, + ActionMeta >; export type TransitionPayloadAction< Type extends string, Op extends Operation, PA extends PrepareAction, -> = PayloadAction< - ReturnType['payload'], - Type, - TransitionMeta & (ReturnType extends { meta: infer M } ? M : object), - ReturnType extends { error: infer E } ? E : never ->; +> = PayloadAction, Type, ActionMeta, PrepareError>; /** Helper action matcher function that will match the supplied * namespace when the transition operation is of type COMMIT */ @@ -51,13 +68,37 @@ const prepareTransition = >( }, }); -/** This function creates a transition action creator by extending RTK's `createAction` - * utility. Due to limitations with the type inference of `PrepareAction`, a type cast - * is required, as we cannot handle all variadic cases of the parameters of the `prepare` - * function. This arises due to the specific requirements of `createTransition` which - * necessitates passing a `transitionId` followed by parameters inferred from `PrepareAction`. - * In contrast, the `PrepareAction` type is defined to accept any number of arguments of - * any type. */ +/** Resolves transitionId and hydrates transition meta onto a prepare result. + * For STAGE, auto-detects transitionId from prepare result when it returns + * `transitionId` — coupling transitionId to entityId for the common CRUD case. + * All other operations require explicit transitionId as the first argument, + * since they target an existing transition the consumer already holds a + * reference to. This also avoids a subtle pitfall: amend shares stagePA, so + * auto-detecting from an amended entity (which may carry a server-assigned ID) + * would target the wrong transition. + * + * For edge-cases where transitionId !== entityId (batch ops, correlation IDs), + * omit `transitionId` from the prepare return — the explicit path works for + * all operations including STAGE. */ +export const resolveTransition = + (operation: Operation, dedupe: DedupeMode) => + >(prepare: PA) => + (...args: any[]) => { + if (operation === Operation.STAGE) { + const result = prepare(...args); + if (result && 'transitionId' in result) { + const id = String(result.transitionId); + return prepareTransition(result, { id, operation, dedupe }); + } + } + + const [transitionId, ...rest] = args; + return prepareTransition(prepare(...rest), { id: transitionId as string, operation, dedupe }); + }; + +/** Creates a transition action creator by wrapping RTK's `createAction`. + * Type-cast required due to RTK's `PrepareAction` not supporting the variadic + * `[transitionId, ...prepareArgs]` signature inference. */ export const createTransition = ( type: Type, @@ -65,12 +106,7 @@ export const createTransition = dedupe: DedupeMode = DedupeMode.OVERWRITE, ) => >(prepare: PA): TransitionWithPreparedPayload => - createAction(type, ((transitionId: string, ...params: Parameters) => - prepareTransition(prepare(...params), { - id: transitionId, - operation, - dedupe, - })) as PrepareAction); + createAction(type, resolveTransition(operation, dedupe)(prepare)); /** Generates transition actions for a specified transition type. By default, it uses the * `OVERWRITE` dedupe strategy, which overwrites transitions with the same `transitionId` in @@ -95,13 +131,6 @@ export const createTransitions = }, ) => { const noOptions = typeof options === 'function'; - const emptyPA = () => ({ payload: {} }); - - const errorPA = (error: unknown) => ({ - error: error instanceof Error ? error.message : error, - payload: {}, - }); - const stagePA = noOptions ? options : options.stage; const commitPA = noOptions ? emptyPA : (options.commit ?? emptyPA); const failPA = noOptions ? errorPA : (options.fail ?? errorPA); @@ -116,3 +145,15 @@ export const createTransitions = match: createCommitMatcher(type), }; }; + +/** Factory for CRUD prepare functions that couple transitionId to entityId. + * This is the recommended default for indexed state — transitionId === entityId + * means dispatching `stage(entity)` automatically tracks the transition by the + * entity's own ID. For edge-cases where transitionId must differ from entityId + * (batch operations, server-assigned IDs with correlation tokens), write custom + * prepare functions and pass transitionId explicitly as the first argument. */ +export const crudPrepare = >(itemIdKey: keyof T & string) => ({ + create: (item: T) => ({ payload: { item }, transitionId: String(item[itemIdKey]) }), + update: (id: string, item: Partial) => ({ payload: { id, item }, transitionId: id }), + remove: (id: string) => ({ payload: { id }, transitionId: id }), +}); diff --git a/src/index.ts b/src/index.ts index 39c5315..7446816 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { optimistron } from './optimistron'; -export { createTransition, createTransitions } from './actions'; +export { createTransition, createTransitions, crudPrepare } from './actions'; export { selectConflictingTransition, selectFailedTransition, diff --git a/test/unit/actions.spec.ts b/test/unit/actions.spec.ts new file mode 100644 index 0000000..9e26304 --- /dev/null +++ b/test/unit/actions.spec.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from 'bun:test'; + +import { createTransitions, crudPrepare, resolveTransition } from '~actions'; +import { META_KEY } from '~constants'; +import { DedupeMode, Operation } from '~transitions'; + +describe('resolveTransition', () => { + const prepare = (id: string, value: string) => ({ + payload: { id, value }, + transitionId: id, + }); + + describe('STAGE operation', () => { + const resolve = resolveTransition(Operation.STAGE, DedupeMode.OVERWRITE)(prepare); + + test('auto-detects transitionId from prepare result', () => { + const result = resolve('item-1', 'hello'); + + expect(result.payload).toEqual({ id: 'item-1', value: 'hello' }); + expect(result.meta[META_KEY]).toEqual({ id: 'item-1', operation: Operation.STAGE, dedupe: DedupeMode.OVERWRITE }); + }); + + test('falls back to explicit transitionId when prepare omits it', () => { + const plainPrepare = (value: string) => ({ payload: { value } }); + const resolve = resolveTransition(Operation.STAGE, DedupeMode.OVERWRITE)(plainPrepare); + const result = resolve('tid-1', 'hello'); + + expect(result.payload).toEqual({ value: 'hello' }); + expect(result.meta[META_KEY].id).toBe('tid-1'); + }); + }); + + describe('non-STAGE operations', () => { + test('AMEND always uses explicit transitionId even when prepare returns transitionId', () => { + const resolve = resolveTransition(Operation.AMEND, DedupeMode.OVERWRITE)(prepare); + const result = resolve('original-tid', 'server-id', 'hello'); + + expect(result.payload).toEqual({ id: 'server-id', value: 'hello' }); + expect(result.meta[META_KEY]).toEqual({ id: 'original-tid', operation: Operation.AMEND, dedupe: DedupeMode.OVERWRITE }); + }); + + test('COMMIT always uses explicit transitionId', () => { + const commitPrepare = () => ({ payload: {} }); + const resolve = resolveTransition(Operation.COMMIT, DedupeMode.OVERWRITE)(commitPrepare); + const result = resolve('tid-1'); + + expect(result.meta[META_KEY].id).toBe('tid-1'); + expect(result.meta[META_KEY].operation).toBe(Operation.COMMIT); + }); + + test('FAIL always uses explicit transitionId', () => { + const failPrepare = (error: unknown) => ({ payload: {}, error }); + const resolve = resolveTransition(Operation.FAIL, DedupeMode.OVERWRITE)(failPrepare); + const result = resolve('tid-1', new Error('test')); + + expect(result.meta[META_KEY].id).toBe('tid-1'); + expect(result.meta[META_KEY].operation).toBe(Operation.FAIL); + }); + }); + + test('respects dedupe mode', () => { + const resolve = resolveTransition(Operation.STAGE, DedupeMode.TRAILING)(prepare); + const result = resolve('item-1', 'hello'); + + expect(result.meta[META_KEY].dedupe).toBe(DedupeMode.TRAILING); + }); +}); + +describe('createTransitions', () => { + describe('with auto transitionId prepare', () => { + const prepare = (id: string, value: string) => ({ + payload: { id, value }, + transitionId: id, + }); + + const actions = createTransitions('ns::test')(prepare); + + test('stage should extract transitionId from prepare result', () => { + const result = actions.stage('item-1', 'hello'); + + expect(result.payload).toEqual({ id: 'item-1', value: 'hello' }); + expect(result.meta[META_KEY].id).toBe('item-1'); + expect(result.meta[META_KEY].operation).toBe(Operation.STAGE); + }); + + test('amend should use explicit transitionId as first arg', () => { + const result = actions.amend('original-tid', 'server-id', 'updated'); + + expect(result.payload).toEqual({ id: 'server-id', value: 'updated' }); + expect(result.meta[META_KEY].id).toBe('original-tid'); + expect(result.meta[META_KEY].operation).toBe(Operation.AMEND); + }); + + test('commit/fail/stash should still use transitionId as first arg (default preparators)', () => { + const commitResult = actions.commit('item-1'); + expect(commitResult.meta[META_KEY].id).toBe('item-1'); + expect(commitResult.meta[META_KEY].operation).toBe(Operation.COMMIT); + + const failResult = actions.fail('item-1', new Error('test')); + expect(failResult.meta[META_KEY].id).toBe('item-1'); + expect(failResult.meta[META_KEY].operation).toBe(Operation.FAIL); + + const stashResult = actions.stash('item-1'); + expect(stashResult.meta[META_KEY].id).toBe('item-1'); + expect(stashResult.meta[META_KEY].operation).toBe(Operation.STASH); + }); + }); + + describe('backwards compatibility', () => { + const prepare = (value: string) => ({ payload: { value } }); + const actions = createTransitions('ns::compat')(prepare); + + test('should accept transitionId as first arg when prepare does not return it', () => { + const result = actions.stage('tid-1', 'hello'); + + expect(result.payload).toEqual({ value: 'hello' }); + expect(result.meta[META_KEY].id).toBe('tid-1'); + expect(result.meta[META_KEY].operation).toBe(Operation.STAGE); + }); + }); + + describe('with options object and mixed preparators', () => { + const stagePA = (id: string, value: string) => ({ + payload: { id, value }, + transitionId: id, + }); + + const commitPA = (id: string, serverData: string) => ({ + payload: { id, serverData }, + transitionId: id, + }); + + const actions = createTransitions('ns::mixed')({ + stage: stagePA, + commit: commitPA, + }); + + test('stage uses auto transitionId', () => { + const result = actions.stage('item-1', 'hello'); + expect(result.meta[META_KEY].id).toBe('item-1'); + expect(result.payload).toEqual({ id: 'item-1', value: 'hello' }); + }); + + test('commit uses explicit transitionId as first arg', () => { + const result = actions.commit('tid-1', 'item-1', 'server-response'); + expect(result.meta[META_KEY].id).toBe('tid-1'); + expect(result.payload).toEqual({ id: 'item-1', serverData: 'server-response' }); + }); + }); +}); + +describe('crudPrepare', () => { + type Item = { id: string; name: string; revision: number }; + const crud = crudPrepare('id'); + + test('create returns payload with item and transitionId', () => { + const item: Item = { id: 'i1', name: 'test', revision: 0 }; + const result = crud.create(item); + + expect(result.payload).toEqual({ item }); + expect(result.transitionId).toBe('i1'); + }); + + test('update returns payload with id and partial item and transitionId', () => { + const result = crud.update('i1', { name: 'updated' }); + + expect(result.payload).toEqual({ id: 'i1', item: { name: 'updated' } }); + expect(result.transitionId).toBe('i1'); + }); + + test('remove returns payload with id and transitionId', () => { + const result = crud.remove('i1'); + + expect(result.payload).toEqual({ id: 'i1' }); + expect(result.transitionId).toBe('i1'); + }); + + test('composes with createTransitions', () => { + const actions = createTransitions('items::add')(crud.create); + const item: Item = { id: 'i1', name: 'test', revision: 0 }; + const result = actions.stage(item); + + expect(result.payload).toEqual({ item }); + expect(result.meta[META_KEY].id).toBe('i1'); + expect(result.meta[META_KEY].operation).toBe(Operation.STAGE); + }); + + test('composes with createTransitions using TRAILING dedupe', () => { + const actions = createTransitions('items::del', DedupeMode.TRAILING)(crud.remove); + const result = actions.stage('i1'); + + expect(result.payload).toEqual({ id: 'i1' }); + expect(result.meta[META_KEY].id).toBe('i1'); + expect(result.meta[META_KEY].dedupe).toBe(DedupeMode.TRAILING); + }); + + test('uses numeric id key converted to string', () => { + type NumItem = { code: number; label: string }; + const numCrud = crudPrepare('code'); + const result = numCrud.create({ code: 42, label: 'test' }); + + expect(result.transitionId).toBe('42'); + }); +}); diff --git a/usecases/basic/App.tsx b/usecases/basic/App.tsx index 806d2ba..08240d2 100644 --- a/usecases/basic/App.tsx +++ b/usecases/basic/App.tsx @@ -34,10 +34,10 @@ export const App: FC = () => { const transitionId = todo.id; try { - dispatch(createTodo.stage(transitionId, todo)); + dispatch(createTodo.stage(todo)); await simulateAPIRequest(); - dispatch(createTodo.amend(transitionId, { ...todo, id: generateId() })); + dispatch(createTodo.amend({ ...todo, id: generateId() })); dispatch(createTodo.commit(transitionId)); } catch (error) { dispatch(createTodo.fail(transitionId, error)); @@ -48,7 +48,7 @@ export const App: FC = () => { const transitionId = todo.id; try { - dispatch(editTodo.stage(transitionId, todo.id, todo)); + dispatch(editTodo.stage(todo.id, todo)); await simulateAPIRequest(); dispatch(editTodo.commit(transitionId)); } catch (error) { @@ -59,7 +59,7 @@ export const App: FC = () => { const handleDelete = async (todo: Todo) => { const transitionId = todo.id; try { - dispatch(deleteTodo.stage(transitionId, todo.id)); + dispatch(deleteTodo.stage(todo.id)); await simulateAPIRequest(); dispatch(deleteTodo.commit(transitionId)); } catch (error) { diff --git a/usecases/lib/store/actions.ts b/usecases/lib/store/actions.ts index 65a9abf..4554501 100644 --- a/usecases/lib/store/actions.ts +++ b/usecases/lib/store/actions.ts @@ -1,15 +1,14 @@ import { createAction } from '@reduxjs/toolkit'; +import { crudPrepare } from '~actions/crud'; import { createTransitions } from '~actions'; import { DedupeMode } from '~transitions'; import type { Todo } from '~usecases/lib/store/types'; -const create = (todo: Todo) => ({ payload: { todo } }); -const edit = (id: string, todo: Todo) => ({ payload: { id, todo } }); -const remove = (id: string) => ({ payload: { id } }); +const crud = crudPrepare('id'); -export const createTodo = createTransitions('todos::add')(create); -export const editTodo = createTransitions('todos::edit')(edit); -export const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)(remove); +export const createTodo = createTransitions('todos::add')(crud.create); +export const editTodo = createTransitions('todos::edit')(crud.update); +export const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)(crud.remove); export type OptimisticActions = | ReturnType diff --git a/usecases/lib/store/reducer.ts b/usecases/lib/store/reducer.ts index 56b15bc..990f42b 100644 --- a/usecases/lib/store/reducer.ts +++ b/usecases/lib/store/reducer.ts @@ -31,8 +31,8 @@ export const { reducer: todos, selectOptimistic } = optimistron( initial, 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); + if (createTodo.match(action)) return create(action.payload.item); + if (editTodo.match(action)) return update(action.payload.id, action.payload.item); if (deleteTodo.match(action)) return remove(action.payload.id); if (sync.match(action)) { diff --git a/usecases/sagas/App.tsx b/usecases/sagas/App.tsx index eb22793..081328f 100644 --- a/usecases/sagas/App.tsx +++ b/usecases/sagas/App.tsx @@ -28,18 +28,15 @@ export const App: FC = () => { const dispatch = useDispatch(); const handleCreate = async (todo: Todo) => { - const transitionId = todo.id; - dispatch(createTodo.stage(transitionId, todo)); + dispatch(createTodo.stage(todo)); }; const handleEdit = async (todo: Todo) => { - const transitionId = todo.id; - dispatch(editTodo.stage(transitionId, todo.id, todo)); + dispatch(editTodo.stage(todo.id, todo)); }; const handleDelete = async (todo: Todo) => { - const transitionId = todo.id; - dispatch(deleteTodo.stage(transitionId, todo.id)); + dispatch(deleteTodo.stage(todo.id)); }; return ( diff --git a/usecases/sagas/saga.ts b/usecases/sagas/saga.ts index 7359c59..902f922 100644 --- a/usecases/sagas/saga.ts +++ b/usecases/sagas/saga.ts @@ -10,7 +10,7 @@ export function* rootSaga() { try { yield simulateAPIRequest(); - yield put(createTodo.amend(transitionId, { ...action.payload.todo, id: generateId() })); + yield put(createTodo.amend({ ...action.payload.item, id: generateId() })); yield put(createTodo.commit(transitionId)); } catch (error) { yield put(createTodo.fail(transitionId, error)); diff --git a/usecases/thunks/thunk.ts b/usecases/thunks/thunk.ts index 81db5ca..12a3e4c 100644 --- a/usecases/thunks/thunk.ts +++ b/usecases/thunks/thunk.ts @@ -11,9 +11,9 @@ export const createTodoThunk = (todo: Todo): ThunkAction Date: Mon, 23 Feb 2026 10:15:49 +0100 Subject: [PATCH 2/5] chore: adapt usecases to new API --- usecases/basic/App.tsx | 2 +- usecases/sagas/saga.ts | 2 +- usecases/thunks/thunk.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/usecases/basic/App.tsx b/usecases/basic/App.tsx index 08240d2..46b5230 100644 --- a/usecases/basic/App.tsx +++ b/usecases/basic/App.tsx @@ -37,7 +37,7 @@ export const App: FC = () => { dispatch(createTodo.stage(todo)); await simulateAPIRequest(); - dispatch(createTodo.amend({ ...todo, id: generateId() })); + dispatch(createTodo.amend(transitionId, { ...todo, id: generateId() })); dispatch(createTodo.commit(transitionId)); } catch (error) { dispatch(createTodo.fail(transitionId, error)); diff --git a/usecases/sagas/saga.ts b/usecases/sagas/saga.ts index 902f922..5c6dff2 100644 --- a/usecases/sagas/saga.ts +++ b/usecases/sagas/saga.ts @@ -10,7 +10,7 @@ export function* rootSaga() { try { yield simulateAPIRequest(); - yield put(createTodo.amend({ ...action.payload.item, id: generateId() })); + yield put(createTodo.amend(transitionId, { ...action.payload.item, id: generateId() })); yield put(createTodo.commit(transitionId)); } catch (error) { yield put(createTodo.fail(transitionId, error)); diff --git a/usecases/thunks/thunk.ts b/usecases/thunks/thunk.ts index 12a3e4c..4fef860 100644 --- a/usecases/thunks/thunk.ts +++ b/usecases/thunks/thunk.ts @@ -13,7 +13,7 @@ export const createTodoThunk = (todo: Todo): ThunkAction Date: Mon, 23 Feb 2026 10:16:11 +0100 Subject: [PATCH 3/5] chore: update documentation for new API --- ARCHITECTURE.md | 71 ++++++++++++++++++++++++++++++++----------------- README.md | 34 ++++++++++++++++------- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4f59e86..107ede6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,21 +18,27 @@ Internals, API, and advanced patterns. For quick start, see [README.md](./README ## Entity Identity -Every transition carries a string ID. This ID is the **stable link between a transition and its entity in state**. `createTodo.stage(todo.id, todo)` — the first argument becomes the transition ID, used by every subsequent operation to locate the right entry. - -Any derivation works. What matters: one ID, one entity. Because: +Every transition carries a string ID — the **stable link between a transition and its entity in state**. One ID, one entity. Because: - **Sanitization** replays by ID — shared IDs cause shadowing - **Selectors** look up by ID — ambiguous IDs break lookups - **Dedupe** matches on ID -For `Record`, the simplest approach is using the entity key directly: +**The recommended default is `transitionId === entityId`.** Use `crudPrepare` to couple them: ```typescript -indexedStateFactory({ itemIdKey: 'id', /* ... */ }); -// stage('abc', entity) → state['abc'] = entity +const crud = crudPrepare('id'); +const createTodo = createTransitions('todos::add')(crud.create); + +dispatch(createTodo.stage(todo)); // transitionId auto-detected from todo.id +dispatch(createTodo.amend(tid, amended)); // explicit — targets original transition +dispatch(createTodo.commit(tid)); // explicit ``` +**Why STAGE-only auto-detection:** `stage` initiates a new transition — the entity *is* the transition. But `amend`/`commit`/`fail`/`stash` target an *existing* transition the consumer already holds a reference to. Auto-detecting on `amend` is a pitfall: it shares `stagePA`, so an amended entity with a server-assigned ID would target the wrong transition. + +For edge-cases where `transitionId !== entityId` (batch ops, correlation IDs, server-assigned IDs with temp tokens), write custom prepare functions and pass transitionId as the first argument — the explicit path works for all operations including `stage`. + --- ## Versioning & Conflicts @@ -150,12 +156,11 @@ const profileHandler: StateHandler< `DedupeMode.TRAILING` — undo-on-failure for destructive ops: ```typescript -const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)( - (id: string) => ({ payload: { id } }) -); +const crud = crudPrepare('id'); +const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)(crud.remove); -dispatch(deleteTodo.stage(id, id)); // gone from UI -dispatch(deleteTodo.stash(id)); // back — restored from trailing +dispatch(deleteTodo.stage(id)); // gone from UI (transitionId auto-detected) +dispatch(deleteTodo.stash(id)); // back — restored from trailing ``` Replaced transitions are stored as fallback. `stash` restores instead of dropping. @@ -171,12 +176,14 @@ Transport-agnostic. Works with anything: ```typescript const handleCreate = async (todo: Todo) => { - dispatch(createTodo.stage(todo.id, todo)); + const transitionId = todo.id; + dispatch(createTodo.stage(todo)); // auto-detect transitionId try { await api.create(todo); - dispatch(createTodo.commit(todo.id)); + dispatch(createTodo.amend(transitionId, { ...todo, id: serverId })); // explicit + dispatch(createTodo.commit(transitionId)); } catch (e) { - dispatch(createTodo.fail(todo.id, e)); + dispatch(createTodo.fail(transitionId, e)); } }; ``` @@ -190,12 +197,14 @@ const handleCreate = async (todo: Todo) => { const createTodoThunk = (todo: Todo): ThunkAction => async (dispatch) => { - dispatch(createTodo.stage(todo.id, todo)); + const transitionId = todo.id; + dispatch(createTodo.stage(todo)); try { await api.create(todo); - dispatch(createTodo.commit(todo.id)); + dispatch(createTodo.amend(transitionId, { ...todo, id: serverId })); + dispatch(createTodo.commit(transitionId)); } catch (e) { - dispatch(createTodo.fail(todo.id, e)); + dispatch(createTodo.fail(transitionId, e)); } }; ``` @@ -207,12 +216,13 @@ const createTodoThunk = ```typescript function* createTodoSaga(action: ReturnType) { - const { id } = getTransitionMeta(action); + const transitionId = getTransitionMeta(action).id; try { - yield call(api.create, action.payload.todo); - yield put(createTodo.commit(id)); + yield call(api.create, action.payload.item); + yield put(createTodo.amend(transitionId, { ...action.payload.item, id: serverId })); + yield put(createTodo.commit(transitionId)); } catch (e) { - yield put(createTodo.fail(id, e)); + yield put(createTodo.fail(transitionId, e)); } } ``` @@ -243,14 +253,27 @@ Returned from `optimistron()`. Replays transitions before selecting. Memoize wit selectOptimistic((todos) => Object.values(todos.state)) ``` +### `crudPrepare(itemIdKey)` + +Factory for CRUD prepare functions that couple `transitionId === entityId`. Recommended default for indexed state. + +```typescript +const crud = crudPrepare('id'); +// crud.create(item) → { payload: { item }, transitionId: item.id } +// crud.update(id, partial) → { payload: { id, item: partial }, transitionId: id } +// crud.remove(id) → { payload: { id }, transitionId: id } +``` + ### `createTransitions(type, dedupe?)(prepare)` -Creates `.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`. Per-operation preparators supported: +Creates `.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`. + +`stage` auto-detects `transitionId` when prepare returns it (e.g. via `crudPrepare`). All other operations require explicit `transitionId` as first argument. Per-operation preparators supported: ```typescript createTransitions('todos::add')({ - stage: (item: Todo) => ({ payload: { item } }), - commit: (serverItem: Todo) => ({ payload: { item: serverItem } }), + stage: (item: Todo) => ({ payload: { item }, transitionId: item.id }), + commit: () => ({ payload: {} }), }); ``` diff --git a/README.md b/README.md index b41a69a..cabc339 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,14 @@ No `isLoading`, `error`, `isOptimistic` flags. A pending transition means loadin ```typescript import { configureStore, createSelector } from '@reduxjs/toolkit'; -import { optimistron, createTransitions, indexedStateFactory } from '@lostsolution/optimistron'; +import { optimistron, createTransitions, crudPrepare, indexedStateFactory } from '@lostsolution/optimistron'; type Todo = { id: string; value: string; done: boolean; revision: number }; -const createTodo = createTransitions('todos::add')((todo: Todo) => ({ payload: { todo } })); -const editTodo = createTransitions('todos::edit')((id: string, todo: Todo) => ({ payload: { id, todo } })); -const deleteTodo = createTransitions('todos::delete')((id: string) => ({ payload: { id } })); +const crud = crudPrepare('id'); +const createTodo = createTransitions('todos::add')(crud.create); +const editTodo = createTransitions('todos::edit')(crud.update); +const deleteTodo = createTransitions('todos::delete')(crud.remove); const { reducer: todos, selectOptimistic } = optimistron( 'todos', @@ -67,8 +68,8 @@ const { reducer: todos, selectOptimistic } = optimistron( eq: (a) => (b) => a.done === b.done && a.value === b.value, }), ({ 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); + if (createTodo.match(action)) return create(action.payload.item); + if (editTodo.match(action)) return update(action.payload.id, action.payload.item); if (deleteTodo.match(action)) return remove(action.payload.id); return getState(); }, @@ -81,8 +82,8 @@ const selectTodos = createSelector( selectOptimistic((todos) => Object.values(todos.state)), ); -dispatch(createTodo.stage(todo.id, todo)); // UI updates instantly -dispatch(createTodo.commit(todo.id)); // persist on success +dispatch(createTodo.stage(todo)); // transitionId auto-detected from entity ID +dispatch(createTodo.commit(todo.id)); // persist on success dispatch(createTodo.fail(todo.id, error)); // flag on error ``` @@ -92,7 +93,11 @@ dispatch(createTodo.fail(todo.id, error)); // flag on error ### Transition IDs -Every transition is tracked by a string ID — the first argument to `.stage()`, `.commit()`, etc. This ID is the **consistent key** between a transition and the entity it describes. It's how `selectIsFailed(id)` and `selectIsOptimistic(id)` infer per-entity status. Any stable derivation works, but each ID should resolve to exactly one entity. +Every transition is tracked by a string ID — the **stable link** between a transition and the entity it describes. It's how `selectIsFailed(id)` and `selectIsOptimistic(id)` infer per-entity status. + +**The recommended default is `transitionId === entityId`.** Use `crudPrepare` to couple them — `stage(entity)` automatically derives the transition ID from the entity's own key. For `amend`/`commit`/`fail`/`stash`, pass the transition ID explicitly (you already have it from the initial `stage`). + +For edge-cases where transitionId must differ from entityId (batch ops, correlation IDs, server-assigned IDs with temp tokens), write custom prepare functions and pass transitionId as the first argument. ### Versioning @@ -106,6 +111,17 @@ Entities need a **monotonically increasing version** — `revision`, `updatedAt` --- +## Roadmap + +- **Batch transitions** — stage multiple entities under a single correlation ID. Commit/fail/stash the batch atomically. +- **More state factories** — `singleEntityFactory` for scalar state, `normalizedStateFactory` for relational stores with foreign key handling. +- **Retry strategies** — configurable retry policies for failed transitions (exponential backoff, max attempts) built into the transition lifecycle. +- **Devtools integration** — Redux DevTools timeline visualization for transitions, sanitization events, and conflict detection. +- **Persistence adapters** — serialize/rehydrate pending transitions across page reloads (localStorage, IndexedDB). +- **Middleware hooks** — `onConflict`, `onStale`, `onSanitize` callbacks for custom side-effects without reducer coupling. + +--- + ## Development ```bash From 3f4341bc182aa03c21d4ac4ccca10e989f86d2fd Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Mon, 23 Feb 2026 10:23:30 +0100 Subject: [PATCH 4/5] fix: github page deploy --- .github/workflows/deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c4e784a..2fb0edd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,6 @@ on: push: branches: - main - pull_request: jobs: deploy: From 6a5329c295be1d677317dc372d6e9ac745b45355 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Mon, 23 Feb 2026 10:23:48 +0100 Subject: [PATCH 5/5] fix: import path for `crudPrepare` --- usecases/lib/store/actions.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/usecases/lib/store/actions.ts b/usecases/lib/store/actions.ts index 4554501..6ee29ea 100644 --- a/usecases/lib/store/actions.ts +++ b/usecases/lib/store/actions.ts @@ -1,6 +1,5 @@ import { createAction } from '@reduxjs/toolkit'; -import { crudPrepare } from '~actions/crud'; -import { createTransitions } from '~actions'; +import { createTransitions, crudPrepare } from '~actions'; import { DedupeMode } from '~transitions'; import type { Todo } from '~usecases/lib/store/types';