From 04d08ec3f7fca8be49e674ff04c516ead8ba1da2 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Tue, 24 Feb 2026 08:18:27 +0100 Subject: [PATCH 1/4] refact: paths signatures --- src/actions/crud.ts | 22 ++++++++++++---------- src/state/record.ts | 25 ++++++++++--------------- src/utils/types.ts | 5 +++-- test/integration/nested.spec.ts | 13 ++++++++----- test/unit/actions.spec.ts | 8 ++++---- test/unit/state/nested.spec.ts | 12 ++++++------ 6 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/actions/crud.ts b/src/actions/crud.ts index c8df053..12c89cb 100644 --- a/src/actions/crud.ts +++ b/src/actions/crud.ts @@ -25,11 +25,14 @@ export function crudPrepare>(): { create: (item: T) => { payload: { item: T }; transitionId: string }; - update: (...args: [...PathIds, Partial]) => { + update: ( + path: PathIds, + item: Partial, + ) => { payload: { path: PathIds; item: Partial }; transitionId: string; }; - remove: (...args: PathIds) => { payload: { path: PathIds }; transitionId: string }; + remove: (path: PathIds) => { payload: { path: PathIds }; transitionId: string }; }; export function crudPrepare>( @@ -54,14 +57,13 @@ export function crudPrepare>(key?: keyof T & strin payload: { item }, transitionId: keys.map((k) => String(item[k])).join('/'), }), - update: (...args: [...PathIds, Partial]) => { - const path = args.slice(0, keys.length) as unknown as PathIds; - const item = args[keys.length] as Partial; - return { payload: { path, item }, transitionId: (path as string[]).join('/') }; - }, - remove: (...args: PathIds) => ({ - payload: { path: args }, - transitionId: (args as unknown as string[]).join('/'), + update: (path: PathIds, item: Partial) => ({ + payload: { path, item }, + transitionId: path.join('/'), + }), + remove: (path: PathIds) => ({ + payload: { path }, + transitionId: path.join('/'), }), }); } diff --git a/src/state/record.ts b/src/state/record.ts index 21050e6..cfaed9e 100644 --- a/src/state/record.ts +++ b/src/state/record.ts @@ -47,8 +47,8 @@ export const nestedRecordState = }: NestedRecordStateOptions): WiredStateHandler< RecursiveRecordState, [item: T], - [...PathMap, Partial], - [...PathMap], + [path: string[], item: Partial], + [path: string[]], NestedRecordCrudMap > => { type State = RecursiveRecordState; @@ -117,25 +117,20 @@ export const nestedRecordState = return { create: (state, item) => setAt(state, extractPath(item), item), - update: (state, ...args) => { - const ids = (args as unknown[]).slice(0, keys.length) as string[]; - const partial = args[keys.length] as Partial; - const existing = getAt(state, ids) as Maybe; + update: (state, path, item) => { + const existing = getAt(state, path) as Maybe; if (!existing) return state; - return setAt(state, ids, { ...existing, ...partial }); + return setAt(state, path, { ...existing, ...item }); }, - remove: (state, ...args) => { - const ids = (args as unknown[]).slice(0, keys.length) as string[]; - return removeAt(state, ids); - }, + remove: (state, path) => removeAt(state, path), merge: (existing, incoming) => mergeAtDepth(existing, incoming, keys.length) as State, wire: (bound, action, actions) => { if (actions.create && actions.create.match(action)) return bound.create(action.payload.item); - if (actions.update && actions.update.match(action)) return bound.update(...action.payload.path, action.payload.item); - if (actions.remove && actions.remove.match(action)) return bound.remove(...action.payload.path); + if (actions.update && actions.update.match(action)) return bound.update(action.payload.path, action.payload.item); + if (actions.remove && actions.remove.match(action)) return bound.remove(action.payload.path); return undefined; }, }; @@ -153,8 +148,8 @@ export const recordState = >({ return { create: nested.create, merge: nested.merge, - update: (state, itemId, partialItem) => nested.update(state, itemId, partialItem), - remove: (state, itemId) => nested.remove(state, itemId), + update: (state, itemId, partialItem) => nested.update(state, [itemId], partialItem), + remove: (state, itemId) => nested.remove(state, [itemId]), wire: (bound, action, actions) => { if (actions.create && actions.create.match(action)) return bound.create(action.payload.item); diff --git a/src/utils/types.ts b/src/utils/types.ts index 40002e9..0b709fe 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -4,8 +4,9 @@ export type StringKeys = { }[keyof T] & string; -/** Maps a keys tuple to a tuple of string IDs — one per nesting level */ -export type PathMap = { [K in keyof Keys]: string } & string[]; +/** Maps a keys tuple to a tuple of string IDs — one per nesting level. + * Only maps numeric indices so array methods like `.join()` are not shadowed. */ +export type PathMap = { [K in keyof Keys & number]: string } & string[]; export type MaybeNull = T | null; export type Maybe = T | undefined; diff --git a/test/integration/nested.spec.ts b/test/integration/nested.spec.ts index 4a22eec..5588558 100644 --- a/test/integration/nested.spec.ts +++ b/test/integration/nested.spec.ts @@ -29,10 +29,13 @@ const add = createTransitions('nested::add')(crud.create); const edit = createTransitions('nested::edit')(crud.update); const remove = createTransitions('nested::remove')(crud.remove); -const reducer: HandlerReducer], [string, string]> = ({ getState, create, update, remove: r }, action) => { +const reducer: HandlerReducer], [path: string[]]> = ( + { getState, create, update, remove: r }, + action, +) => { if (add.match(action)) return create(action.payload.item); - if (edit.match(action)) return update(action.payload.path[0], action.payload.path[1], action.payload.item); - if (remove.match(action)) return r(action.payload.path[0], action.payload.path[1]); + if (edit.match(action)) return update(action.payload.path, action.payload.item); + if (remove.match(action)) return r(action.payload.path); return getState(); }; @@ -96,7 +99,7 @@ describe('optimistron', () => { const updatedPartial: Partial = { value: 'updated', revision: 2 }; const updatedItem: Item = { ...item, ...updatedPartial }; - const stage = edit.stage('g1', 'i1', updatedPartial); + const stage = edit.stage(['g1', 'i1'], updatedPartial); const commit = edit.commit('g1/i1'); const stash = edit.stash('g1/i1'); @@ -125,7 +128,7 @@ describe('optimistron', () => { describe('delete', () => { const item: Item = { groupId: 'g1', itemId: 'i1', value: 'test', revision: 0 }; - const stage = remove.stage('g1', 'i1'); + const stage = remove.stage(['g1', 'i1']); const commit = remove.commit('g1/i1'); const stash = remove.stash('g1/i1'); diff --git a/test/unit/actions.spec.ts b/test/unit/actions.spec.ts index 79ed122..c14d5fc 100644 --- a/test/unit/actions.spec.ts +++ b/test/unit/actions.spec.ts @@ -217,14 +217,14 @@ describe('crudPrepare', () => { }); test('update returns payload with path and partial item', () => { - const result = crud.update('g1', 'i1', { value: 'updated' }); + const result = crud.update(['g1', 'i1'], { value: 'updated' }); expect(result.payload).toEqual({ path: ['g1', 'i1'], item: { value: 'updated' } }); expect(result.transitionId).toBe('g1/i1'); }); test('remove returns payload with path', () => { - const result = crud.remove('g1', 'i1'); + const result = crud.remove(['g1', 'i1']); expect(result.payload).toEqual({ path: ['g1', 'i1'] }); expect(result.transitionId).toBe('g1/i1'); @@ -246,8 +246,8 @@ describe('crudPrepare', () => { const item: Deep = { a: 'x', b: 'y', c: 'z', val: 1 }; expect(deepCrud.create(item).transitionId).toBe('x/y/z'); - expect(deepCrud.update('x', 'y', 'z', { val: 2 }).transitionId).toBe('x/y/z'); - expect(deepCrud.remove('x', 'y', 'z').transitionId).toBe('x/y/z'); + expect(deepCrud.update(['x', 'y', 'z'], { val: 2 }).transitionId).toBe('x/y/z'); + expect(deepCrud.remove(['x', 'y', 'z']).transitionId).toBe('x/y/z'); }); }); }); diff --git a/test/unit/state/nested.spec.ts b/test/unit/state/nested.spec.ts index 15a9911..66a0ac3 100644 --- a/test/unit/state/nested.spec.ts +++ b/test/unit/state/nested.spec.ts @@ -44,17 +44,17 @@ describe('nestedRecordState', () => { const state: State = { g1: { i1: item } }; test('should update existing item', () => { - const next = handler.update(state, 'g1', 'i1', { value: 'updated' }); + const next = handler.update(state, ['g1', 'i1'], { value: 'updated' }); expect(next.g1.i1).toEqual({ ...item, value: 'updated' }); }); test('should return state in-place if item does not exist', () => { - const next = handler.update(state, 'g1', 'missing', { value: 'nope' }); + const next = handler.update(state, ['g1', 'missing'], { value: 'nope' }); expect(next).toBe(state); }); test('should return state in-place if group does not exist', () => { - const next = handler.update(state, 'missing', 'i1', { value: 'nope' }); + const next = handler.update(state, ['missing', 'i1'], { value: 'nope' }); expect(next).toBe(state); }); }); @@ -63,17 +63,17 @@ describe('nestedRecordState', () => { const state: State = { g1: { i1: item } }; test('should remove existing item', () => { - const next = handler.remove(state, 'g1', 'i1'); + const next = handler.remove(state, ['g1', 'i1']); expect(next).toEqual({ g1: {} }); }); test('should return state in-place if item does not exist', () => { - const next = handler.remove(state, 'g1', 'missing'); + const next = handler.remove(state, ['g1', 'missing']); expect(next).toBe(state); }); test('should return state in-place if group does not exist', () => { - const next = handler.remove(state, 'missing', 'i1'); + const next = handler.remove(state, ['missing', 'i1']); expect(next).toBe(state); }); }); From b7b0c306e6e1ec3b8018ec38ab778defe02437d8 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Tue, 24 Feb 2026 16:54:24 +0100 Subject: [PATCH 2/4] feat: improve action creator DTOs --- src/actions/crud.ts | 44 +++++++++++------------- src/actions/types.ts | 9 +++++ src/index.ts | 15 ++++---- src/optimistron.ts | 8 ++--- src/reducer.ts | 14 ++++---- src/state/factory.ts | 28 ++++++++------- src/state/list.ts | 33 ++++++++++-------- src/state/record.ts | 57 +++++++++++++++---------------- src/state/singular.ts | 13 +++---- src/state/types.ts | 39 ++++++++++----------- src/transitions.ts | 4 +-- test/integration/nested.spec.ts | 17 +++++---- test/integration/record.spec.ts | 10 +++--- test/integration/singular.spec.ts | 38 ++++++++++----------- test/unit/actions.spec.ts | 36 +++++++++---------- test/unit/optimistron.spec.ts | 26 ++++++++------ test/unit/reducer.spec.ts | 8 ++--- test/unit/selectors.spec.ts | 2 +- test/unit/state.spec.ts | 22 ++++++------ test/unit/state/list.spec.ts | 22 ++++++------ test/unit/state/nested.spec.ts | 28 +++++++-------- test/unit/state/record.spec.ts | 20 +++++------ test/unit/state/singular.spec.ts | 10 +++--- test/unit/transitions.spec.ts | 6 ++-- test/utils/index.ts | 14 ++++---- 25 files changed, 266 insertions(+), 257 deletions(-) diff --git a/src/actions/crud.ts b/src/actions/crud.ts index 12c89cb..8eb4107 100644 --- a/src/actions/crud.ts +++ b/src/actions/crud.ts @@ -1,4 +1,4 @@ -import type { PathIds } from './types'; +import type { DeleteDTO, UpdateDTO } from './types'; /** * Factory for CRUD prepare functions that couple transitionId to entityId. @@ -6,7 +6,7 @@ import type { PathIds } from './types'; * means dispatching `stage(entity)` automatically tracks the transition by the * entity's own ID. * - * Single-key overload (unchanged): + * Single-key overload: * ```ts * const crud = crudPrepare('id'); * ``` @@ -24,46 +24,40 @@ import type { PathIds } from './types'; export function crudPrepare>(): ( keys: Keys, ) => { - create: (item: T) => { payload: { item: T }; transitionId: string }; - update: ( - path: PathIds, - item: Partial, - ) => { - payload: { path: PathIds; item: Partial }; - transitionId: string; - }; - remove: (path: PathIds) => { payload: { path: PathIds }; transitionId: string }; + create: (item: T) => { payload: T; transitionId: string }; + update: (dto: UpdateDTO) => { payload: UpdateDTO; transitionId: string }; + remove: (dto: DeleteDTO) => { payload: DeleteDTO; transitionId: string }; }; export function crudPrepare>( key: keyof T & string, ): { - create: (item: T) => { payload: { item: T }; transitionId: string }; - update: (id: string, item: Partial) => { payload: { id: string; item: Partial }; transitionId: string }; - remove: (id: string) => { payload: { id: string }; transitionId: string }; + create: (item: T) => { payload: T; transitionId: string }; + update: (dto: Partial) => { payload: Partial; transitionId: string }; + remove: (dto: Partial) => { payload: Partial; transitionId: string }; }; -export function crudPrepare>(key?: keyof T & string) { +export function crudPrepare>(key?: keyof T & string): any { if (key !== undefined) { return { - create: (item: T) => ({ payload: { item }, transitionId: String(item[key]) }), - update: (id: string, item: Partial) => ({ payload: { id, item }, transitionId: id }), - remove: (id: string) => ({ payload: { id }, transitionId: id }), + create: (item: T) => ({ payload: item, transitionId: String(item[key]) }), + update: (dto: Partial) => ({ payload: dto, transitionId: String(dto[key]) }), + remove: (dto: Partial) => ({ payload: dto, transitionId: String(dto[key]) }), }; } return (keys: Keys) => ({ create: (item: T) => ({ - payload: { item }, + payload: item, transitionId: keys.map((k) => String(item[k])).join('/'), }), - update: (path: PathIds, item: Partial) => ({ - payload: { path, item }, - transitionId: path.join('/'), + update: (dto: Partial) => ({ + payload: dto, + transitionId: keys.map((k) => String(dto[k])).join('/'), }), - remove: (path: PathIds) => ({ - payload: { path }, - transitionId: path.join('/'), + remove: (dto: Record) => ({ + payload: dto, + transitionId: keys.map((k) => String(dto[k])).join('/'), }), }); } diff --git a/src/actions/types.ts b/src/actions/types.ts index 2666fa4..2618076 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -39,3 +39,12 @@ export type TransitionPayloadAction; export type { PathMap as PathIds } from '~/utils/types'; + +/** Picks the identity keys from T — the "address" of an entity */ +export type ItemPath = Pick; + +/** Partial update DTO: all fields optional, identity keys required */ +export type UpdateDTO = Partial & ItemPath; + +/** Delete DTO: just the identity keys */ +export type DeleteDTO = ItemPath; diff --git a/src/index.ts b/src/index.ts index 0278ca9..1f0d8e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -export { optimistron } from './optimistron'; export { createTransition, createTransitions, crudPrepare } from './actions'; +export { optimistron } from './optimistron'; export { selectAllFailedTransitions, selectConflictingTransition, @@ -11,17 +11,18 @@ export { selectRetryCount, } from './selectors/selectors'; export { listState } from './state/list'; -export { recordState, nestedRecordState } from './state/record'; +export { nestedRecordState, recordState } from './state/record'; export { singularState } from './state/singular'; -export { TransitionMode, Operation, OptimisticMergeResult, isTransition, retryTransition } from './transitions'; +export { isTransition, Operation, OptimisticMergeResult, retryTransition, TransitionMode } from './transitions'; export type { HandlerReducer, ReducerConfig } from './reducer'; export type { ActionMatcher, BoundStateHandler, CrudActionMap, StateHandler, TransitionState, VersioningOptions, WiredStateHandler } from './state/types'; -export type { RecordStateOptions, NestedRecordStateOptions } from './state/record'; -export type { SingularStateOptions } from './state/singular'; +export type { DeleteDTO, ItemPath, UpdateDTO } from './actions/types'; export type { ListStateOptions } from './state/list'; -export type { StringKeys, PathMap as PathIds, MaybeNull } from './utils/types'; +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 { TransitionAction, StagedAction, CommittedAction, Transition } from './transitions'; +export type { CommittedAction, StagedAction, Transition, TransitionAction } from './transitions'; diff --git a/src/optimistron.ts b/src/optimistron.ts index d1f12e9..d6a1eb1 100644 --- a/src/optimistron.ts +++ b/src/optimistron.ts @@ -1,6 +1,5 @@ import type { Action, Reducer } from 'redux'; -import { warn } from './utils/logger'; import { bindReducer, resolveReducer, type BoundReducer, type HandlerReducer, type ReducerConfig } from './reducer'; import { createSelectOptimistic } from './selectors/internal'; import { bindStateFactory, buildTransitionState, transitionStateFactory } from './state/factory'; @@ -15,6 +14,7 @@ import { toCommit, type StagedAction, } from './transitions'; +import { warn } from './utils/logger'; import type { Maybe } from './utils/types'; /** Applies a staged transition as a commit via the bound reducer. @@ -35,7 +35,7 @@ type OptimistronOptions = { }; /** Manual mode — full control via a reducer function */ -export function optimistron( +export function optimistron( namespace: string, initialState: S, handler: StateHandler, @@ -44,7 +44,7 @@ export function optimistron; /** Auto-wire mode — CRUD action map routed via handler's wire method */ -export function optimistron( +export function optimistron( namespace: string, initialState: S, handler: WiredStateHandler, @@ -52,7 +52,7 @@ export function optimistron; -export function optimistron( +export function optimistron( namespace: string, initialState: S, handler: StateHandler, diff --git a/src/reducer.ts b/src/reducer.ts index 85eb959..4671587 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -3,19 +3,19 @@ import type { BoundStateHandler, CrudActionMap, StateHandler, TransitionState, W export type BoundReducer = (state: TransitionState, action: Action) => State; -export type HandlerReducer = ( - boundStateHandler: BoundStateHandler, +export type HandlerReducer = ( + boundStateHandler: BoundStateHandler, action: Action, ) => State; /** Consumer-facing reducer config: either a function (manual) or a CRUD map (auto-wired) */ -export type ReducerConfig = +export type ReducerConfig = | HandlerReducer | (CrudActionMap & { reducer?: HandlerReducer }); /** Runtime shape of the CRUD config branch — matches ActionMatcher's runtime interface. * Type-level safety is enforced at `optimistron()` call sites via overloads. */ -type CrudConfigRuntime = { +type CrudConfigRuntime = { create?: { match(action: { type: string; [key: string]: unknown }): boolean }; update?: { match(action: { type: string; [key: string]: unknown }): boolean }; remove?: { match(action: { type: string; [key: string]: unknown }): boolean }; @@ -24,10 +24,10 @@ type CrudConfigRuntime = WiredStateHandler>; +type WiredHandler = WiredStateHandler>; /** Resolves a `ReducerConfig` to a `HandlerReducer` — auto-wires CRUD maps via the handler's `wire` method */ -export const resolveReducer = ( +export const resolveReducer = ( handler: StateHandler, config: ReducerConfig, ): HandlerReducer => { @@ -49,7 +49,7 @@ export const resolveReducer = ( + ( reducer: HandlerReducer, bindState: (state: S) => BoundStateHandler, ): BoundReducer => diff --git a/src/state/factory.ts b/src/state/factory.ts index 10f4f00..082f87d 100644 --- a/src/state/factory.ts +++ b/src/state/factory.ts @@ -2,32 +2,34 @@ import type { TransitionAction } from '~/transitions'; import type { BoundStateHandler, StateHandler, TransitionState } from './types'; export const bindStateFactory = - ( - handler: StateHandler, - ) => - (state: State): BoundStateHandler => ({ - create: (...args: CreateParams) => handler.create(state, ...args), - update: (...args: UpdateParams) => handler.update(state, ...args), - remove: (...args: DeleteParams) => handler.remove(state, ...args), - merge: (incoming: State) => handler.merge(state, incoming), + (handler: StateHandler) => + (state: S): BoundStateHandler => ({ + create: (dto: C) => handler.create(state, dto), + update: (dto: U) => handler.update(state, dto), + remove: (dto: D) => handler.remove(state, dto), + merge: (incoming: S) => handler.merge(state, incoming), getState: () => state, }); -export const buildTransitionState = (state: State, transitions: TransitionAction[]): TransitionState => { - const transitionState = { state } as TransitionState; +export const buildTransitionState = (state: S, transitions: TransitionAction[]): TransitionState => { + const transitionState = { state } as TransitionState; /* make transitions non-enumerable to avoid consumers * from unintentionally accessing them when iterating */ Object.defineProperties(transitionState, { - transitions: { value: transitions, enumerable: false, writable: true }, + transitions: { + value: transitions, + enumerable: false, + writable: true, + }, }); return transitionState; }; export const transitionStateFactory = - (prev: TransitionState) => - (state: State, transitions: TransitionAction[]): TransitionState => { + (prev: TransitionState) => + (state: S, transitions: TransitionAction[]): TransitionState => { if (state === prev.state && transitions === prev.transitions) return prev; return buildTransitionState(state, transitions); }; diff --git a/src/state/list.ts b/src/state/list.ts index 2643fa0..ec254d0 100644 --- a/src/state/list.ts +++ b/src/state/list.ts @@ -1,46 +1,49 @@ -import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types'; -import type { StringKeys } from '~/utils/types'; import { OptimisticMergeResult } from '~/transitions'; +import type { StringKeys } from '~/utils/types'; +import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types'; export type ListStateOptions = VersioningOptions & { key: StringKeys }; -/** Typed CRUD action map for list state */ -type ListCrudMap = CrudActionMap<{ item: T }, { id: string; item: Partial }, { id: string }>; - /** * Creates a `StateHandler` for ordered list state (`T[]`). * Items are identified by a string key property on `T`. * Useful when insertion order matters or consumers need array semantics. + * Handler types use `Partial` for update/remove DTOs — narrower types + * are enforced at dispatch time via `crudPrepare`. * - `compare` determines if an incoming item is newer/conflicting - * - `eq` checks deep equality beyond versioning */ + * - `eq` checks deep equality beyond versioning + */ export const listState = >({ key, compare, eq, -}: ListStateOptions): WiredStateHandler], [itemId: string], ListCrudMap> => ({ +}: ListStateOptions): WiredStateHandler, Partial, CrudActionMap, Partial>> => ({ create: (state: T[], item: T) => { if (state.some((entry) => entry[key] === item[key])) return state; return [...state, item]; }, - update: (state: T[], itemId: string, partial: Partial) => { - const idx = state.findIndex((entry) => entry[key as StringKeys] === itemId); + update: (state: T[], dto: Partial) => { + const itemId = String(dto[key]); + const idx = state.findIndex((entry) => entry[key] === itemId); if (idx === -1) return state; + const next = [...state]; - next[idx] = { ...state[idx], ...partial }; + next[idx] = { ...state[idx], ...dto }; return next; }, - remove: (state: T[], itemId: string) => { - const idx = state.findIndex((entry) => entry[key as StringKeys] === itemId); + remove: (state: T[], dto: Partial) => { + const itemId = String(dto[key]); + const idx = state.findIndex((entry) => entry[key] === itemId); if (idx === -1) return state; return state.filter((_, i) => i !== idx); }, wire: (bound, action, actions) => { - if (actions.create && actions.create.match(action)) return bound.create(action.payload.item); - if (actions.update && actions.update.match(action)) return bound.update(action.payload.id, action.payload.item); - if (actions.remove && actions.remove.match(action)) return bound.remove(action.payload.id); + if (actions.create?.match(action)) return bound.create(action.payload); + if (actions.update?.match(action)) return bound.update(action.payload); + if (actions.remove?.match(action)) return bound.remove(action.payload); return undefined; }, diff --git a/src/state/record.ts b/src/state/record.ts index cfaed9e..49020a0 100644 --- a/src/state/record.ts +++ b/src/state/record.ts @@ -1,9 +1,9 @@ -import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types'; -import type { Maybe, StringKeys } from '~/utils/types'; -import type { PathMap } from '~/utils/types'; -import type { Obj } from '~/utils/path'; -import { getAt, setAt, removeAt } from '~/utils/path'; +import type { DeleteDTO, UpdateDTO } from '~/actions/types'; import { OptimisticMergeResult } from '~/transitions'; +import type { Obj } from '~/utils/path'; +import { getAt, removeAt, setAt } from '~/utils/path'; +import type { Maybe, StringKeys } from '~/utils/types'; +import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types'; export type RecordStateOptions = VersioningOptions & { key: StringKeys }; export type NestedRecordStateOptions[]> = VersioningOptions & { keys: Keys }; @@ -22,12 +22,6 @@ export type RecursiveRecordState = Keys exten * `PathOf<['groupId', 'itemId']>` = `{ groupId: string; itemId: string }` */ export type PathOf = { [K in Keys[number]]: string }; -/** Typed CRUD action map for nested record state */ -type NestedRecordCrudMap = CrudActionMap<{ item: T }, { path: PathMap; item: Partial }, { path: PathMap }>; - -/** Typed CRUD action map for flat record state */ -type RecordCrudMap = CrudActionMap<{ item: T }, { id: string; item: Partial }, { id: string }>; - /** * Creates a `StateHandler` for nested record-based state. * Curried to support partial type application — fix `T`, infer `Keys`: @@ -46,15 +40,15 @@ export const nestedRecordState = eq, }: NestedRecordStateOptions): WiredStateHandler< RecursiveRecordState, - [item: T], - [path: string[], item: Partial], - [path: string[]], - NestedRecordCrudMap + T, + UpdateDTO, + DeleteDTO, + CrudActionMap, DeleteDTO> > => { type State = RecursiveRecordState; - /** Extracts path IDs from an item using the keys tuple */ - const extractPath = (item: T): string[] => keys.map((k) => item[k]); + /** Extracts path IDs from a DTO using the keys tuple */ + const extractPath = (dto: Record): string[] => keys.map((k) => String(dto[k])); /** Recursive merge at a given depth. `depth` counts down from `keys.length`. * Defers the `{ ...existing }` spread until the first mutation is detected @@ -117,44 +111,47 @@ export const nestedRecordState = return { create: (state, item) => setAt(state, extractPath(item), item), - update: (state, path, item) => { + update: (state, dto) => { + const path = extractPath(dto); const existing = getAt(state, path) as Maybe; if (!existing) return state; - return setAt(state, path, { ...existing, ...item }); + return setAt(state, path, { ...existing, ...dto }); }, - remove: (state, path) => removeAt(state, path), + remove: (state, dto) => removeAt(state, extractPath(dto as Record)), merge: (existing, incoming) => mergeAtDepth(existing, incoming, keys.length) as State, wire: (bound, action, actions) => { - if (actions.create && actions.create.match(action)) return bound.create(action.payload.item); - if (actions.update && actions.update.match(action)) return bound.update(action.payload.path, action.payload.item); - if (actions.remove && actions.remove.match(action)) return bound.remove(action.payload.path); + if (actions.create?.match(action)) return bound.create(action.payload); + if (actions.update?.match(action)) return bound.update(action.payload); + if (actions.remove?.match(action)) return bound.remove(action.payload); return undefined; }, }; }; /** Creates a `StateHandler` for a flat record-based state (`Record`). - * This is a depth-1 specialization of `nestedRecordState`. */ + * This is a depth-1 specialization of `nestedRecordState`. + * Handler types use `Partial` for update/remove DTOs — narrower types + * are enforced at dispatch time via `crudPrepare`. */ export const recordState = >({ key, compare, eq, -}: RecordStateOptions): WiredStateHandler, [item: T], [itemId: string, partialItem: Partial], [itemId: string], RecordCrudMap> => { +}: RecordStateOptions): WiredStateHandler, T, Partial, Partial, CrudActionMap, Partial>> => { const nested = nestedRecordState()({ keys: [key], compare, eq }); return { create: nested.create, merge: nested.merge, - update: (state, itemId, partialItem) => nested.update(state, [itemId], partialItem), - remove: (state, itemId) => nested.remove(state, [itemId]), + update: (state, dto) => nested.update(state, dto as any), + remove: (state, dto) => nested.remove(state, dto as any), wire: (bound, action, actions) => { - if (actions.create && actions.create.match(action)) return bound.create(action.payload.item); - if (actions.update && actions.update.match(action)) return bound.update(action.payload.id, action.payload.item); - if (actions.remove && actions.remove.match(action)) return bound.remove(action.payload.id); + if (actions.create?.match(action)) return bound.create(action.payload); + if (actions.update?.match(action)) return bound.update(action.payload); + if (actions.remove?.match(action)) return bound.remove(action.payload); return undefined; }, }; diff --git a/src/state/singular.ts b/src/state/singular.ts index a4e7f0c..e061dc8 100644 --- a/src/state/singular.ts +++ b/src/state/singular.ts @@ -1,12 +1,9 @@ +import { OptimisticMergeResult } from '~/transitions'; import type { MaybeNull } from '~/utils/types'; import type { CrudActionMap, VersioningOptions, WiredStateHandler } from './types'; -import { OptimisticMergeResult } from '~/transitions'; export type SingularStateOptions = VersioningOptions; -/** Typed CRUD action map for singular state */ -type SingularCrudMap = CrudActionMap<{ item: T }, { item: Partial }, Record>; - /** * Creates a `StateHandler` for single-object state (`MaybeNull`). * Suited for cases like user profile, settings, or any singleton entity. @@ -15,15 +12,15 @@ type SingularCrudMap = CrudActionMap<{ item: T }, { item: Partial }, Recor export const singularState = ({ compare, eq, -}: SingularStateOptions): WiredStateHandler, [item: T], [partial: Partial], [], SingularCrudMap> => ({ +}: SingularStateOptions): WiredStateHandler, T, Partial, void, CrudActionMap, void>> => ({ create: (_: MaybeNull, item: T) => item, update: (state: MaybeNull, partial: Partial) => (state ? { ...state, ...partial } : state), remove: (state: MaybeNull) => (state !== null ? null : state), wire: (bound, action, actions) => { - if (actions.create && actions.create.match(action)) return bound.create(action.payload.item); - if (actions.update && actions.update.match(action)) return bound.update(action.payload.item); - if (actions.remove && actions.remove.match(action)) return bound.remove(); + if (actions.create?.match(action)) return bound.create(action.payload); + if (actions.update?.match(action)) return bound.update(action.payload); + if (actions.remove?.match(action)) return bound.remove(undefined as void); return undefined; }, diff --git a/src/state/types.ts b/src/state/types.ts index 51cb144..d69b028 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -31,34 +31,31 @@ export type CrudActionMap = { remove?: ActionMatcher; }; -export interface StateHandler { - create: (state: State, ...args: CreateParams) => State; - update: (state: State, ...args: UpdateParams) => State; - remove: (state: State, ...args: DeleteParams) => State; - merge: (current: State, incoming: State) => State; +export interface StateHandler { + create: (state: S, dto: C) => S; + update: (state: S, dto: U) => S; + remove: (state: S, dto: D) => S; + merge: (current: S, incoming: S) => S; } /** StateHandler extended with auto-wired CRUD support. * `wire` receives the fully-typed `BoundStateHandler` — all type params * are inherited from `StateHandler`, so inference works at call sites. */ -export interface WiredStateHandler< - State, - CreateParams extends unknown[], - UpdateParams extends unknown[], - DeleteParams extends unknown[], - Actions, -> extends StateHandler { +export interface WiredStateHandler extends StateHandler { wire: ( - bound: BoundStateHandler, - action: { type: string; [key: string]: unknown }, + bound: BoundStateHandler, + action: { + type: string; + [key: string]: unknown; + }, actions: Actions, - ) => Maybe; + ) => Maybe; } -export interface BoundStateHandler { - create: (...args: CreateParams) => State; - update: (...args: UpdateParams) => State; - remove: (...args: DeleteParams) => State; - merge: (incoming: State) => State; - getState: () => State; +export interface BoundStateHandler { + create: (dto: C) => S; + update: (dto: U) => S; + remove: (dto: D) => S; + merge: (incoming: S) => S; + getState: () => S; } diff --git a/src/transitions.ts b/src/transitions.ts index ca19b1f..8377ac5 100644 --- a/src/transitions.ts +++ b/src/transitions.ts @@ -170,9 +170,9 @@ export const processTransition = (transition: TransitionAction, transitions: Sta * and performing a sanity 'merge' check on each iteration. This process helps cleanse the transitions * list by eliminating no-op actions and identifying potential conflicts. */ export const sanitizeTransitions = - ( + ( boundReducer: BoundReducer, - bindState: ReturnType>, + bindState: ReturnType>, ) => (state: TransitionState) => { const sanitized = state.transitions.reduce<{ diff --git a/test/integration/nested.spec.ts b/test/integration/nested.spec.ts index 5588558..76c589f 100644 --- a/test/integration/nested.spec.ts +++ b/test/integration/nested.spec.ts @@ -8,6 +8,7 @@ import { buildTransitionState } from '~state/factory'; import type { TransitionState } from '~state/types'; import { nestedRecordState } from '~state/record'; import type { RecursiveRecordState } from '~state/record'; +import type { UpdateDTO, DeleteDTO } from '~actions/types'; import { updateTransition } from '~transitions'; type Item = { groupId: string; itemId: string; value: string; revision: number }; @@ -29,13 +30,15 @@ const add = createTransitions('nested::add')(crud.create); const edit = createTransitions('nested::edit')(crud.update); const remove = createTransitions('nested::remove')(crud.remove); -const reducer: HandlerReducer], [path: string[]]> = ( +type Keys = ['groupId', 'itemId']; + +const reducer: HandlerReducer, DeleteDTO> = ( { getState, create, update, remove: r }, action, ) => { - if (add.match(action)) return create(action.payload.item); - if (edit.match(action)) return update(action.payload.path, action.payload.item); - if (remove.match(action)) return r(action.payload.path); + if (add.match(action)) return create(action.payload); + if (edit.match(action)) return update(action.payload); + if (remove.match(action)) return r(action.payload); return getState(); }; @@ -96,10 +99,10 @@ describe('optimistron', () => { describe('update', () => { const item: Item = { groupId: 'g1', itemId: 'i1', value: 'test', revision: 0 }; - const updatedPartial: Partial = { value: 'updated', revision: 2 }; + const updatedPartial = { groupId: 'g1', itemId: 'i1', value: 'updated', revision: 2 }; const updatedItem: Item = { ...item, ...updatedPartial }; - const stage = edit.stage(['g1', 'i1'], updatedPartial); + const stage = edit.stage(updatedPartial); const commit = edit.commit('g1/i1'); const stash = edit.stash('g1/i1'); @@ -128,7 +131,7 @@ describe('optimistron', () => { describe('delete', () => { const item: Item = { groupId: 'g1', itemId: 'i1', value: 'test', revision: 0 }; - const stage = remove.stage(['g1', 'i1']); + const stage = remove.stage({ groupId: 'g1', itemId: 'i1' }); const commit = remove.commit('g1/i1'); const stash = remove.stash('g1/i1'); diff --git a/test/integration/record.spec.ts b/test/integration/record.spec.ts index acd627a..1b1ecd8 100644 --- a/test/integration/record.spec.ts +++ b/test/integration/record.spec.ts @@ -16,12 +16,12 @@ describe('optimistron', () => { const conflictItem = { ...item, revision: -1 }; const amendedItem = { ...item, value: 'amended value' }; - const stage = create.stage(item.id, item); + const stage = create.stage(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 conflict = create.stage(conflictItem); const initial = buildTransitionState({}, []); const state = optimisticReducer(initial, stage); @@ -159,7 +159,7 @@ describe('optimistron', () => { describe('delete', () => { const item = createItem(); - const stage = remove.stage(item.id, item.id); + const stage = remove.stage({ id: item.id }); const fail = remove.fail(item.id, new Error()); const stash = remove.stash(item.id); const commit = remove.commit(item.id); @@ -230,12 +230,12 @@ describe('optimistron', () => { const updatedItem = { ...item, revision: 2, value: 'updated value' }; const amendedItem = { ...updatedItem, value: 'amended value' }; - const stage = edit.stage(updatedItem.id, updatedItem); + const stage = edit.stage(updatedItem); const amend = edit.amend(updatedItem.id, amendedItem); const fail = edit.fail(updatedItem.id, new Error()); const stash = edit.stash(updatedItem.id); const commit = edit.commit(updatedItem.id); - const conflict = edit.stage(updatedItem.id, conflictItem); + const conflict = edit.stage(conflictItem); const initial = buildTransitionState({ [item.id]: item }, []); const state = optimisticReducer(initial, stage); diff --git a/test/integration/singular.spec.ts b/test/integration/singular.spec.ts index 21e8109..1eea1be 100644 --- a/test/integration/singular.spec.ts +++ b/test/integration/singular.spec.ts @@ -21,15 +21,15 @@ const handler = singularState({ eq: (a) => (b) => a.id === b.id && a.name === b.name, }); -const create = createTransitions('profile::create')((item: Profile) => ({ payload: { item } })); -const edit = createTransitions('profile::edit')((partial: Partial) => ({ payload: { partial } })); -const remove = createTransitions('profile::remove')(() => ({ payload: {} })); +const create = createTransitions('profile::create')((item: Profile) => ({ payload: item, transitionId: item.id })); +const edit = createTransitions('profile::edit')((partial: Partial) => ({ payload: partial, transitionId: 'profile' })); +const remove = createTransitions('profile::remove')(() => ({ payload: undefined as void, transitionId: 'profile' })); const sync = (profile: MaybeNull) => ({ type: 'sync', payload: { profile } }); -const reducer: HandlerReducer, [item: Profile], [partial: Partial], []> = ({ getState, create: c, update, remove: r }, action) => { - if (create.match(action)) return c(action.payload.item); - if (edit.match(action)) return update(action.payload.partial); - if (remove.match(action)) return r(); +const reducer: HandlerReducer, Profile, Partial, void> = ({ getState, create: c, update, remove: r }, action) => { + if (create.match(action)) return c(action.payload); + if (edit.match(action)) return update(action.payload); + if (remove.match(action)) return r(undefined as void); if (action.type === 'sync') return (action as ReturnType).payload.profile; return getState(); }; @@ -45,12 +45,12 @@ describe('optimistron', () => { const amendedItem: Profile = { ...item, name: 'Amended' }; const conflictItem: Profile = { ...item, revision: -1 }; - const stage = create.stage('1', item); + const stage = create.stage(item); const amend = create.amend('1', amendedItem); const fail = create.fail('1', new Error()); const stash = create.stash('1'); const commit = create.commit('1'); - const conflict = create.stage('1', conflictItem); + const conflict = create.stage(conflictItem); const initial = buildTransitionState>(null, []); const state = optimisticReducer(initial, stage); @@ -111,9 +111,9 @@ describe('optimistron', () => { describe('delete', () => { const item: Profile = { id: '1', name: 'Alice', revision: 0 }; - const stage = remove.stage('1'); - const commit = remove.commit('1'); - const stash = remove.stash('1'); + const stage = remove.stage(); + const commit = remove.commit('profile'); + const stash = remove.stash('profile'); const initial = buildTransitionState>(item, []); const state = optimisticReducer(initial, stage); @@ -122,7 +122,7 @@ describe('optimistron', () => { expect(state.state).toEqual(item); expect(state.transitions).toStrictEqual([stage]); expect(selectOptimistic(selectState)(state)).toBeNull(); - expect(selectIsOptimistic('1')(state)).toBe(true); + expect(selectIsOptimistic('profile')(state)).toBe(true); }); test('commit', () => { @@ -154,10 +154,10 @@ describe('optimistron', () => { const updatedPartial: Partial = { name: 'Updated', revision: 2 }; const updatedItem: Profile = { ...item, ...updatedPartial }; - const stage = edit.stage('1', updatedPartial); - const commit = edit.commit('1'); - const stash = edit.stash('1'); - const fail = edit.fail('1', new Error()); + const stage = edit.stage(updatedPartial); + const commit = edit.commit('profile'); + const stash = edit.stash('profile'); + const fail = edit.fail('profile', new Error()); const initial = buildTransitionState>(item, []); const state = optimisticReducer(initial, stage); @@ -166,7 +166,7 @@ describe('optimistron', () => { expect(state.state).toEqual(item); expect(state.transitions).toStrictEqual([stage]); expect(selectOptimistic(selectState)(state)).toEqual(updatedItem); - expect(selectIsOptimistic('1')(state)).toBe(true); + expect(selectIsOptimistic('profile')(state)).toBe(true); }); test('commit', () => { @@ -188,7 +188,7 @@ describe('optimistron', () => { expect(next.state).toEqual(item); expect(next.transitions).toStrictEqual([updateTransition(stage, { failed: true })]); - expect(selectIsFailed('1')(next)).toBe(true); + expect(selectIsFailed('profile')(next)).toBe(true); }); }); }); diff --git a/test/unit/actions.spec.ts b/test/unit/actions.spec.ts index c14d5fc..864faa5 100644 --- a/test/unit/actions.spec.ts +++ b/test/unit/actions.spec.ts @@ -158,19 +158,19 @@ describe('crudPrepare', () => { const item: Item = { id: 'i1', name: 'test', revision: 0 }; const result = crud.create(item); - expect(result.payload).toEqual({ 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' }); + test('update returns payload with dto and transitionId', () => { + const result = crud.update({ id: 'i1', name: 'updated' }); - expect(result.payload).toEqual({ id: 'i1', item: { name: 'updated' } }); + expect(result.payload).toEqual({ id: 'i1', name: 'updated' }); expect(result.transitionId).toBe('i1'); }); - test('remove returns payload with id and transitionId', () => { - const result = crud.remove('i1'); + test('remove returns payload with dto and transitionId', () => { + const result = crud.remove({ id: 'i1' }); expect(result.payload).toEqual({ id: 'i1' }); expect(result.transitionId).toBe('i1'); @@ -181,14 +181,14 @@ describe('crudPrepare', () => { const item: Item = { id: 'i1', name: 'test', revision: 0 }; const result = actions.stage(item); - expect(result.payload).toEqual({ 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 REVERTIBLE mode', () => { const actions = createTransitions('items::del', TransitionMode.REVERTIBLE)(crud.remove); - const result = actions.stage('i1'); + const result = actions.stage({ id: 'i1' }); expect(result.payload).toEqual({ id: 'i1' }); expect(result.meta[META_KEY].id).toBe('i1'); @@ -212,21 +212,21 @@ describe('crudPrepare', () => { const item: Item = { groupId: 'g1', itemId: 'i1', value: 'test' }; const result = crud.create(item); - expect(result.payload).toEqual({ item }); + expect(result.payload).toEqual(item); expect(result.transitionId).toBe('g1/i1'); }); - test('update returns payload with path and partial item', () => { - const result = crud.update(['g1', 'i1'], { value: 'updated' }); + test('update returns payload with dto', () => { + const result = crud.update({ groupId: 'g1', itemId: 'i1', value: 'updated' }); - expect(result.payload).toEqual({ path: ['g1', 'i1'], item: { value: 'updated' } }); + expect(result.payload).toEqual({ groupId: 'g1', itemId: 'i1', value: 'updated' }); expect(result.transitionId).toBe('g1/i1'); }); - test('remove returns payload with path', () => { - const result = crud.remove(['g1', 'i1']); + test('remove returns payload with dto', () => { + const result = crud.remove({ groupId: 'g1', itemId: 'i1' }); - expect(result.payload).toEqual({ path: ['g1', 'i1'] }); + expect(result.payload).toEqual({ groupId: 'g1', itemId: 'i1' }); expect(result.transitionId).toBe('g1/i1'); }); @@ -235,7 +235,7 @@ describe('crudPrepare', () => { const item: Item = { groupId: 'g1', itemId: 'i1', value: 'test' }; const result = actions.stage(item); - expect(result.payload).toEqual({ item }); + expect(result.payload).toEqual(item); expect(result.meta[META_KEY].id).toBe('g1/i1'); expect(result.meta[META_KEY].operation).toBe(Operation.STAGE); }); @@ -246,8 +246,8 @@ describe('crudPrepare', () => { const item: Deep = { a: 'x', b: 'y', c: 'z', val: 1 }; expect(deepCrud.create(item).transitionId).toBe('x/y/z'); - expect(deepCrud.update(['x', 'y', 'z'], { val: 2 }).transitionId).toBe('x/y/z'); - expect(deepCrud.remove(['x', 'y', 'z']).transitionId).toBe('x/y/z'); + expect(deepCrud.update({ a: 'x', b: 'y', c: 'z', val: 2 }).transitionId).toBe('x/y/z'); + expect(deepCrud.remove({ a: 'x', b: 'y', c: 'z' }).transitionId).toBe('x/y/z'); }); }); }); diff --git a/test/unit/optimistron.spec.ts b/test/unit/optimistron.spec.ts index 62123d1..e71bea8 100644 --- a/test/unit/optimistron.spec.ts +++ b/test/unit/optimistron.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, mock, test } from 'bun:test'; import { createTransitions } from '~actions'; import { optimistron } from '~optimistron'; -import { create, createItem, indexedState, reducer } from '~test/utils'; +import { create, createItem, indexedState, reducer, remove, type TestItem } from '~test/utils'; import { TransitionMode, getTransitionMeta, toCommit } from '~transitions'; describe('optimistron', () => { @@ -22,7 +22,7 @@ describe('optimistron', () => { const sanitizeAction = mock((action) => action); const { reducer: optimisticReducer } = optimistron('test', {}, indexedState, reducer, { sanitizeAction }); const initial = optimisticReducer(undefined, { type: 'init' }); - const stage = create.stage(item.id, item); + const stage = create.stage(item); optimisticReducer(initial, stage); expect(sanitizeAction).toHaveBeenCalledWith(stage); @@ -51,7 +51,7 @@ describe('optimistron', () => { const testReducerSpy = mock(reducer); const { reducer: optimisticReducer } = optimistron('test', {}, indexedState, testReducerSpy); const initial = optimisticReducer(undefined, { type: 'init' }); - const staged = create.stage(item.id, item); + const staged = create.stage(item); const commit = create.commit(item.id); [staged, commit].reduce(optimisticReducer, initial); @@ -64,13 +64,16 @@ describe('optimistron', () => { }); describe('TransitionMode.DISPOSABLE', () => { - const disposableCreate = createTransitions('test::add', TransitionMode.DISPOSABLE)((item: any) => ({ payload: { item } })); + const disposableCreate = createTransitions('test::add', TransitionMode.DISPOSABLE)((item: TestItem) => ({ + payload: item, + transitionId: item.id, + })); test('should drop transition on FAIL', () => { const { reducer: optimisticReducer } = optimistron('test', {}, indexedState, reducer); const initial = optimisticReducer(undefined, { type: 'init' }); - const stage = disposableCreate.stage(item.id, item); + const stage = disposableCreate.stage(item); const afterStage = optimisticReducer(initial, stage); expect(afterStage.transitions.length).toBe(1); @@ -87,7 +90,7 @@ describe('optimistron', () => { const { reducer: optimisticReducer } = optimistron('test', {}, indexedState, reducer); const initial = optimisticReducer(undefined, { type: 'init' }); - const stage = create.stage(item.id, item); + const stage = create.stage(item); const afterStage = optimisticReducer(initial, stage); const fail = create.fail(item.id, new Error('test')); const afterFail = optimisticReducer(afterStage, fail); @@ -98,22 +101,25 @@ describe('optimistron', () => { }); describe('TransitionMode.REVERTIBLE', () => { - const revertibleDelete = createTransitions('test::remove', TransitionMode.REVERTIBLE)((id: string) => ({ payload: { itemId: id } })); + const revertibleDelete = createTransitions('test::remove', TransitionMode.REVERTIBLE)((dto: Pick) => ({ + payload: dto, + transitionId: dto.id, + })); test('should stash transition on FAIL and revert to trailing', () => { const { reducer: optimisticReducer } = optimistron('test', {}, indexedState, reducer); const initial = optimisticReducer(undefined, { type: 'init' }); /* Create and commit so the item exists in committed state */ - const createStage = create.stage(item.id, item); + const createStage = create.stage(item); const afterCreate = optimisticReducer(initial, createStage); const afterCommit = optimisticReducer(afterCreate, create.commit(item.id)); /* Stage an edit, then a revertible delete on the same ID */ - const editStage = create.stage(item.id, { ...item, value: 'edited', revision: item.revision + 1 }); + const editStage = create.stage({ ...item, value: 'edited', revision: item.revision + 1 }); const afterEdit = optimisticReducer(afterCommit, editStage); - const deleteStage = revertibleDelete.stage(item.id, item.id); + const deleteStage = revertibleDelete.stage({ id: item.id }); const afterDelete = optimisticReducer(afterEdit, deleteStage); expect(afterDelete.transitions.length).toBe(1); expect(getTransitionMeta(afterDelete.transitions[0]).trailing).toEqual(editStage); diff --git a/test/unit/reducer.spec.ts b/test/unit/reducer.spec.ts index 76e0f50..7ff3c5e 100644 --- a/test/unit/reducer.spec.ts +++ b/test/unit/reducer.spec.ts @@ -60,17 +60,17 @@ describe('resolveReducer', () => { }); test('should auto-wire CRUD actions via handler.wire', () => { - const resolved = resolveReducer(indexedState, { create: matcher<{ item: TestItem }>('create') }); + const resolved = resolveReducer(indexedState, { create: matcher('create') }); const newItem = createItem({ id: 'new' }); const bound = bindState(state); - const result = resolved(bound, { type: 'create', payload: { item: newItem } } as any); + const result = resolved(bound, { type: 'create', payload: newItem } as any); expect(result).toEqual({ ...state, new: newItem }); }); test('should fall through to fallback reducer for unmatched actions', () => { const fallback = mock(() => ({ fallback: true })); const resolved = resolveReducer(indexedState, { - create: matcher<{ item: TestItem }>('__nomatch__'), + create: matcher('__nomatch__'), reducer: fallback as any, }); const bound = bindState(state); @@ -80,7 +80,7 @@ describe('resolveReducer', () => { test('should return getState() when no match and no fallback', () => { const resolved = resolveReducer(indexedState, { - create: matcher<{ item: TestItem }>('__nomatch__'), + create: matcher('__nomatch__'), }); const bound = bindState(state); const result = resolved(bound, { type: 'unknown' } as any); diff --git a/test/unit/selectors.spec.ts b/test/unit/selectors.spec.ts index 6771b75..04f8732 100644 --- a/test/unit/selectors.spec.ts +++ b/test/unit/selectors.spec.ts @@ -15,7 +15,7 @@ import { updateTransition } from '~transitions'; describe('selectors', () => { const item = createItem(); - const stage = create.stage(item.id, item); + const stage = create.stage(item); describe('selectOptimistic', () => { const state = createIndexedState([stage]); diff --git a/test/unit/state.spec.ts b/test/unit/state.spec.ts index de28b0d..59ccf30 100644 --- a/test/unit/state.spec.ts +++ b/test/unit/state.spec.ts @@ -6,33 +6,33 @@ import { create, createIndexedState, createItem } from '~test/utils'; describe('state', () => { describe('bindStateFactory', () => { describe('should bind', () => { - const create = mock(); + const createFn = mock(); const update = mock(); const remove = mock(); const merge = mock(); - const handler: StateHandler = { create, update, remove, merge }; + const handler: StateHandler = { create: createFn, 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()); + const mockDto = { id: 'test', value: Math.random() }; test('create', () => { - boundState.create(...mockParams); - expect(create).toHaveBeenCalledWith(state, ...mockParams); + boundState.create(mockDto); + expect(createFn).toHaveBeenCalledWith(state, mockDto); }); test('update', () => { - boundState.update(...mockParams); - expect(update).toHaveBeenCalledWith(state, ...mockParams); + boundState.update(mockDto); + expect(update).toHaveBeenCalledWith(state, mockDto); }); test('remove', () => { - boundState.remove(...mockParams); - expect(remove).toHaveBeenCalledWith(state, ...mockParams); + boundState.remove(mockDto); + expect(remove).toHaveBeenCalledWith(state, mockDto); }); test('merge', () => { @@ -79,10 +79,10 @@ describe('state', () => { test('should return updated copy if transitions changed', () => { const item = createItem(); const state = createIndexedState(); - const next = transitionStateFactory(state)({}, [create.stage(item.id, item)]); + const next = transitionStateFactory(state)({}, [create.stage(item)]); expect(state !== next).toBe(true); - expect(next.transitions).toEqual([create.stage(item.id, item)]); + expect(next.transitions).toEqual([create.stage(item)]); }); }); }); diff --git a/test/unit/state/list.spec.ts b/test/unit/state/list.spec.ts index f33d41c..ea3d8b6 100644 --- a/test/unit/state/list.spec.ts +++ b/test/unit/state/list.spec.ts @@ -44,13 +44,13 @@ describe('listState', () => { describe('update', () => { test('should merge partial into matching item', () => { const state = [item]; - const result = handler.update(state, '1', { name: 'Updated' }); + const result = handler.update(state, { id: '1', name: 'Updated' }); expect(result).toEqual([{ ...item, name: 'Updated' }]); }); test('should return same reference if item not found', () => { const state = [item]; - const result = handler.update(state, 'nonexistent', { name: 'Updated' }); + const result = handler.update(state, { id: 'nonexistent', name: 'Updated' }); expect(result).toBe(state); }); @@ -59,7 +59,7 @@ describe('listState', () => { const b: Item = { id: '2', name: 'B', revision: 0 }; const c: Item = { id: '3', name: 'C', revision: 0 }; const state = [a, b, c]; - const result = handler.update(state, '2', { name: 'Updated' }); + const result = handler.update(state, { id: '2', name: 'Updated' }); expect(result).toEqual([a, { ...b, name: 'Updated' }, c]); }); }); @@ -67,13 +67,13 @@ describe('listState', () => { describe('remove', () => { test('should remove item by key', () => { const state = [item]; - const result = handler.remove(state, '1'); + const result = handler.remove(state, { id: '1' }); expect(result).toEqual([]); }); test('should return same reference if item not found', () => { const state = [item]; - const result = handler.remove(state, 'nonexistent'); + const result = handler.remove(state, { id: 'nonexistent' }); expect(result).toBe(state); }); @@ -82,7 +82,7 @@ describe('listState', () => { const b: Item = { id: '2', name: 'B', revision: 0 }; const c: Item = { id: '3', name: 'C', revision: 0 }; const state = [a, b, c]; - const result = handler.remove(state, '2'); + const result = handler.remove(state, { id: '2' }); expect(result).toEqual([a, c]); }); }); @@ -148,19 +148,19 @@ describe('listState', () => { const bound = bindStateFactory(handler)(state); const actions = { - create: matcher<{ item: Item }>('create'), - update: matcher<{ id: string; item: Partial }>('update'), - remove: matcher<{ id: string }>('remove'), + create: matcher('create'), + update: matcher>('update'), + remove: matcher>('remove'), }; test('should wire create action', () => { const newItem: Item = { id: '2', name: 'Bob', revision: 0 }; - const result = handler.wire(bound, { type: 'create', payload: { item: newItem } }, actions); + const result = handler.wire(bound, { type: 'create', payload: newItem }, actions); expect(result).toEqual([item, newItem]); }); test('should wire update action', () => { - const result = handler.wire(bound, { type: 'update', payload: { id: '1', item: { name: 'Updated' } } }, actions); + const result = handler.wire(bound, { type: 'update', payload: { id: '1', name: 'Updated' } }, actions); expect(result).toEqual([{ ...item, name: 'Updated' }]); }); diff --git a/test/unit/state/nested.spec.ts b/test/unit/state/nested.spec.ts index 66a0ac3..c281794 100644 --- a/test/unit/state/nested.spec.ts +++ b/test/unit/state/nested.spec.ts @@ -44,17 +44,17 @@ describe('nestedRecordState', () => { const state: State = { g1: { i1: item } }; test('should update existing item', () => { - const next = handler.update(state, ['g1', 'i1'], { value: 'updated' }); + const next = handler.update(state, { groupId: 'g1', itemId: 'i1', value: 'updated' }); expect(next.g1.i1).toEqual({ ...item, value: 'updated' }); }); test('should return state in-place if item does not exist', () => { - const next = handler.update(state, ['g1', 'missing'], { value: 'nope' }); + const next = handler.update(state, { groupId: 'g1', itemId: 'missing', value: 'nope' }); expect(next).toBe(state); }); test('should return state in-place if group does not exist', () => { - const next = handler.update(state, ['missing', 'i1'], { value: 'nope' }); + const next = handler.update(state, { groupId: 'missing', itemId: 'i1', value: 'nope' }); expect(next).toBe(state); }); }); @@ -63,17 +63,17 @@ describe('nestedRecordState', () => { const state: State = { g1: { i1: item } }; test('should remove existing item', () => { - const next = handler.remove(state, ['g1', 'i1']); + const next = handler.remove(state, { groupId: 'g1', itemId: 'i1' }); expect(next).toEqual({ g1: {} }); }); test('should return state in-place if item does not exist', () => { - const next = handler.remove(state, ['g1', 'missing']); + const next = handler.remove(state, { groupId: 'g1', itemId: 'missing' }); expect(next).toBe(state); }); test('should return state in-place if group does not exist', () => { - const next = handler.remove(state, ['missing', 'i1']); + const next = handler.remove(state, { groupId: 'missing', itemId: 'i1' }); expect(next).toBe(state); }); }); @@ -149,24 +149,24 @@ describe('nestedRecordState', () => { const bound = bindStateFactory(handler)(state); const actions = { - create: matcher<{ item: Item }>('create'), - update: matcher<{ path: [string, string]; item: Partial }>('update'), - remove: matcher<{ path: [string, string] }>('remove'), + create: matcher('create'), + update: matcher & Pick>('update'), + remove: matcher>('remove'), }; test('should wire create action', () => { const newItem: Item = { groupId: 'g2', itemId: 'i2', value: 'new', revision: 0 }; - const result = handler.wire(bound, { type: 'create', payload: { item: newItem } }, actions); + const result = handler.wire(bound, { type: 'create', payload: newItem }, actions); expect(result).toEqual({ ...state, g2: { i2: newItem } }); }); - test('should wire update action with path spread', () => { - const result = handler.wire(bound, { type: 'update', payload: { path: ['g1', 'i1'], item: { value: 'updated' } } }, actions); + test('should wire update action', () => { + const result = handler.wire(bound, { type: 'update', payload: { groupId: 'g1', itemId: 'i1', value: 'updated' } }, actions); expect((result as any).g1.i1).toEqual({ ...item, value: 'updated' }); }); - test('should wire remove action with path spread', () => { - const result = handler.wire(bound, { type: 'remove', payload: { path: ['g1', 'i1'] } }, actions); + test('should wire remove action', () => { + const result = handler.wire(bound, { type: 'remove', payload: { groupId: 'g1', itemId: 'i1' } }, actions); expect(result).toEqual({ g1: {} }); }); diff --git a/test/unit/state/record.spec.ts b/test/unit/state/record.spec.ts index 35c8f8c..80dde91 100644 --- a/test/unit/state/record.spec.ts +++ b/test/unit/state/record.spec.ts @@ -15,29 +15,29 @@ describe('recordState', () => { }); describe('update', () => { - const update: Partial = { value: 'newvalue', revision: 1 }; + const update = { id: item.id, value: 'newvalue', revision: 1 }; test('should edit entry if it exists', () => { - const next = indexedState.update({ [item.id]: item }, item.id, update); + const next = indexedState.update({ [item.id]: item }, 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); + const next = indexedState.update(initial, { id: 'unknown', value: 'newvalue', revision: 1 }); expect(next).toEqual(initial); }); }); describe('remove', () => { test('should delete entry if it exists', () => { - const next = indexedState.remove({ [item.id]: item }, item.id); + const next = indexedState.remove({ [item.id]: item }, { id: item.id }); expect(next).toEqual({}); }); test('should return state in-place otherwise', () => { const state = { [item.id]: item }; - const next = indexedState.remove(state, 'non-existing'); + const next = indexedState.remove(state, { id: 'non-existing' }); expect(next).toEqual(state); }); }); @@ -91,19 +91,19 @@ describe('recordState', () => { const bound = bindStateFactory(indexedState)(state); const actions = { - create: matcher<{ item: TestItem }>('create'), - update: matcher<{ id: string; item: Partial }>('update'), - remove: matcher<{ id: string }>('remove'), + create: matcher('create'), + update: matcher>('update'), + remove: matcher>('remove'), }; test('should wire create action', () => { const newItem = createItem({ id: 'new' }); - const result = indexedState.wire(bound, { type: 'create', payload: { item: newItem } }, actions); + const result = indexedState.wire(bound, { type: 'create', payload: newItem }, actions); expect(result).toEqual({ ...state, new: newItem }); }); test('should wire update action', () => { - const result = indexedState.wire(bound, { type: 'update', payload: { id: item.id, item: { value: 'updated' } } }, actions); + const result = indexedState.wire(bound, { type: 'update', payload: { id: item.id, value: 'updated' } }, actions); expect(result![item.id]).toEqual({ ...item, value: 'updated' }); }); diff --git a/test/unit/state/singular.spec.ts b/test/unit/state/singular.spec.ts index cc6524b..5c41fa1 100644 --- a/test/unit/state/singular.spec.ts +++ b/test/unit/state/singular.spec.ts @@ -97,19 +97,19 @@ describe('singularState', () => { const bound = bindStateFactory(handler)(item); const actions = { - create: matcher<{ item: Profile }>('create'), - update: matcher<{ item: Partial }>('update'), - remove: matcher>('remove'), + create: matcher('create'), + update: matcher>('update'), + remove: matcher('remove'), }; test('should wire create action', () => { const newItem: Profile = { id: '2', name: 'Bob', revision: 0 }; - const result = handler.wire(bound, { type: 'create', payload: { item: newItem } }, actions); + const result = handler.wire(bound, { type: 'create', payload: newItem }, actions); expect(result).toEqual(newItem); }); test('should wire update action', () => { - const result = handler.wire(bound, { type: 'update', payload: { item: { name: 'Updated' } } }, actions); + const result = handler.wire(bound, { type: 'update', payload: { name: 'Updated' } }, actions); expect(result).toEqual({ ...item, name: 'Updated' }); }); diff --git a/test/unit/transitions.spec.ts b/test/unit/transitions.spec.ts index cfc7048..f3ef467 100644 --- a/test/unit/transitions.spec.ts +++ b/test/unit/transitions.spec.ts @@ -226,10 +226,10 @@ describe('processTransition retry metadata', () => { describe('sanitizeTransition', () => { const item = createItem(); - const stage = create.stage(item.id, item); + const stage = create.stage(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 noop = edit.stage(item); /* noops because no matching item to update */ + const conflict = edit.stage({ ...item, revision: item.revision - 1 }); const innerReducer = mock(reducer); const bindState = bindStateFactory(indexedState); diff --git a/test/utils/index.ts b/test/utils/index.ts index 366c7bf..99c7d6e 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -19,9 +19,9 @@ export const createItem = (data?: Partial): TestItem => ({ }); /** testing actions */ -export const create = createTransitions('test::add')((item: TestItem) => ({ payload: { item } })); -export const edit = createTransitions('test::edit')((item: TestItem) => ({ payload: { item } })); -export const remove = createTransitions('test::remove')((itemId: string) => ({ payload: { itemId } })); +export const create = createTransitions('test::add')((item: TestItem) => ({ payload: item, transitionId: item.id })); +export const edit = createTransitions('test::edit')((item: TestItem) => ({ payload: item, transitionId: item.id })); +export const remove = createTransitions('test::remove')((dto: Pick) => ({ payload: dto, transitionId: dto.id })); export const sync = (items: TestIndexedState) => ({ type: 'sync', payload: { items } }); export const throwAction = { type: 'throw ' }; @@ -37,12 +37,12 @@ export const indexedState = recordState({ eq: (a: TestItem) => (b: TestItem) => a.id === b.id && a.value === b.value, }); -export const reducer: HandlerReducer = (handler, action) => { +export const reducer: HandlerReducer, Partial> = (handler, action): TestIndexedState => { if (action.type === throwAction.type) throw new Error('test error'); if (action.type === 'sync') return (action as ReturnType).payload.items; - if (create.match(action)) return handler.create(action.payload.item); - if (edit.match(action)) return handler.update(action.payload.item.id, action.payload.item); - if (remove.match(action)) return handler.remove(action.payload.itemId); + 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(); }; From 22642f67ac52cda33f1d2984e4988533d7a3c7f6 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Tue, 24 Feb 2026 16:55:19 +0100 Subject: [PATCH 3/4] chore: reflect action changes in usecases --- usecases/basic/App.tsx | 26 +++++++++++++------------- usecases/lib/store/profile/actions.ts | 4 ++-- usecases/sagas/App.tsx | 12 ++++++------ usecases/sagas/saga.ts | 6 +++--- usecases/thunks/App.tsx | 16 ++++++++-------- usecases/thunks/thunk.ts | 16 ++++++++-------- 6 files changed, 40 insertions(+), 40 deletions(-) diff --git a/usecases/basic/App.tsx b/usecases/basic/App.tsx index 376f93d..7905f73 100644 --- a/usecases/basic/App.tsx +++ b/usecases/basic/App.tsx @@ -46,7 +46,7 @@ export const App: FC = () => { const handleEditEpic = async (epic: Epic) => { const transitionId = epic.id; try { - dispatch(editEpic.stage(epic.id, epic)); + dispatch(editEpic.stage(epic)); await simulateAPIRequest(); dispatch(editEpic.commit(transitionId)); } catch (error) { @@ -57,7 +57,7 @@ export const App: FC = () => { const handleDeleteEpic = async (epic: Epic) => { const transitionId = epic.id; try { - dispatch(deleteEpic.stage(epic.id)); + dispatch(deleteEpic.stage({ id: epic.id })); await simulateAPIRequest(); dispatch(deleteEpic.commit(transitionId)); } catch (error) { @@ -90,7 +90,7 @@ export const App: FC = () => { const handleEditProjectTodo = async (todo: ProjectTodo) => { const transitionId = `${todo.projectId}/${todo.id}`; try { - dispatch(editProjectTodo.stage(todo.projectId, todo.id, todo)); + dispatch(editProjectTodo.stage(todo)); await simulateAPIRequest(); dispatch(editProjectTodo.commit(transitionId)); } catch (error) { @@ -101,7 +101,7 @@ export const App: FC = () => { const handleDeleteProjectTodo = async (todo: ProjectTodo) => { const transitionId = `${todo.projectId}/${todo.id}`; try { - dispatch(deleteProjectTodo.stage(todo.projectId, todo.id)); + dispatch(deleteProjectTodo.stage({ projectId: todo.projectId, id: todo.id })); await simulateAPIRequest(); dispatch(deleteProjectTodo.commit(transitionId)); } catch (error) { @@ -124,7 +124,7 @@ export const App: FC = () => { const handleEditActivity = async (entry: ActivityEntry) => { const transitionId = entry.id; try { - dispatch(editActivity.stage(entry.id, entry)); + dispatch(editActivity.stage(entry)); await simulateAPIRequest(); dispatch(editActivity.commit(transitionId)); } catch (error) { @@ -135,7 +135,7 @@ export const App: FC = () => { const handleDismissActivity = async (entry: ActivityEntry) => { const transitionId = entry.id; try { - dispatch(dismissActivity.stage(entry.id)); + dispatch(dismissActivity.stage({ id: entry.id })); await simulateAPIRequest(); dispatch(dismissActivity.commit(transitionId)); } catch (error) { @@ -147,13 +147,13 @@ export const App: FC = () => { * CRUD update payloads are `Partial` — cast to full type since * failed stage actions always carry the complete entity. */ const retryTransition = (action: StagedAction) => { - if (createEpic.stage.match(action)) return handleCreateEpic(action.payload.item); - if (editEpic.stage.match(action)) return handleEditEpic(action.payload.item as Epic); - if (updateProfile.stage.match(action)) return handleUpdateProfile(action.payload.item); - if (createProjectTodo.stage.match(action)) return handleCreateProjectTodo(action.payload.item); - if (editProjectTodo.stage.match(action)) return handleEditProjectTodo(action.payload.item as ProjectTodo); - if (logActivity.stage.match(action)) return handleLogActivity(action.payload.item); - if (editActivity.stage.match(action)) return handleEditActivity(action.payload.item as ActivityEntry); + if (createEpic.stage.match(action)) return handleCreateEpic(action.payload); + if (editEpic.stage.match(action)) return handleEditEpic(action.payload as Epic); + if (updateProfile.stage.match(action)) return handleUpdateProfile(action.payload); + if (createProjectTodo.stage.match(action)) return handleCreateProjectTodo(action.payload); + if (editProjectTodo.stage.match(action)) return handleEditProjectTodo(action.payload as ProjectTodo); + if (logActivity.stage.match(action)) return handleLogActivity(action.payload); + if (editActivity.stage.match(action)) return handleEditActivity(action.payload as ActivityEntry); }; useAutoRetry(retryTransition); diff --git a/usecases/lib/store/profile/actions.ts b/usecases/lib/store/profile/actions.ts index eefbd5d..510e8b3 100644 --- a/usecases/lib/store/profile/actions.ts +++ b/usecases/lib/store/profile/actions.ts @@ -1,8 +1,8 @@ import { createTransitions } from '~actions'; import type { Profile } from '~usecases/lib/store/types'; -const updatePrepare = (item: Partial) => ({ payload: { item }, transitionId: 'profile' }); -const clearPrepare = () => ({ payload: {}, transitionId: 'profile' }); +const updatePrepare = (item: Partial) => ({ payload: item, transitionId: 'profile' }); +const clearPrepare = () => ({ payload: undefined as void, transitionId: 'profile' }); export const updateProfile = createTransitions('profile::update')(updatePrepare); export const clearProfile = createTransitions('profile::clear')(clearPrepare); diff --git a/usecases/sagas/App.tsx b/usecases/sagas/App.tsx index 163c107..9c864d5 100644 --- a/usecases/sagas/App.tsx +++ b/usecases/sagas/App.tsx @@ -31,15 +31,15 @@ export const App: FC = () => { const dispatch = useDispatch(); const handleCreateEpic = (epic: Epic) => dispatch(createEpic.stage(epic)); - const handleEditEpic = (epic: Epic) => dispatch(editEpic.stage(epic.id, epic)); - const handleDeleteEpic = (epic: Epic) => dispatch(deleteEpic.stage(epic.id)); + const handleEditEpic = (epic: Epic) => dispatch(editEpic.stage(epic)); + const handleDeleteEpic = (epic: Epic) => dispatch(deleteEpic.stage({ id: epic.id })); const handleUpdateProfile = (update: Partial) => dispatch(updateProfile.stage(update)); const handleCreateProjectTodo = (todo: ProjectTodo) => dispatch(createProjectTodo.stage(todo)); - const handleEditProjectTodo = (todo: ProjectTodo) => dispatch(editProjectTodo.stage(todo.projectId, todo.id, todo)); - const handleDeleteProjectTodo = (todo: ProjectTodo) => dispatch(deleteProjectTodo.stage(todo.projectId, todo.id)); + const handleEditProjectTodo = (todo: ProjectTodo) => dispatch(editProjectTodo.stage(todo)); + const handleDeleteProjectTodo = (todo: ProjectTodo) => dispatch(deleteProjectTodo.stage({ projectId: todo.projectId, id: todo.id })); const handleLogActivity = (entry: ActivityEntry) => dispatch(logActivity.stage(entry)); - const handleEditActivity = (entry: ActivityEntry) => dispatch(editActivity.stage(entry.id, entry)); - const handleDismissActivity = (entry: ActivityEntry) => dispatch(dismissActivity.stage(entry.id)); + const handleEditActivity = (entry: ActivityEntry) => dispatch(editActivity.stage(entry)); + const handleDismissActivity = (entry: ActivityEntry) => dispatch(dismissActivity.stage({ id: entry.id })); /** Sagas observe stage actions — retry is just a re-dispatch */ const retryTransition = (action: StagedAction) => dispatch(action); diff --git a/usecases/sagas/saga.ts b/usecases/sagas/saga.ts index afd119c..7e6ff31 100644 --- a/usecases/sagas/saga.ts +++ b/usecases/sagas/saga.ts @@ -12,7 +12,7 @@ export function* rootSaga() { const transitionId = getTransitionMeta(action).id; try { yield simulateAPIRequest(); - yield put(createEpic.amend(transitionId, { ...action.payload.item, id: generateId() })); + yield put(createEpic.amend(transitionId, { ...action.payload, id: generateId() })); yield put(createEpic.commit(transitionId)); } catch (error) { yield put(createEpic.fail(transitionId, error)); @@ -53,7 +53,7 @@ export function* rootSaga() { const transitionId = getTransitionMeta(action).id; try { yield simulateAPIRequest(); - yield put(createProjectTodo.amend(transitionId, { ...action.payload.item, id: generateId() })); + yield put(createProjectTodo.amend(transitionId, { ...action.payload, id: generateId() })); yield put(createProjectTodo.commit(transitionId)); } catch (error) { yield put(createProjectTodo.fail(transitionId, error)); @@ -84,7 +84,7 @@ export function* rootSaga() { const transitionId = getTransitionMeta(action).id; try { yield simulateAPIRequest(); - yield put(logActivity.amend(transitionId, { ...action.payload.item, id: generateId() })); + yield put(logActivity.amend(transitionId, { ...action.payload, id: generateId() })); yield put(logActivity.commit(transitionId)); } catch (error) { yield put(logActivity.fail(transitionId, error)); diff --git a/usecases/thunks/App.tsx b/usecases/thunks/App.tsx index 19e290a..373c449 100644 --- a/usecases/thunks/App.tsx +++ b/usecases/thunks/App.tsx @@ -44,7 +44,7 @@ export const App: FC = () => { const dispatch = useDispatch() as typeof store.dispatch; const handleCreateEpic = async (epic: Epic) => dispatch(createEpicThunk(epic)); - const handleEditEpic = async (epic: Epic) => dispatch(editEpicThunk(epic.id, epic)); + const handleEditEpic = async (epic: Epic) => dispatch(editEpicThunk(epic)); const handleDeleteEpic = async ({ id }: Epic) => dispatch(deleteEpicThunk(id)); const handleUpdateProfile = async (update: Partial) => dispatch(updateProfileThunk(update)); const handleCreateProjectTodo = async (todo: ProjectTodo) => dispatch(createProjectTodoThunk(todo)); @@ -58,13 +58,13 @@ export const App: FC = () => { * CRUD update payloads are `Partial` — cast to full type since * failed stage actions always carry the complete entity. */ const retryTransition = (action: StagedAction) => { - if (createEpic.stage.match(action)) return handleCreateEpic(action.payload.item); - if (editEpic.stage.match(action)) return handleEditEpic(action.payload.item as Epic); - if (updateProfile.stage.match(action)) return handleUpdateProfile(action.payload.item); - if (createProjectTodo.stage.match(action)) return handleCreateProjectTodo(action.payload.item); - if (editProjectTodo.stage.match(action)) return handleEditProjectTodo(action.payload.item as ProjectTodo); - if (logActivity.stage.match(action)) return handleLogActivity(action.payload.item); - if (editActivity.stage.match(action)) return handleEditActivity(action.payload.item as ActivityEntry); + if (createEpic.stage.match(action)) return handleCreateEpic(action.payload); + if (editEpic.stage.match(action)) return handleEditEpic(action.payload as Epic); + if (updateProfile.stage.match(action)) return handleUpdateProfile(action.payload); + if (createProjectTodo.stage.match(action)) return handleCreateProjectTodo(action.payload); + if (editProjectTodo.stage.match(action)) return handleEditProjectTodo(action.payload as ProjectTodo); + if (logActivity.stage.match(action)) return handleLogActivity(action.payload); + if (editActivity.stage.match(action)) return handleEditActivity(action.payload as ActivityEntry); }; useAutoRetry(retryTransition); diff --git a/usecases/thunks/thunk.ts b/usecases/thunks/thunk.ts index f7c7e8c..fb495db 100644 --- a/usecases/thunks/thunk.ts +++ b/usecases/thunks/thunk.ts @@ -23,10 +23,10 @@ export const createEpicThunk = (epic: Epic): Thunk => async (dispatch) => { } }; -export const editEpicThunk = (id: string, update: Epic): Thunk => async (dispatch) => { - const transitionId = id; +export const editEpicThunk = (epic: Epic): Thunk => async (dispatch) => { + const transitionId = epic.id; try { - dispatch(editEpic.stage(id, update)); + dispatch(editEpic.stage(epic)); await simulateAPIRequest(); dispatch(editEpic.commit(transitionId)); } catch (error) { @@ -37,7 +37,7 @@ export const editEpicThunk = (id: string, update: Epic): Thunk => async (dispatc export const deleteEpicThunk = (id: string): Thunk => async (dispatch) => { const transitionId = id; try { - dispatch(deleteEpic.stage(id)); + dispatch(deleteEpic.stage({ id })); await simulateAPIRequest(); dispatch(deleteEpic.commit(transitionId)); } catch { @@ -70,7 +70,7 @@ export const createProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispa export const editProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispatch) => { const transitionId = `${todo.projectId}/${todo.id}`; try { - dispatch(editProjectTodo.stage(todo.projectId, todo.id, todo)); + dispatch(editProjectTodo.stage(todo)); await simulateAPIRequest(); dispatch(editProjectTodo.commit(transitionId)); } catch (error) { @@ -81,7 +81,7 @@ export const editProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispatc export const deleteProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispatch) => { const transitionId = `${todo.projectId}/${todo.id}`; try { - dispatch(deleteProjectTodo.stage(todo.projectId, todo.id)); + dispatch(deleteProjectTodo.stage({ projectId: todo.projectId, id: todo.id })); await simulateAPIRequest(); dispatch(deleteProjectTodo.commit(transitionId)); } catch { @@ -104,7 +104,7 @@ export const logActivityThunk = (entry: ActivityEntry): Thunk => async (dispatch export const editActivityThunk = (entry: ActivityEntry): Thunk => async (dispatch) => { const transitionId = entry.id; try { - dispatch(editActivity.stage(entry.id, entry)); + dispatch(editActivity.stage(entry)); await simulateAPIRequest(); dispatch(editActivity.commit(transitionId)); } catch (error) { @@ -115,7 +115,7 @@ export const editActivityThunk = (entry: ActivityEntry): Thunk => async (dispatc export const dismissActivityThunk = (entry: ActivityEntry): Thunk => async (dispatch) => { const transitionId = entry.id; try { - dispatch(dismissActivity.stage(entry.id)); + dispatch(dismissActivity.stage({ id: entry.id })); await simulateAPIRequest(); dispatch(dismissActivity.commit(transitionId)); } catch { From 9bdee4824ea25e03fa291b532680341a9cd7ff87 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Tue, 24 Feb 2026 16:59:21 +0100 Subject: [PATCH 4/4] chore: refine readmes --- ARCHITECTURE.md | 406 +++++++++++++++++++++++++----------------------- README.md | 306 +++++++++++++++--------------------- 2 files changed, 334 insertions(+), 378 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 70981b6..51ccf45 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,234 +1,207 @@ # Architecture -Internals, API, and advanced patterns. For quick start, see [README.md](./README.md). +Internals, design decisions, and the full API reference. For getting started, see [README.md](./README.md). --- +- [Data Flow](#data-flow) - [Entity Identity](#entity-identity) -- [Versioning & Conflicts](#versioning--conflicts) -- [StateHandler](#statehandler) +- [Versioning & Conflict Detection](#versioning--conflict-detection) - [Sanitization](#sanitization) -- [Data Flow](#data-flow) -- [Custom Handlers](#custom-handlers) -- [Transition Modes](#transition-modes) +- [StateHandler Interface](#statehandler-interface) +- [Transition Modes In Depth](#transition-modes-in-depth) - [Async Patterns](#async-patterns) +- [Module Map](#module-map) +- [Performance Invariants](#performance-invariants) - [API Reference](#api-reference) --- -## Entity Identity +## Data Flow + +

+ Data Flow +

+ +Two paths through the system: + +**Write path** — dispatching actions: +1. `stage`/`amend` dispatched — only the transitions list is updated, reducer state is untouched +2. `commit` dispatched — reducer state is updated via the bound reducer, transition is removed +3. After every mutation (gated by `===`), `sanitizeTransitions` replays all remaining transitions to detect no-ops and conflicts + +**Read path** — selecting state: +1. `selectOptimistic` replays pending transitions on top of committed state +2. Returns the derived optimistic view — never stored, always computed +3. Memoization is the consumer's responsibility via `createSelector` + +--- -Every transition carries a string ID — the **stable link between a transition and its entity in state**. One ID, one entity. Because: +## Entity Identity -- **Sanitization** replays by ID — shared IDs cause shadowing -- **Selectors** look up by ID — ambiguous IDs break lookups -- **Dedupe** matches on ID +Every transition carries a string ID — the **stable link between a transition and its entity**. This ID is used everywhere: sanitization replays by ID, selectors look up by ID, deduplication matches on ID. -**The recommended default is `transitionId === entityId`.** Use `crudPrepare` to couple them: +**Default: `transitionId === entityId`.** Use `crudPrepare` to couple them automatically: ```typescript 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.amend(tid, amended)); // explicit — targets existing 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. +**Why only `stage` auto-detects:** `stage` initiates a new transition — the entity *is* the transition. `amend`/`commit`/`fail`/`stash` target an *existing* transition the consumer already holds a reference to. Auto-detecting on `amend` would be a footgun: 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`. +For edge-cases where `transitionId !== entityId` (batch ops, correlation IDs, temp-to-server ID mapping), write custom prepare functions. --- -## Versioning & Conflicts +## Versioning & Conflict Detection -Conflict detection needs **version ordering**. Entities carry a monotonically increasing value — `revision`, `updatedAt`, sequence number. +Entities must carry a **monotonically increasing version** — `revision`, `updatedAt`, sequence number — anything orderable. Two curried comparators drive conflict detection: ```typescript -compare: (a: T) => (b: T) => 0 | 1 | -1 // version ordering +compare: (a: T) => (b: T) => 0 | 1 | -1 // version ordering eq: (a: T) => (b: T) => boolean // content equality at same version ``` -During sanitization, `merge` runs `compare` per entity: +During sanitization, `merge` calls `compare` on each entity: -| `compare` | Then | Outcome | -|-----------|------|---------| -| `1` (newer) | — | Valid update | -| `0` (same) | `eq` → `true` | Skip (no-op) | -| `0` (same) | `eq` → `false` | **Conflict** | -| `-1` (older) | — | **Conflict** | +| `compare` result | Then check | Outcome | +|------------------|------------|---------| +| `1` (transition is newer) | — | **Valid** — keep | +| `0` (same version) | `eq` returns `true` | **Skip** — no-op, discard | +| `0` (same version) | `eq` returns `false` | **Conflict** — flag | +| `-1` (transition is older) | — | **Conflict** — flag | -Thrown as `OptimisticMergeResult.CONFLICT` / `.SKIP`, caught by `sanitizeTransitions`. +These are thrown as `OptimisticMergeResult.SKIP` / `.CONFLICT` and caught by `sanitizeTransitions`. -Without versioning, conflict detection degrades to content equality — missing concurrent mutations from other clients. +Without versioning, conflict detection degrades to content equality — it can't distinguish concurrent mutations from different clients. --- -## StateHandler - -```typescript -interface StateHandler { - create: (state: State, ...args: CreateParams) => State; - update: (state: State, ...args: UpdateParams) => State; - remove: (state: State, ...args: DeleteParams) => State; - merge: (current: State, incoming: State) => State; -} -``` - -**Key invariant:** `update`/`remove` return the **same reference** on no-op. Sanitization uses `===` to detect effect. - -### Auto-wired CRUD - -Built-in handlers (`recordState`, `nestedRecordState`, `singularState`) expose a typed `wire` method as a structural extension — it is **not on the `StateHandler` interface** because each handler needs specifically typed `CrudActionMap` payloads. The `wire` method uses `ActionMatcher

` type guards to narrow action payloads without `as any` casts. When the consumer passes a CRUD action map instead of a function, `wire` handles the dispatch: +## Sanitization -```typescript -// Zero boilerplate — handler.wire does the routing -optimistron('todos', initial, handler, { - create: createTodo, update: editTodo, remove: deleteTodo, -}); +

+ Sanitization Flow +

-// Hybrid — auto-wire CRUD + fallback for custom actions -optimistron('todos', initial, handler, { - create: createTodo, update: editTodo, remove: deleteTodo, - reducer: ({ getState }, action) => { /* custom logic */ }, -}); -``` +After every state mutation, `sanitizeTransitions` replays all pending transitions against committed state: -The `wire` method is handler-specific because each handler needs typed payload shapes. `optimistron()` uses function overloads — the auto-wire overload infers the CRUD map type `A` from `WireMethod` on the handler, enforcing that each action matcher produces the right payload shape at compile time: +1. Start with a shallow working copy of committed state (`Object.assign({}, state)` — the only copy in the system) +2. For each transition: apply as-if-committed, check if state reference changed (`!==`), then `merge` to validate +3. Result per transition: **keep** (valid), **discard** (no-op/skip), or **flag** (conflict) -| Handler | `wire` unpacks payload as | -|---------|--------------------------| -| `recordState` | `create(item)`, `update(id, item)`, `remove(id)` | -| `nestedRecordState` | `create(item)`, `update(...path, item)`, `remove(...path)` | -| `singularState` | `create(item)`, `update(item)`, `remove()` | -| `listState` | `create(item)`, `update(id, item)`, `remove(id)` | +Sanitization only runs when state actually changes — gated by referential equality (`===`). -### Manual mode +--- -Pass a function for full control — the `BoundStateHandler` is the handler closed over current state: +## StateHandler Interface ```typescript -({ 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 (deleteTodo.match(action)) return remove(action.payload.id); - return getState(); +interface StateHandler { + create: (state: State, dto: C) => State; + update: (state: State, dto: U) => State; + remove: (state: State, dto: D) => State; + merge: (current: State, incoming: State) => State; } ``` ---- - -## Sanitization - -After every state mutation, `sanitizeTransitions` replays pending transitions against committed state. - -

- Sanitization Flow -

- -For each transition: apply as-if-committed, check if state mutated, then `merge` to validate. Results: **keep**, **discard** (no-op/skip), or **flag** (conflict). Gated by `!==` — only runs when state actually changed. - ---- - -## Data Flow - -

- Data Flow -

+`C`, `U`, `D` are scalar DTO generics — each operation takes a single object argument (identity + data together). ---- +**Critical invariant:** `update` and `remove` must return the **same reference** when nothing changed. Sanitization uses `===` to detect whether a transition had any effect. If your handler returns a new object on no-op, sanitization breaks. -## Built-in State Handlers +### Built-in handlers -Four built-in handlers cover the common state shapes: +| Handler | State shape | DTO types | Options | +|---------|-------------|-----------|---------| +| `recordState` | `Record` | `C=T`, `U=Partial`, `D=Partial` | `{ key, compare, eq }` | +| `nestedRecordState()` | `Record>` | `C=T`, `U=UpdateDTO`, `D=DeleteDTO` | `{ keys, compare, eq }` | +| `singularState` | `T \| null` | `C=T`, `U=Partial`, `D=void` | `{ compare, eq }` | +| `listState` | `T[]` | `C=T`, `U=Partial`, `D=Partial` | `{ key, compare, eq }` | -### `recordState` — flat key-value map +### Auto-wired CRUD -`Record` indexed by a single key. Depth-1 specialization of `nestedRecordState`. +Built-in handlers expose a `wire` method via `WiredStateHandler`. When you pass a CRUD action map instead of a reducer function, `wire` handles action matching and payload routing: ```typescript -import { recordState, crudPrepare } from '@lostsolution/optimistron'; -const handler = recordState({ key: 'id', compare, eq }); -const crud = crudPrepare('id'); +// wire does the routing — zero boilerplate +optimistron('todos', initial, handler, { + create: createTodo, update: editTodo, remove: deleteTodo, +}); ``` -### `singularState` — single object +`optimistron()` uses function overloads to infer the CRUD map type from the handler's `wire` method, enforcing that each action matcher produces the right payload shape **at compile time**. -`T | null` for singletons (profile, settings). CRUD operates on the whole object. +### Custom handlers -```typescript -import { singularState } from '@lostsolution/optimistron'; -const handler = singularState({ compare, eq }); -``` +Implement `StateHandler` for any shape. The contract: -### `nestedRecordState()` — nested records +1. `update`/`remove` must return the same reference on no-op +2. `merge` must throw `OptimisticMergeResult.SKIP` for redundant transitions +3. `merge` must throw `OptimisticMergeResult.CONFLICT` for stale transitions +4. `merge` must return the merged state for valid transitions -`Record>` for multi-level grouping. Curried: fix `T`, infer keys. Multi-key `crudPrepare` joins path IDs with `/` for the transitionId. - -```typescript -import { nestedRecordState, crudPrepare } from '@lostsolution/optimistron'; -const handler = nestedRecordState()({ keys: ['projectId', 'id'], compare, eq }); -const crud = crudPrepare()(['projectId', 'id']); -``` +--- -### `listState` — ordered list +## Transition Modes In Depth -`T[]` for collections where insertion order matters. Items identified by a single key on `T`. +`TransitionMode` is a single enum that controls both re-staging and failure behavior. Declared per action type at the `createTransitions` site — making invalid state combinations unrepresentable. -```typescript -import { listState, crudPrepare } from '@lostsolution/optimistron'; -const handler = listState({ key: 'id', compare, eq }); -const crud = crudPrepare('id'); -``` +### `DEFAULT` — edits -## Custom Handlers +- **Re-stage:** overwrites the existing transition +- **Fail:** flags the transition as failed, keeps it in the list +- **Use case:** user edits an entity, server rejects — show error, let user retry -For shapes not covered by the built-ins, implement `StateHandler` directly. +### `DISPOSABLE` — creates -**The contract:** -1. `update`/`remove` → same reference on no-op -2. `merge` → throw `SKIP` on redundant, `CONFLICT` on stale, return merged on valid +- **Re-stage:** overwrites the existing transition +- **Fail:** drops the transition entirely +- **Use case:** user creates an entity, server rejects — the entity never existed, remove it from view ---- +### `REVERTIBLE` — deletes -## Transition Modes +- **Re-stage:** stores the replaced transition as a trailing fallback +- **Fail:** stashes the transition (reverts to the trailing fallback) +- **Use case:** user deletes an entity, server rejects — undo the deletion, restore the entity -`TransitionMode` controls re-staging and failure behavior per action type: +### Retry -| Mode | On re-stage | On fail | Use case | -|------|-------------|---------|----------| -| `DEFAULT` | Overwrite | Flag as failed | Edits | -| `DISPOSABLE` | Overwrite | Drop transition | Creates | -| `REVERTIBLE` | Store trailing | Stash (revert) | Deletes | +Retry is always a consumer concern — timing, backoff, and reconnect logic belong in your app. The library provides: ```typescript -const createTodo = createTransitions('todos::add', TransitionMode.DISPOSABLE)(crud.create); -const editTodo = createTransitions('todos::edit')(crud.update); -const deleteTodo = createTransitions('todos::delete', TransitionMode.REVERTIBLE)(crud.remove); +import { retryTransition, selectFailedTransition, selectRetryCount } from '@lostsolution/optimistron'; + +const failed = selectFailedTransition(id)(state.todos); +if (failed) { + const retries = selectRetryCount(id)(state.todos); + if (retries < 3) dispatch(retryTransition(failed)); // strips failure flags, re-stages +} ``` -`REVERTIBLE` stores the replaced transition as a trailing fallback. On fail or explicit stash, the previous transition is restored. +When `processTransition` overwrites a failed transition (re-stage or amend after fail), it increments `retryCount` and sets `lastRetry` (timestamp). --- ## Async Patterns -Transport-agnostic. Works with anything: +Optimistron is transport-agnostic. The pattern is always: stage, then resolve.
-Component-level +Component-level async ```typescript const handleCreate = async (todo: Todo) => { - const transitionId = todo.id; - dispatch(createTodo.stage(todo)); // auto-detect transitionId + dispatch(createTodo.stage(todo)); try { - await api.create(todo); - dispatch(createTodo.amend(transitionId, { ...todo, id: serverId })); // explicit - dispatch(createTodo.commit(transitionId)); + const saved = await api.create(todo); + dispatch(createTodo.amend(todo.id, saved)); + dispatch(createTodo.commit(todo.id)); } catch (e) { - dispatch(createTodo.fail(transitionId, e)); + dispatch(createTodo.fail(todo.id, e)); } }; ``` @@ -242,14 +215,13 @@ const handleCreate = async (todo: Todo) => { const createTodoThunk = (todo: Todo): ThunkAction => async (dispatch) => { - const transitionId = todo.id; dispatch(createTodo.stage(todo)); try { - await api.create(todo); - dispatch(createTodo.amend(transitionId, { ...todo, id: serverId })); - dispatch(createTodo.commit(transitionId)); + const saved = await api.create(todo); + dispatch(createTodo.amend(todo.id, saved)); + dispatch(createTodo.commit(todo.id)); } catch (e) { - dispatch(createTodo.fail(transitionId, e)); + dispatch(createTodo.fail(todo.id, e)); } }; ``` @@ -263,8 +235,8 @@ const createTodoThunk = function* createTodoSaga(action: ReturnType) { const transitionId = getTransitionMeta(action).id; try { - yield call(api.create, action.payload.item); - yield put(createTodo.amend(transitionId, { ...action.payload.item, id: serverId })); + const saved = yield call(api.create, action.payload); + yield put(createTodo.amend(transitionId, saved)); yield put(createTodo.commit(transitionId)); } catch (e) { yield put(createTodo.fail(transitionId, e)); @@ -276,84 +248,132 @@ function* createTodoSaga(action: ReturnType) { --- +## Module Map + +``` +src/ +├── index.ts # Public API surface (barrel export) +├── optimistron.ts # Factory: wraps reducers, returns { reducer, selectOptimistic } +├── transitions.ts # Transition operations, processTransition, sanitizeTransitions +├── reducer.ts # resolveReducer, bindReducer +├── constants.ts # META_KEY +│ +├── actions/ +│ ├── index.ts # Barrel: re-exports public API +│ ├── transitions.ts # createTransition, createTransitions, resolveTransition +│ ├── crud.ts # crudPrepare (single-key + multi-key overloads) +│ └── types.ts # PreparePayload, PrepareError, ActionMeta, ItemPath, UpdateDTO, DeleteDTO +│ +├── selectors/ +│ ├── internal.ts # createSelectOptimistic (returned from optimistron, not exported) +│ └── selectors.ts # selectIsFailed, selectIsOptimistic, selectIsConflicting, etc. +│ +├── state/ +│ ├── types.ts # TransitionState, StateHandler, WiredStateHandler, BoundStateHandler +│ ├── factory.ts # bindStateFactory, buildTransitionState, transitionStateFactory +│ ├── record.ts # recordState, nestedRecordState +│ ├── singular.ts # singularState +│ └── list.ts # listState +│ +└── utils/ + ├── path.ts # getAt, setAt, removeAt — nested Record path traversal + ├── types.ts # StringKeys, PathMap, Maybe, MaybeNull + └── logger.ts # warn +``` + +Key implementation details: + +- **`TransitionState`** wraps user state with a non-enumerable `transitions` list (via `Object.defineProperties` — hidden from serializers and spreads) +- **`transitionStateFactory`** returns the previous state object when both `state` and `transitions` are referentially equal (preserves memoization) +- **`selectOptimistic`** is closed over the bound reducer — no global state needed +- **Action types** use `namespace::operation` format, matching uses `startsWith` + +--- + +## Performance Invariants + +These are non-negotiable — the library design depends on them: + +1. **No full state copies.** The only shallow copy is `Object.assign({}, state)` in `sanitizeTransitions` — a mutable working copy, not a checkpoint. +2. **`sanitizeTransitions` runs on every state mutation.** Keep it lean. No unnecessary allocations. +3. **Referential equality (`===`) gates sanitization.** `transitionStateFactory` returns the previous state object when nothing changed. +4. **`selectOptimistic` replays all transitions on every call.** Memoization is the consumer's job via `createSelector`. Fast-path returns early when `transitions.length === 0`. +5. **Handler operations return the same reference on no-op.** This is how sanitization detects no-ops. + +--- + ## API Reference ### `optimistron(namespace, initialState, handler, config, options?)` -Returns `{ reducer, selectOptimistic }`. +Creates an optimistic reducer wrapper. Returns `{ reducer, selectOptimistic }`. | Param | Type | Description | |-------|------|-------------| -| `namespace` | `string` | Action type prefix | -| `initialState` | `S` | Initial state | +| `namespace` | `string` | Action type prefix (`"namespace::operation"`) | +| `initialState` | `S` | Initial state value | | `handler` | `StateHandler` | State handler implementation | -| `config` | `ReducerConfig` | CRUD action map (auto-wired) or function (manual) | -| `options.sanitizeAction` | `(action) => action` | Optional action transform | +| `config` | `ReducerConfig` | CRUD action map or reducer function | +| `options.sanitizeAction` | `(action) => action` | Optional action transform before sanitization | ### `selectOptimistic(selector)` -Returned from `optimistron()`. Replays transitions before selecting. Memoize with `createSelector`. +Returned from `optimistron()`. Replays pending transitions before applying the selector. Always wrap with `createSelector`: ```typescript -selectOptimistic((todos) => Object.values(todos.state)) +const selectTodos = createSelector( + (state: RootState) => state.todos, + selectOptimistic((todos) => Object.values(todos.state)), +); +``` + +### `createTransitions(type, mode?)(prepare)` + +Creates a full set of transition action creators: `.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`. + +`prepare` can be a single prepare function (shared across operations) or an object with per-operation preparators: + +```typescript +createTransitions('todos::add')({ + stage: (item: Todo) => ({ payload: item, transitionId: item.id }), + commit: () => ({ payload: {} }), +}); ``` ### `crudPrepare(key)` / `crudPrepare()(keys)` -Factory for CRUD prepare functions that couple `transitionId === entityId`. +Factory for CRUD prepare functions that couple `transitionId === entityId`: ```typescript -// Single-key (recordState): +// Single-key (recordState, listState) 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 } +// crud.create(todo) → payload: todo, transitionId: todo.id +// crud.update({ id, done }) → payload: { id, done }, transitionId: id +// crud.remove({ id }) → payload: { id }, transitionId: id -// Multi-key (nestedRecordState) — curried for key inference: +// Multi-key (nestedRecordState) — curried for key inference const crud = crudPrepare()(['projectId', 'id']); -// crud.create(item) → { payload: { item }, transitionId: "projectId/id" } -// crud.update(projectId, id, partial) → { payload: { path, item }, transitionId: "projectId/id" } -// crud.remove(projectId, id) → { payload: { path }, transitionId: "projectId/id" } +// transitionId: "projectId-value/id-value" ``` ### `retryTransition(action)` -Strips `failed` and `conflict` flags from a `StagedAction`, returning a clean action ready for re-dispatch. - -### `createTransitions(type, mode?)(prepare)` - -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 }, transitionId: item.id }), - commit: () => ({ payload: {} }), -}); -``` +Strips `failed` and `conflict` flags from a `StagedAction`, returning a clean action for re-dispatch. ### Selectors +All transition selectors are curried: `selector(id)(transitionState)`. + | Selector | Returns | |----------|---------| -| `selectIsOptimistic(id)` | `boolean` — pending | -| `selectIsFailed(id)` | `boolean` — failed | -| `selectIsConflicting(id)` | `boolean` — conflicting | +| `selectIsOptimistic(id)` | `boolean` — transition is pending | +| `selectIsFailed(id)` | `boolean` — transition has failed | +| `selectIsConflicting(id)` | `boolean` — transition conflicts with committed state | | `selectFailedTransition(id)` | `StagedAction \| undefined` | | `selectConflictingTransition(id)` | `StagedAction \| undefined` | -| `selectFailedTransitions` | `StagedAction[]` | -| `selectAllFailedTransitions(...states)` | `StagedAction[]` — aggregated across slices | -| `selectRetryCount(id)` | `number` — retry count (0 if none) | - -### State Handler Factories - -| Factory | Options | State shape | -|---------|---------|-------------| -| `recordState` | `{ key, compare, eq }` | `Record` | -| `singularState` | `{ compare, eq }` | `T \| null` | -| `nestedRecordState()(opts)` | `{ keys, compare, eq }` | Nested records | -| `listState` | `{ key, compare, eq }` | `T[]` | +| `selectRetryCount(id)` | `number` — times re-staged after failure | +| `selectFailedTransitions` | `(state) => StagedAction[]` — all failed in one slice | +| `selectAllFailedTransitions` | `(...states) => StagedAction[]` — across slices | ### Enums diff --git a/README.md b/README.md index 19c5176..9f36c5f 100644 --- a/README.md +++ b/README.md @@ -7,34 +7,37 @@ Redux Toolkit ^2.11.2

-> Opinionated optimistic state management for Redux. Tracks transitions alongside reducer state and derives the optimistic view at the selector level — like `git rebase`. No state copies. No checkpoints. +> Optimistic state management for Redux. No state copies, no checkpoints — optimistic state is derived at the selector level, like `git rebase`. -## When to use Optimistron +--- -Optimistron is a good fit when your app has: +## Why Optimistron? -- **Offline-first flows** — users act while disconnected, transitions queue up, conflicts resolve on reconnect. -- **Async dispatch patterns** — thunks, sagas, listener middleware — anything where you dispatch an intent and later resolve it with success or failure. -- **Large or normalized state** — where snapshotting the full state tree per in-flight operation gets expensive fast. +Most optimistic-update libraries snapshot your entire state tree for every in-flight operation. Optimistron doesn't. It tracks lightweight **transitions** (stage, amend, commit, fail) alongside your reducer state and replays them at read-time through `selectOptimistic` — right where `reselect` memoization already lives. -Other libraries solve optimistic updates in their own way — snapshot/replay, cache patching, query-level invalidation. Optimistron is a different tradeoff: **no state copies, no checkpoints**. Optimistic state is derived at the selector level — which is already memoized by `reselect` in most Redux apps. You get optimistic UI on the read path, with zero write-path overhead. +Good fit for: +- **Offline-first** — transitions queue up while disconnected, conflicts resolve on reconnect +- **Async dispatch** — thunks, sagas, listener middleware +- **Large/normalized state** — no per-operation snapshots -> If you're already using RTK Query's built-in optimistic updates and they cover your needs, you probably don't need this. +> Already happy with RTK Query's built-in optimistic updates? You probably don't need this. --- ## The Mental Model -Think of each reducer you wrap with `optimistron()` as a **branch** — not the whole store. +Think of each `optimistron()` reducer as a **git branch**: -- **Committed state** = the branch tip. Source of truth — only `COMMIT` advances it. -- **Transitions** = staged commits on top of that branch. Intended changes that haven't landed yet. -- **`selectOptimistic`** = `rebase`. Replays transitions onto the branch tip at read-time. Never stored — always derived. -- **Sanitization** = conflict detection. After every mutation, transitions are replayed. No-ops get discarded. Conflicts get flagged. +| Git | Optimistron | +|-----|-------------| +| Branch tip | **Committed state** — only `COMMIT` advances it | +| Staged commits | **Transitions** — pending changes on top of committed state | +| `git rebase` | **`selectOptimistic`** — replays transitions at read-time | +| Merge conflict | **Sanitization** — detects no-ops and conflicts after every mutation | -`STAGE`, `AMEND`, `FAIL`, `STASH` never touch reducer state — they only modify the transitions list. The optimistic view updates because `selectOptimistic` re-derives it on the next read. +`STAGE`, `AMEND`, `FAIL`, `STASH` never touch reducer state — they only modify the transitions list. The optimistic view updates because `selectOptimistic` re-derives on the next read. -No `isLoading`, `error`, `isOptimistic` flags. A pending transition means loading. A failed one means error. A conflicting one means stale. One source of truth, zero boilerplate. +No `isLoading` / `error` / `isOptimistic` flags. A pending transition *is* loading. A failed one *is* the error. One source of truth. --- @@ -46,290 +49,223 @@ No `isLoading`, `error`, `isOptimistic` flags. A pending transition means loadin --- +## Install + +```bash +npm install @lostsolution/optimistron +# peer deps: @reduxjs/toolkit ^2.1.0, redux ^5.0.1 +``` + +--- + ## Quick Start ```typescript import { configureStore, createSelector } from '@reduxjs/toolkit'; -import { optimistron, createTransitions, crudPrepare, recordState, TransitionMode } from '@lostsolution/optimistron'; +import { + optimistron, createTransitions, crudPrepare, + recordState, TransitionMode, +} from '@lostsolution/optimistron'; +// 1. Define your entity type Todo = { id: string; value: string; done: boolean; revision: number }; +// 2. Create CRUD prepare functions (couples transitionId === entityId) const crud = crudPrepare('id'); + +// 3. Create transition action creators const createTodo = createTransitions('todos::add', TransitionMode.DISPOSABLE)(crud.create); -const editTodo = createTransitions('todos::edit')(crud.update); +const editTodo = createTransitions('todos::edit')(crud.update); // DEFAULT mode const deleteTodo = createTransitions('todos::delete', TransitionMode.REVERTIBLE)(crud.remove); +// 4. Create the optimistic reducer const { reducer: todos, selectOptimistic } = optimistron( - 'todos', - {} as Record, - recordState({ - key: 'id', - compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1), - eq: (a) => (b) => a.done === b.done && a.value === b.value, - }), - { create: createTodo, update: editTodo, remove: deleteTodo }, + 'todos', + {} as Record, + recordState({ + key: 'id', + compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1), + eq: (a) => (b) => a.done === b.done && a.value === b.value, + }), + { create: createTodo, update: editTodo, remove: deleteTodo }, ); +// 5. Wire up the store const store = configureStore({ reducer: { todos } }); +// 6. Select optimistic state (memoize with createSelector) const selectTodos = createSelector( - (state) => state.todos, - selectOptimistic((todos) => Object.values(todos.state)), + (state: RootState) => state.todos, + selectOptimistic((todos) => Object.values(todos.state)), ); -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 +// 7. Dispatch transitions +dispatch(createTodo.stage(todo)); // optimistic — shows immediately +dispatch(createTodo.commit(todo.id)); // server confirmed — becomes committed state +dispatch(createTodo.fail(todo.id, error)); // server rejected — flagged as failed ``` --- -## Rules - -### Transition IDs +## Three Rules -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. +1. **One ID, one entity** — each transition ID maps to exactly one entity +2. **One at a time** — don't stage a new transition while one is already pending for the same ID +3. **One operation per transition** — a single create, update, or delete -**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 +## Versioning -Entities need a **monotonically increasing version** — `revision`, `updatedAt`, anything orderable. The `compare` function uses this to determine if a transition is still valid, stale, or redundant during sanitization. Without it, conflict detection can't distinguish "newer" from "older". +Entities need a **monotonically increasing version** — `revision`, `updatedAt`, a sequence number. This is how sanitization tells "newer" from "stale": -### The rules +```typescript +compare: (a) => (b) => 0 | 1 | -1 // version ordering (curried) +eq: (a) => (b) => boolean // content equality at same version (curried) +``` -1. **One ID, one entity** — each transition ID resolves to a single entity. -2. **One at a time** — don't stage while one is already pending for the same ID. -3. **Granular** — one create, one update, or one delete per transition. +Without versioning, conflict detection degrades to content equality only. --- ## State Handlers -Optimistron ships four built-in `StateHandler` implementations. Each one defines `create`, `update`, `remove`, and `merge` for a different state shape. +Four built-in handlers for common state shapes. Each defines `create`, `update`, `remove`, and `merge`. ### `recordState` — flat key-value map -`Record` indexed by a single key on `T`. The most common shape for entity collections. +`Record` indexed by a single key. The most common shape. ```typescript -import { recordState, crudPrepare } from '@lostsolution/optimistron'; - -const handler = recordState({ - key: 'id', - compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1), - eq: (a) => (b) => a.done === b.done && a.value === b.value, -}); +const handler = recordState({ key: 'id', compare, eq }); const crud = crudPrepare('id'); ``` -### `singularState` — single object +### `nestedRecordState` — nested records -`T | null` for singleton entities like a user profile or app settings. CRUD operates on the whole object; `merge` uses `compare`/`eq` on non-null values. +`Record>` for multi-level grouping. Curried to fix `T` and infer the keys tuple. `transitionId` joins path IDs with `/`. ```typescript -import { singularState } from '@lostsolution/optimistron'; - -type Profile = { displayName: string; avatarUrl: string; revision: number }; - -const handler = singularState({ - compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1), - eq: (a) => (b) => a.displayName === b.displayName && a.avatarUrl === b.avatarUrl, -}); +const handler = nestedRecordState()({ keys: ['projectId', 'id'], compare, eq }); +const crud = crudPrepare()(['projectId', 'id']); ``` -### `nestedRecordState` — nested records +### `singularState` — single object -`Record>` for multi-level grouping. Curried to fix `T` and infer the keys tuple. `crudPrepare` multi-key overload derives `transitionId` by joining path IDs with `/`. +`T | null` for singletons (user profile, settings). ```typescript -import { nestedRecordState, crudPrepare } from '@lostsolution/optimistron'; - -type ProjectTodo = { id: string; projectId: string; value: string; revision: number }; - -const handler = nestedRecordState()({ - keys: ['projectId', 'id'], - compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1), - eq: (a) => (b) => a.value === b.value, -}); -const crud = crudPrepare()(['projectId', 'id']); +const handler = singularState({ compare, eq }); ``` ### `listState` — ordered list -`T[]` for collections where insertion order matters or consumers need array semantics. Items identified by a single key on `T`, like `recordState`. +`T[]` where insertion order matters. ```typescript -import { listState, crudPrepare } from '@lostsolution/optimistron'; - -const handler = listState({ - key: 'id', - compare: (a) => (b) => (a.revision === b.revision ? 0 : a.revision > b.revision ? 1 : -1), - eq: (a) => (b) => a.done === b.done && a.value === b.value, -}); +const handler = listState({ key: 'id', compare, eq }); const crud = crudPrepare('id'); ``` -You can implement the `StateHandler` interface for any state shape — the built-in handlers are just the common cases. +You can implement the `StateHandler` interface for any shape — the built-ins are just the common cases. --- -## Reducer Config +## Reducer Configuration -The 4th argument to `optimistron()` accepts three modes: - -### Auto-wired (zero boilerplate) - -Pass a CRUD action map — the handler's built-in `wire` method routes `crudPrepare` payloads automatically: +The 4th argument to `optimistron()` supports three modes: +**Auto-wired** — zero boilerplate, handler routes payloads: ```typescript optimistron('todos', initial, handler, { - create: createTodo, - update: editTodo, - remove: deleteTodo, + create: createTodo, update: editTodo, remove: deleteTodo, }); ``` -### Hybrid (auto-wired + fallback) - -Auto-wire CRUD and handle custom actions in a fallback reducer: - +**Hybrid** — auto-wire + fallback for custom actions: ```typescript optimistron('todos', initial, handler, { - create: createTodo, - update: editTodo, - remove: deleteTodo, - reducer: ({ getState }, action) => { - if (sync.match(action)) return /* custom logic */; - return getState(); - }, + create: createTodo, update: editTodo, remove: deleteTodo, + reducer: ({ getState }, action) => { /* custom logic */ }, }); ``` -### Manual (full control) - -Pass a function — the current behavior, nothing changes: - +**Manual** — full control via `BoundStateHandler`: ```typescript optimistron('todos', initial, handler, ({ getState, create, update, remove }, action) => { - 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(); + if (createTodo.match(action)) return create(action.payload); + if (editTodo.match(action)) return update(action.payload); + if (deleteTodo.match(action)) return remove(action.payload); + return getState(); }); ``` -All three modes are fully backwards compatible. The CRUD map only requires `{ match }` — an `ActionMatcher

` type guard — from each action creator. `optimistron()` uses function overloads to infer the expected payload types from the handler, so mismatched action creators are caught at compile time. +--- + +## Transition Modes + +Declared per action type — controls what happens on re-stage and failure: + +| Mode | On re-stage | On fail | Typical use | +|------|-------------|---------|-------------| +| `DEFAULT` | Overwrite | Flag as failed | Edits | +| `DISPOSABLE` | Overwrite | Drop transition | Creates | +| `REVERTIBLE` | Store trailing | Revert to previous | Deletes | --- ## Selectors -### `selectOptimistic` - -Returned from `optimistron()`. Replays pending transitions onto committed state at read-time. Wrap with `createSelector` for memoization: +### Optimistic state ```typescript -import { createSelector } from '@reduxjs/toolkit'; - const selectTodos = createSelector( - (state: RootState) => state.todos, - selectOptimistic((todos) => Object.values(todos.state)), + (state: RootState) => state.todos, + selectOptimistic((todos) => Object.values(todos.state)), ); ``` ### Per-entity status -All transition selectors take a `transitionId` and a `TransitionState` — no global store shape required: - -```typescript -import { - selectIsOptimistic, - selectIsFailed, - selectIsConflicting, - selectFailedTransition, - selectConflictingTransition, - selectRetryCount, -} from '@lostsolution/optimistron'; - -selectIsOptimistic(id)(state.todos) // true if transition is pending -selectIsFailed(id)(state.todos) // true if transition has failed -selectIsConflicting(id)(state.todos) // true if transition conflicts with committed state - -selectFailedTransition(id)(state.todos) // StagedAction | undefined -selectConflictingTransition(id)(state.todos) // StagedAction | undefined -selectRetryCount(id)(state.todos) // number of times this transition was re-staged after failure -``` - -### Aggregate selectors - ```typescript -import { selectFailedTransitions, selectAllFailedTransitions } from '@lostsolution/optimistron'; +import { selectIsOptimistic, selectIsFailed, selectIsConflicting } from '@lostsolution/optimistron'; -selectFailedTransitions(state.todos) // all failed in one slice -selectAllFailedTransitions(state.todos, state.projects, state.activity) // all failed across slices +selectIsOptimistic(id)(state.todos) // pending? +selectIsFailed(id)(state.todos) // failed? +selectIsConflicting(id)(state.todos) // stale conflict? ``` ---- - -## Transition Modes - -`TransitionMode` controls re-staging and failure behavior per action type — declared at the `createTransitions` site: - -```typescript -import { createTransitions, TransitionMode } from '@lostsolution/optimistron'; - -const createTodo = createTransitions('todos::add', TransitionMode.DISPOSABLE)(crud.create); -const editTodo = createTransitions('todos::edit')(crud.update); // DEFAULT -const deleteTodo = createTransitions('todos::delete', TransitionMode.REVERTIBLE)(crud.remove); -``` - -| Mode | On re-stage | On fail | Use case | -|------|-------------|---------|----------| -| **`DEFAULT`** | Overwrite | Flag as failed | Edits — consumer retries manually | -| **`DISPOSABLE`** | Overwrite | Drop transition | Creates — entity never existed server-side | -| **`REVERTIBLE`** | Store trailing | Stash (revert to trailing) | Deletes — undo on failure | - ### Retry -Retry is a consumer concern — timing, backoff, and reconnect logic belong in your app layer. The library provides the building blocks: - ```typescript import { retryTransition, selectFailedTransition, selectRetryCount } from '@lostsolution/optimistron'; const failed = selectFailedTransition(id)(state.todos); -if (failed) { - const retries = selectRetryCount(id)(state.todos); - if (retries < 3) dispatch(retryTransition(failed)); // strips failure flags, re-stages +if (failed && selectRetryCount(id)(state.todos) < 3) { + dispatch(retryTransition(failed)); } ``` -### Multi-slice failure aggregation +### Aggregate failures ```typescript import { selectAllFailedTransitions } from '@lostsolution/optimistron'; - -const allFailed = selectAllFailedTransitions(state.todos, state.projects, state.activity); +selectAllFailedTransitions(state.todos, state.projects, state.activity); ``` --- -## Roadmap - -- **Batch transitions** — stage multiple entities under a single correlation ID. Commit/fail/stash the batch atomically. -- **Devtools integration** — Redux DevTools timeline visualization for transitions, sanitization events, and conflict detection. -- **Persistence adapters** — serialize/rehydrate pending transitions across page reloads (localStorage, IndexedDB). -- **Transition expiry / TTL** — automatic cleanup of stale failed transitions after a configurable time window. - ---- - ## Development ```bash -bun test # run tests (coverage threshold 90%) -bun run build:esm # build to lib/ +bun test # tests with coverage (threshold 90%) +bun run build:esm # build to lib/ ``` -See `usecases/` for working examples demonstrating state handlers (`recordState`, `singularState`, `nestedRecordState`, `listState`) with basic async, thunks, and sagas. +See `usecases/` for working examples with basic async, thunks, and sagas. + +--- + +## Deep Dive + +For internals, design decisions, and the full API reference, see [ARCHITECTURE.md](./ARCHITECTURE.md).