diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fc143aa..0d101a2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,19 +1,6 @@ # Architecture -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 & Conflict Detection](#versioning--conflict-detection) -- [Sanitization](#sanitization) -- [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) +Internals and design decisions. For usage, see [README.md](./README.md). --- @@ -23,62 +10,23 @@ Internals, design decisions, and the full API reference. For getting started, se 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 +1. `stage`/`amend` — only the transitions list is updated, committed state is untouched +2. `commit` — committed state is updated via the bound reducer, transition is removed +3. After every mutation (gated by `===`), `sanitizeTransitions` replays 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 +2. Returns the derived view — never stored, always computed 3. Memoization is the consumer's responsibility via `createSelector` --- ## Entity Identity -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. - -**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 existing transition -dispatch(createTodo.commit(tid)); // explicit -``` - -**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, temp-to-server ID mapping), write custom prepare functions. - ---- - -## Versioning & Conflict Detection +Every transition carries a string ID — the stable link between a transition and its entity. -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 -eq: (a: T) => (b: T) => boolean // content equality at same version -``` - -During sanitization, `merge` calls `compare` on each entity: - -| `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 | - -These are thrown as `OptimisticMergeResult.SKIP` / `.CONFLICT` and caught by `sanitizeTransitions`. - -Without versioning, conflict detection degrades to content equality — it can't distinguish concurrent mutations from different clients. +**Default: `transitionId === entityId`.** `crudPrepare` couples them automatically. Only `stage` auto-detects — `amend`/`commit`/`fail`/`stash` require explicit `transitionId` because the entity may carry a server-assigned ID that differs from the transition's. --- @@ -90,18 +38,27 @@ Without versioning, conflict detection degrades to content equality — it can't After every state mutation, `sanitizeTransitions` replays all pending transitions against committed state: -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) +1. For each transition: apply as-if-committed, check if state reference changed (`!==`), then `merge` to validate +2. Result per transition: **keep** (valid), **discard** (no-op/skip), or **flag** (conflict) -Sanitization only runs when state actually changes — gated by referential equality (`===`). +Only runs when state actually changes — gated by referential equality. + +--- + +## Transition Modes + +`TransitionMode` controls both re-staging and failure behavior. Declared per action type at the `createTransitions` site — making invalid state combinations unrepresentable. + +- **`DEFAULT`** — overwrites on re-stage, flags on fail. Edits. +- **`DISPOSABLE`** — overwrites on re-stage, drops on fail. Creates (entity never existed server-side). +- **`REVERTIBLE`** — stores trailing fallback on re-stage, reverts on fail. Deletes (undo deletion). --- ## StateHandler Interface ```typescript -interface StateHandler { +interface StateHandler { create: (state: State, dto: C) => State; update: (state: State, dto: U) => State; remove: (state: State, dto: D) => State; @@ -109,126 +66,9 @@ interface StateHandler { } ``` -`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 handlers - -| 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 }` | - -### Auto-wired CRUD - -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 -// wire does the routing — zero boilerplate -optimistron('todos', initial, handler, { - create: createTodo, update: editTodo, remove: deleteTodo, -}); -``` - -`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**. - -### Custom handlers - -Implement `StateHandler` for any shape. The contract: - -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 - ---- - -## Transition Modes In Depth - -`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. - -### `DEFAULT` — edits - -- **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 - -### `DISPOSABLE` — creates - -- **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 - -- **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 - ---- - -## Async Patterns - -Optimistron is transport-agnostic. The pattern is always: stage, then resolve. - -
-Component-level async - -```typescript -const handleCreate = async (todo: Todo) => { - dispatch(createTodo.stage(todo)); - try { - const saved = await api.create(todo); - dispatch(createTodo.amend(todo.id, saved)); - dispatch(createTodo.commit(todo.id)); - } catch (e) { - dispatch(createTodo.fail(todo.id, e)); - } -}; -``` - -
- -
-Thunks - -```typescript -const createTodoThunk = - (todo: Todo): ThunkAction => - async (dispatch) => { - dispatch(createTodo.stage(todo)); - try { - const saved = await api.create(todo); - dispatch(createTodo.amend(todo.id, saved)); - dispatch(createTodo.commit(todo.id)); - } catch (e) { - dispatch(createTodo.fail(todo.id, e)); - } - }; -``` - -
+**Critical:** `update` and `remove` must return the **same reference** when nothing changed — sanitization uses `===` to detect no-ops. -
-Sagas - -```typescript -function* createTodoSaga(action: ReturnType) { - const transitionId = getTransitionMeta(action).id; - try { - 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)); - } -} -``` - -
+Built-in handlers (`recordState`, `nestedRecordState`, `singularState`, `listState`) implement this. Custom handlers must follow the same contract: return same reference on no-op, throw `OptimisticMergeResult.SKIP`/`.CONFLICT` from `merge`. --- @@ -236,149 +76,41 @@ function* createTodoSaga(action: ReturnType) { ``` src/ -├── index.ts # Public API surface (barrel export) -├── optimistron.ts # Factory: wraps reducers, returns { reducer, selectors } -├── 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 # All selectors (returned from optimistron via selectors object, not exported) -│ -├── 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 +├── core/ # Pure Redux — no saga dependency +│ ├── index.ts # Core barrel +│ ├── optimistron.ts # Factory: wraps reducer, returns { reducer, selectors } +│ ├── transitions.ts # Transition operations, processTransition, sanitizeTransitions +│ ├── reducer.ts # resolveReducer, bindReducer +│ ├── constants.ts # META_KEY +│ ├── actions/ +│ │ ├── index.ts # Barrel +│ │ ├── transitions.ts # createTransition, createTransitions, resolveTransition +│ │ ├── crud.ts # crudPrepare +│ │ ├── crud-transitions.ts # createCrudTransitions +│ │ └── types.ts # TransitionActions, InferPayload, DTOs +│ ├── selectors/ +│ │ └── internal.ts # All selectors (exposed via optimistron().selectors) +│ ├── state/ +│ │ ├── types.ts # TransitionState, StateHandler, WiredStateHandler +│ │ ├── factory.ts # bindStateFactory, buildTransitionState +│ │ ├── record.ts # recordState, nestedRecordState +│ │ ├── singular.ts # singularState +│ │ └── list.ts # listState +│ └── utils/ +│ ├── path.ts # getAt, setAt, removeAt +│ ├── types.ts # StringKeys, PathMap, Maybe, MaybeNull +│ └── logger.ts # warn +├── saga/ # Saga orchestration effects +│ ├── index.ts # Saga barrel +│ └── effects.ts # watchTransition, handleTransition, retryFailed +└── index.ts # Root barrel (core + saga) ``` -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 `committed` and `transitions` are referentially equal (preserves memoization) -- **`selectors`** are returned as a grouped object from `optimistron()` — no standalone exports, each slice is self-contained -- **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?)` - -Creates an optimistic reducer wrapper. Returns `{ reducer, selectors }`. - -| Param | Type | Description | -|-------|------|-------------| -| `namespace` | `string` | Action type prefix (`"namespace::operation"`) | -| `initialState` | `S` | Initial state value | -| `handler` | `StateHandler` | State handler implementation | -| `config` | `ReducerConfig` | CRUD action map or reducer function | -| `options.sanitizeAction` | `(action) => action` | Optional action transform before sanitization | - -### `selectors` - -Returned from `optimistron()` as a grouped object. No selectors are exported from the library — they are all bound to the slice's `TransitionState`. - -#### `selectors.selectOptimistic(selector)` - -Replays pending transitions before applying the selector. Always wrap with `createSelector`: - -```typescript -const { selectors } = optimistron('todos', initial, handler, config); - -const selectTodos = createSelector( - (state: RootState) => state.todos, - selectors.selectOptimistic((todos) => Object.values(todos.committed)), -); -``` - -### `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: {} }), -}); -``` - -### `createCrudTransitions(namespace, key)` / `createCrudTransitions()(namespace, keys)` - -High-level helper that composes `crudPrepare` + `createTransitions` with golden-path modes: - -```typescript -// Single-key -const todo = createCrudTransitions('todos', 'id'); -// todo.create → DISPOSABLE (drop on fail) -// todo.update → DEFAULT (flag on fail) -// todo.remove → REVERTIBLE (stash on fail) - -// Multi-key — curried for key inference -const item = createCrudTransitions()('projects', ['projectId', 'id']); -``` - -Each operation returns a full transition set (`.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`). - -### `crudPrepare(key)` / `crudPrepare()(keys)` - -Factory for CRUD prepare functions that couple `transitionId === entityId`. Use when you need custom modes or per-operation preparators: - -```typescript -// Single-key (recordState, listState) -const crud = crudPrepare('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 -const crud = crudPrepare()(['projectId', 'id']); -// transitionId: "projectId-value/id-value" -``` - -#### Per-entity selectors - -All returned on `selectors`, curried: `selectors.selector(id)(transitionState)`. - -| Selector | Returns | -|----------|---------| -| `selectIsOptimistic(id)` | `boolean` — transition is pending | -| `selectIsFailed(id)` | `boolean` — transition has failed | -| `selectIsConflicting(id)` | `boolean` — transition conflicts with committed state | -| `selectFailure(id)` | `StagedAction \| undefined` — failed transition for entity | -| `selectConflict(id)` | `StagedAction \| undefined` — conflicting transition for entity | -| `selectFailures` | `(state) => StagedAction[]` — all failed transitions in this slice | - -### Enums - -```typescript -Operation.STAGE | .AMEND | .COMMIT | .STASH | .FAIL -TransitionMode.DEFAULT | .DISPOSABLE | .REVERTIBLE -OptimisticMergeResult.SKIP | .CONFLICT -``` +1. **No full state copies.** Only shallow copy is in `sanitizeTransitions` — a mutable working copy, not a checkpoint. +2. **Referential equality (`===`) gates sanitization.** `transitionStateFactory` returns the previous state object when nothing changed. +3. **`selectOptimistic` replays all transitions on every call.** Fast-path returns early when `transitions.length === 0`. Memoize with `createSelector`. +4. **Handler operations return the same reference on no-op.** This is how sanitization detects no-ops. diff --git a/README.md b/README.md index f0083c0..e56c54b 100644 --- a/README.md +++ b/README.md @@ -5,47 +5,10 @@

Redux ^5.0.1 Redux Toolkit ^2.11.2 + Redux Saga ^1.4.2

-> Optimistic state management for Redux. Optimistic state is derived at the selector level by replaying transitions on top of committed state, similar to `git rebase`. - ---- - -## Why Optimistron? - -Optimistron tracks lightweight **transitions** (stage, amend, commit, fail) alongside your reducer state and replays them at read-time through `selectOptimistic`. No state snapshots or checkpoints are stored per operation. - -Optimistron was designed around an **event-driven saga architecture** — components dispatch intent via `stage`, and sagas (or listener middleware) orchestrate the transition lifecycle. It can be used with thunks or direct component dispatches, but the separation between intent and orchestration is where it fits most naturally. - -Designed for: - -- **Offline-first** — transitions queue up while disconnected, conflicts resolve on reconnect -- **Large/normalized state** — state is derived, not copied - ---- - -## The Mental Model - -Think of each `optimistron()` reducer as a **git branch**: - -| 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 on the next read. - -There are no separate `isLoading` / `error` / `isOptimistic` flags — a pending transition represents the loading state, and a failed transition carries the error. - ---- - -## Transition Lifecycle - -

- Transition Lifecycle -

+> Saga-first optimistic state management for Redux. Components dispatch intent, sagas orchestrate the lifecycle, optimistic state is derived at read-time by replaying transitions — like `git rebase`. --- @@ -53,16 +16,30 @@ There are no separate `isLoading` / `error` / `isOptimistic` flags — a pending ```bash npm install @lostsolution/optimistron -# ⚠️ not published yet +# requires: redux ^5, @reduxjs/toolkit ^2, redux-saga ^1 ``` +### Subpath exports + +| Import | Includes | Requires saga? | +|--------|----------|----------------| +| `@lostsolution/optimistron` | Core + saga effects | Yes | +| `@lostsolution/optimistron/core` | Core only (reducers, actions, selectors) | No | +| `@lostsolution/optimistron/saga` | Saga effects only | Yes | + +Use `/core` for thunks, listener middleware, or manual orchestration — no saga dependency. + --- ## Quick Start ```typescript -import { configureStore, createSelector } from '@reduxjs/toolkit'; -import { optimistron, createCrudTransitions, recordState } from '@lostsolution/optimistron'; +import { configureStore, createAction, createSelector } from '@reduxjs/toolkit'; +import createSagaMiddleware from 'redux-saga'; +import { + optimistron, createCrudTransitions, recordState, + watchTransition, retryFailed, +} from '@lostsolution/optimistron'; // 1. Define your entity type Todo = { id: string; value: string; done: boolean; revision: number }; @@ -83,130 +60,108 @@ const { reducer: todos, selectors } = optimistron( ); // 4. Wire up the store -const store = configureStore({ reducer: { todos } }); +const sagaMiddleware = createSagaMiddleware(); +const store = configureStore({ + reducer: { todos }, + middleware: (getDefault) => getDefault().concat(sagaMiddleware), +}); -// 5. Select optimistic state (memoize with createSelector) +// 5. Sagas — one line per transition +function* rootSaga() { + yield watchTransition(todo.create, api.createTodo, { + amend: (payload, saved) => ({ ...payload, id: saved.id }), + }); + yield watchTransition(todo.update, api.updateTodo); + yield watchTransition(todo.remove, api.deleteTodo); + // stash-on-fail is automatic for REVERTIBLE transitions (removes) + + const retryAll = createAction('todos::retryAll'); + yield retryFailed(retryAll, (state) => selectors.selectFailures(state.todos)); +} + +sagaMiddleware.run(rootSaga); + +// 6. Select optimistic state const selectTodos = createSelector( (state: RootState) => state.todos, selectors.selectOptimistic((todos) => Object.values(todos.committed)), ); -// 6. Dispatch transitions -dispatch(todo.create.stage(item)); // optimistic — shows immediately -dispatch(todo.create.commit(item.id)); // server confirmed — becomes committed state -dispatch(todo.create.fail(item.id, error)); // server rejected — dropped (DISPOSABLE) +// 7. Components only dispatch intent +dispatch(todo.create.stage(item)); // shows immediately +dispatch(todo.update.stage(edited)); // saga handles commit/fail ``` -> `createCrudTransitions` composes `crudPrepare` + `createTransitions` with golden-path modes: **DISPOSABLE** create, **DEFAULT** update, **REVERTIBLE** remove. For custom modes or per-operation preparators, use `crudPrepare` + `createTransitions` directly. - --- -## Three Rules - -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 Mental Model ---- +Think of each `optimistron()` reducer as a **git branch**: -## Versioning +| 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 | -Entities need a **monotonically increasing version** — `revision`, `updatedAt`, a sequence number. This is how sanitization tells "newer" from "stale": +`STAGE`, `AMEND`, `FAIL`, `STASH` never touch reducer state — they only modify the transitions list. There are no separate `isLoading` / `error` / `isOptimistic` flags — a pending transition **is** the loading state, and a failed transition carries the error. -```typescript -// Shorthand — extracts version, compare is generated automatically -version: (item) => item.revision, -eq: (a, b) => boolean, +--- -// Full control — provide your own compare -compare: (a, b) => 0 | 1 | -1, -eq: (a, b) => boolean, -``` +## Transition Lifecycle -Without versioning, conflict detection degrades to content equality only. +

+ Transition Lifecycle +

--- -## State Handlers - -Four built-in handlers for common state shapes. Each defines `create`, `update`, `remove`, and `merge`. +## Saga Effects -### `recordState` — flat key-value map +### `watchTransition(actions, effect, options?)` -`Record` indexed by a single key. The most common shape. +Returns a `takeEvery` effect. Watches for `stage` actions, calls the effect, then commits or fails based on the `TransitionMode` on the transition meta. ```typescript -const handler = recordState({ key: 'id', compare, eq }); -const crud = crudPrepare('id'); +yield watchTransition(todo.create, api.createTodo, { + amend: (payload, result) => ({ ...payload, id: result.id }), +}); +yield watchTransition(todo.update, api.updateTodo); +yield watchTransition(todo.remove, api.deleteTodo); ``` -### `nestedRecordState` — nested records - -`Record>` for multi-level grouping. Curried to fix `T` and infer the keys tuple. `transitionId` joins path IDs with `/`. - -```typescript -const handler = nestedRecordState()({ keys: ['projectId', 'id'], compare, eq }); -const crud = crudPrepare()(['projectId', 'id']); -``` +- **`effect`** — `(payload, action) => Promise`. Wrapped in `call()` internally. +- **`options.amend`** — `(payload, result) => P`. Pure transform applied before commit. +- **fail vs stash** — auto-detected from `TransitionMode` on the transition meta. -### `singularState` — single object +### `handleTransition(actions, effect, options?)` -`T | null` for singletons (user profile, settings). +The inner worker generator, exposed for custom watcher patterns: ```typescript -const handler = singularState({ compare, eq }); +yield takeLatest(todo.update.stage.match, handleTransition(todo.update, api.updateTodo)); ``` -### `listState` — ordered list +### `retryFailed(trigger, selectFailed)` -`T[]` where insertion order matters. +Returns a `takeLeading` effect. Re-dispatches all failed transitions when the trigger fires. ```typescript -const handler = listState({ key: 'id', compare, eq }); -const crud = crudPrepare('id'); +const retryAll = createAction('retryAll'); +yield retryFailed(retryAll, (state: RootState) => [ + ...todosSelectors.selectFailures(state.todos), + ...projectsSelectors.selectFailures(state.projects), +]); ``` -Custom shapes can implement the `StateHandler` interface directly. - --- -## Reducer Configuration - -The 4th argument to `optimistron()` supports three modes: - -**Auto-wired** — handler routes payloads by CRUD type: - -```typescript -optimistron('todos', initial, handler, { - create: createTodo, - update: editTodo, - remove: deleteTodo, -}); -``` - -**Hybrid** — auto-wire + fallback for custom actions: - -```typescript -optimistron('todos', initial, handler, { - create: createTodo, - update: editTodo, - remove: deleteTodo, - reducer: ({ getState }, action) => { - /* custom logic */ - }, -}); -``` - -**Manual** — full control via `BoundStateHandler`: +## Three Rules -```typescript -optimistron('todos', initial, handler, ({ getState, create, update, remove }, action) => { - 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(); -}); -``` +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 --- @@ -222,46 +177,95 @@ Declared per action type — controls what happens on re-stage and failure: --- -## Selectors +## State Handlers + +Four built-in handlers for common state shapes: + +| Handler | State shape | Usage | +|---------|-------------|-------| +| `recordState` | `Record` | Flat key-value map | +| `nestedRecordState()` | `Record>` | Multi-level grouping | +| `singularState` | `T \| null` | Singletons (profile, settings) | +| `listState` | `T[]` | Ordered list | + +Custom shapes can implement the `StateHandler` interface directly. + +--- -All selectors are returned from `optimistron()` on the `selectors` object — there are no standalone selector exports. +## Selectors -### Optimistic state +All selectors are returned from `optimistron()` on the `selectors` object. ```typescript -const selectTodos = createSelector( - (state: RootState) => state.todos, - selectors.selectOptimistic((todos) => Object.values(todos.committed)), -); +selectors.selectOptimistic(selector)(state.todos); // derived optimistic view +selectors.selectIsOptimistic(id)(state.todos); // pending? +selectors.selectIsFailed(id)(state.todos); // failed? +selectors.selectIsConflicting(id)(state.todos); // stale conflict? +selectors.selectFailures(state.todos); // all failed transitions +selectors.selectFailure(id)(state.todos); // specific failed transition +selectors.selectConflict(id)(state.todos); // specific conflict ``` -### Per-entity status +--- + +## Versioning + +Entities need a **monotonically increasing version** — `revision`, `updatedAt`, a sequence number: ```typescript +recordState({ + key: 'id', + version: (item) => item.revision, + eq: (a, b) => a.done === b.done && a.value === b.value, +}); +``` + +Without versioning, conflict detection degrades to content equality only. + +--- -selectors.selectIsOptimistic(id)(state.todos); // pending? -selectors.selectIsFailed(id)(state.todos); // failed? -selectors.selectIsConflicting(id)(state.todos); // stale conflict? -selectors.selectFailures(state.todos); // all failed transitions in this slice -selectors.selectFailure(id)(state.todos); // failed transition for a specific entity -selectors.selectConflict(id)(state.todos); // conflicting transition for a specific entity +## Without Sagas + +
+Thunks + +```typescript +import { createCrudTransitions, recordState } from '@lostsolution/optimistron/core'; + +const createTodoThunk = (todo: Todo): Thunk => async (dispatch) => { + dispatch(todoActions.create.stage(todo)); + try { + const saved = await api.create(todo); + dispatch(todoActions.create.amend(todo.id, saved)); + dispatch(todoActions.create.commit(todo.id)); + } catch (e) { + dispatch(todoActions.create.fail(todo.id, e)); + } +}; ``` -### Aggregate failures across slices +
-Cross-slice aggregation is a consumer concern. Compose per-slice selectors: +
+Component-level async ```typescript -const selectAllFailed = createSelector( - (state: RootState) => state.todos, - (state: RootState) => state.projects, - (todos, projects) => [ - ...todosSelectors.selectFailures(todos), - ...projectsSelectors.selectFailures(projects), - ], -); +import { createCrudTransitions, recordState } from '@lostsolution/optimistron/core'; + +const handleCreate = async (todo: Todo) => { + dispatch(todoActions.create.stage(todo)); + try { + const saved = await api.create(todo); + dispatch(todoActions.create.amend(todo.id, saved)); + dispatch(todoActions.create.commit(todo.id)); + } catch (e) { + dispatch(todoActions.create.fail(todo.id, e)); + } +}; ``` +
+ --- ## Development @@ -271,10 +275,8 @@ bun test # tests with coverage (threshold 90%) bun run build:esm # build to lib/ ``` -See `usecases/` for working examples with basic async, thunks, and sagas. +See `usecases/` for working examples with sagas, thunks, and basic async. --- -## Deep Dive - -For internals, design decisions, and the full API reference, see [ARCHITECTURE.md](./ARCHITECTURE.md). +For internals and design decisions, see [ARCHITECTURE.md](./ARCHITECTURE.md). diff --git a/package.json b/package.json index 89e8b7d..8b41e13 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,31 @@ ".": { "types": "./lib/index.d.ts", "import": "./lib/index.js" + }, + "./core": { + "types": "./lib/core/index.d.ts", + "import": "./lib/core/index.js" + }, + "./saga": { + "types": "./lib/saga/index.d.ts", + "import": "./lib/saga/index.js" + } + }, + "typesVersions": { + "*": { + "core": ["lib/core/index.d.ts"], + "saga": ["lib/saga/index.d.ts"] } }, "peerDependencies": { "@reduxjs/toolkit": "^2.11.2", - "redux": "^5.0.1" + "redux": "^5.0.1", + "redux-saga": "^1.4.2" + }, + "peerDependenciesMeta": { + "redux-saga": { + "optional": true + } }, "devDependencies": { "@types/lodash": "^4.17.23", @@ -40,7 +60,6 @@ "react-redux": "^9.2.0", "react-router-dom": "^6.30.3", "redux-saga": "^1.4.2", - "redux-thunk": "^3.1.0", "serve": "^14.2.5", "typescript": "^5.9.3" }, diff --git a/src/actions/crud-transitions.ts b/src/core/actions/crud-transitions.ts similarity index 100% rename from src/actions/crud-transitions.ts rename to src/core/actions/crud-transitions.ts diff --git a/src/actions/crud.ts b/src/core/actions/crud.ts similarity index 100% rename from src/actions/crud.ts rename to src/core/actions/crud.ts diff --git a/src/actions/index.ts b/src/core/actions/index.ts similarity index 100% rename from src/actions/index.ts rename to src/core/actions/index.ts diff --git a/src/actions/transitions.ts b/src/core/actions/transitions.ts similarity index 100% rename from src/actions/transitions.ts rename to src/core/actions/transitions.ts diff --git a/src/actions/types.ts b/src/core/actions/types.ts similarity index 66% rename from src/actions/types.ts rename to src/core/actions/types.ts index 2618076..fadca11 100644 --- a/src/actions/types.ts +++ b/src/core/actions/types.ts @@ -1,4 +1,4 @@ -import type { ActionCreatorWithPreparedPayload, PayloadAction, PrepareAction } from '@reduxjs/toolkit'; +import type { Action, ActionCreatorWithPreparedPayload, PayloadAction, PrepareAction } from '@reduxjs/toolkit'; import type { TransitionMeta, TransitionNamespace } from '~/transitions'; import { type Operation } from '~/transitions'; @@ -38,6 +38,27 @@ export type TransitionPayloadAction >; +/** Structural constraint for the action map returned by `createTransitions`. + * Only constrains what the saga effects actually consume: `stage.match` for + * the watcher, lifecycle methods for dispatch. Rest params use `any` — + * required for contravariant compatibility with RTK's prepared action creators + * (a fn accepting `Item` is not assignable to one accepting `unknown`). + * Payload type safety flows through `InferPayload`, not here. */ +export type TransitionActions = { + stage: { match(action: Action): boolean }; + amend: (...args: any[]) => Action; + commit: (...args: any[]) => Action; + fail: (...args: any[]) => Action; + stash: (...args: any[]) => Action; +}; + +/** Extracts the stage payload type from a `createTransitions` result. + * Works via the callable signature on `stage` (which exists on the actual + * `ActionCreatorWithPreparedPayload` even though the constraint only + * requires `.match`). */ +export type InferPayload = + A extends { stage: (...args: any[]) => { payload: infer P } } ? P : unknown; + export type { PathMap as PathIds } from '~/utils/types'; /** Picks the identity keys from T — the "address" of an entity */ diff --git a/src/constants.ts b/src/core/constants.ts similarity index 100% rename from src/constants.ts rename to src/core/constants.ts diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..21e930f --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,18 @@ +export { createCrudTransitions, createTransition, createTransitions, crudPrepare } from './actions'; +export { optimistron } from './optimistron'; +export { listState } from './state/list'; +export { nestedRecordState, recordState } from './state/record'; +export { singularState } from './state/singular'; +export { getTransitionID, getTransitionMeta, isTransition, Operation, OptimisticMergeResult, TransitionMode } from './transitions'; + +export type { HandlerReducer, ReducerConfig } from './reducer'; +export type { ActionMatcher, BoundStateHandler, CrudActionMap, StateHandler, TransitionState, VersioningOptions, WiredStateHandler } from './state/types'; + +export type { DeleteDTO, InferPayload, ItemPath, TransitionActions, UpdateDTO } from './actions/types'; +export type { ListStateOptions } from './state/list'; +export type { NestedRecordStateOptions, RecordStateOptions } from './state/record'; +export type { SingularStateOptions } from './state/singular'; +export type { MaybeNull, PathMap as PathIds, StringKeys } from './utils/types'; + +export type { RecordState as IndexedState, RecursiveRecordState as NestedRecord, PathOf } from './state/record'; +export type { CommittedAction, StagedAction, Transition, TransitionAction, TransitionMeta, TransitionNamespace } from './transitions'; diff --git a/src/optimistron.ts b/src/core/optimistron.ts similarity index 100% rename from src/optimistron.ts rename to src/core/optimistron.ts diff --git a/src/reducer.ts b/src/core/reducer.ts similarity index 100% rename from src/reducer.ts rename to src/core/reducer.ts diff --git a/src/selectors/internal.ts b/src/core/selectors/internal.ts similarity index 100% rename from src/selectors/internal.ts rename to src/core/selectors/internal.ts diff --git a/src/state/factory.ts b/src/core/state/factory.ts similarity index 100% rename from src/state/factory.ts rename to src/core/state/factory.ts diff --git a/src/state/list.ts b/src/core/state/list.ts similarity index 100% rename from src/state/list.ts rename to src/core/state/list.ts diff --git a/src/state/record.ts b/src/core/state/record.ts similarity index 99% rename from src/state/record.ts rename to src/core/state/record.ts index c2febb9..f8b83c8 100644 --- a/src/state/record.ts +++ b/src/core/state/record.ts @@ -7,7 +7,6 @@ import { resolveCompare, type CrudActionMap, type VersioningOptions, type WiredS export type RecordStateOptions = VersioningOptions & { key: StringKeys }; export type NestedRecordStateOptions[]> = VersioningOptions & { keys: Keys }; - export type RecordState = Record; /** Recursively builds a nested Record type from a keys tuple. diff --git a/src/state/singular.ts b/src/core/state/singular.ts similarity index 100% rename from src/state/singular.ts rename to src/core/state/singular.ts diff --git a/src/state/types.ts b/src/core/state/types.ts similarity index 100% rename from src/state/types.ts rename to src/core/state/types.ts diff --git a/src/transitions.ts b/src/core/transitions.ts similarity index 100% rename from src/transitions.ts rename to src/core/transitions.ts diff --git a/src/utils/logger.ts b/src/core/utils/logger.ts similarity index 100% rename from src/utils/logger.ts rename to src/core/utils/logger.ts diff --git a/src/utils/path.ts b/src/core/utils/path.ts similarity index 100% rename from src/utils/path.ts rename to src/core/utils/path.ts diff --git a/src/utils/types.ts b/src/core/utils/types.ts similarity index 100% rename from src/utils/types.ts rename to src/core/utils/types.ts diff --git a/src/index.ts b/src/index.ts index 8528906..21a4ba3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,3 @@ -export { createCrudTransitions, createTransition, createTransitions, crudPrepare } from './actions'; -export { optimistron } from './optimistron'; -export { listState } from './state/list'; -export { nestedRecordState, recordState } from './state/record'; -export { singularState } from './state/singular'; -export { getTransitionID, getTransitionMeta, isTransition, Operation, OptimisticMergeResult, TransitionMode } from './transitions'; - -export type { HandlerReducer, ReducerConfig } from './reducer'; -export type { ActionMatcher, BoundStateHandler, CrudActionMap, StateHandler, TransitionState, VersioningOptions, WiredStateHandler } from './state/types'; - -export type { DeleteDTO, ItemPath, UpdateDTO } from './actions/types'; -export type { ListStateOptions } from './state/list'; -export type { NestedRecordStateOptions, RecordStateOptions } from './state/record'; -export type { SingularStateOptions } from './state/singular'; -export type { MaybeNull, PathMap as PathIds, StringKeys } from './utils/types'; - -export type { RecordState as IndexedState, RecursiveRecordState as NestedRecord, PathOf } from './state/record'; -export type { CommittedAction, StagedAction, Transition, TransitionAction, TransitionMeta, TransitionNamespace } from './transitions'; +export * from './core'; +export { handleTransition, retryFailed, watchTransition } from './saga'; +export type { TransitionSagaOptions } from './saga'; diff --git a/src/saga/effects.ts b/src/saga/effects.ts new file mode 100644 index 0000000..e254a79 --- /dev/null +++ b/src/saga/effects.ts @@ -0,0 +1,69 @@ +import { call, put, select, takeEvery, takeLeading } from 'redux-saga/effects'; +import type { Action } from '@reduxjs/toolkit'; + +import { getTransitionMeta, TransitionMode } from '../core/transitions'; +import type { StagedAction } from '../core/transitions'; +import type { InferPayload, TransitionActions } from '../core/actions/types'; + +export type TransitionSagaOptions = { + /** Transform before commit. Return value is passed to `amend()`. + * If omitted, commits directly without amending. */ + amend?: (payload: P, result: Awaited) => P; +}; + +/** Inner generator that orchestrates a single transition lifecycle. + * Calls the effect, optionally amends, then commits or fails/stashes + * based on the `TransitionMode` already declared on the transition meta. */ +export const handleTransition = ( + actions: A, + effect: (payload: InferPayload, action: StagedAction>) => R, + options?: TransitionSagaOptions, R>, +) => + function* (action: StagedAction>) { + const { id, mode } = getTransitionMeta(action); + try { + const result: Awaited = yield call(effect, action.payload, action); + if (options?.amend) yield put(actions.amend(id, options.amend(action.payload, result))); + yield put(actions.commit(id)); + } catch (error) { + if (mode === TransitionMode.REVERTIBLE) yield put(actions.stash(id)); + else yield put(actions.fail(id, error)); + } + }; + +/** Returns a `takeEvery` effect that watches for staged transition + * actions and orchestrates the API call → commit/fail lifecycle. + * + * ```ts + * yield watchTransition(createEpic, api.create, { + * amend: (payload, result) => ({ ...payload, id: result.id }), + * }); + * yield watchTransition(editEpic, api.update); + * yield watchTransition(deleteEpic, api.delete); + * // stash-on-fail is automatic for REVERTIBLE transitions + * ``` */ +export const watchTransition = ( + actions: A, + effect: (payload: InferPayload, action: StagedAction>) => R, + options?: TransitionSagaOptions, R>, +) => takeEvery(actions.stage.match, handleTransition(actions, effect, options)); + +/** Returns a `takeLeading` effect that retries all failed transitions + * when the trigger action is dispatched. + * + * ```ts + * const retryAll = createAction('optimistron::retryAll'); + * + * yield retryFailed(retryAll, (state: RootState) => [ + * ...epicsSelectors.selectFailures(state.epics), + * ...profileSelectors.selectFailures(state.profile), + * ]); + * ``` */ +export const retryFailed = ( + trigger: { match(action: Action): boolean }, + selectFailed: (state: RootState) => StagedAction[], +) => + takeLeading(trigger.match, function* () { + const state: RootState = yield select(); + for (const action of selectFailed(state)) yield put(action); + }); diff --git a/src/saga/index.ts b/src/saga/index.ts new file mode 100644 index 0000000..011648d --- /dev/null +++ b/src/saga/index.ts @@ -0,0 +1,2 @@ +export { handleTransition, retryFailed, watchTransition } from './effects'; +export type { TransitionSagaOptions } from './effects'; diff --git a/test/unit/saga/effects.spec.ts b/test/unit/saga/effects.spec.ts new file mode 100644 index 0000000..d987783 --- /dev/null +++ b/test/unit/saga/effects.spec.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from 'bun:test'; +import { configureStore, createAction } from '@reduxjs/toolkit'; +import createSagaMiddleware from 'redux-saga'; +import { takeEvery } from 'redux-saga/effects'; + +import { createTransitions, optimistron, TransitionMode, getTransitionMeta } from '~index'; +import { handleTransition, retryFailed, watchTransition } from '~saga/index'; +import type { TransitionState } from '~state/types'; +import { createItem, indexedState, selectState } from '~test/utils'; +import type { TestItem, TestIndexedState } from '~test/utils'; + +const create = createTransitions('sagaFx::add', TransitionMode.DISPOSABLE)((item: TestItem) => ({ payload: item, transitionId: item.id })); +const edit = createTransitions('sagaFx::edit')((item: TestItem) => ({ payload: item, transitionId: item.id })); +const remove = createTransitions('sagaFx::remove', TransitionMode.REVERTIBLE)((dto: Pick) => ({ payload: dto, transitionId: dto.id })); + +const manualReducer = (handler: any, action: any): TestIndexedState => { + if (create.match(action)) return handler.create(action.payload); + if (edit.match(action)) return handler.update(action.payload); + if (remove.match(action)) return handler.remove(action.payload); + return handler.getState(); +}; + +const { reducer, selectors } = optimistron('sagaFx', {} as TestIndexedState, indexedState, manualReducer); +const { selectOptimistic, selectFailures } = selectors; + +type RootState = { sagaFx: TransitionState }; + +const setupStore = (saga: () => Generator) => { + const sagaMiddleware = createSagaMiddleware(); + const store = configureStore({ reducer: { sagaFx: reducer }, middleware: (getDefault) => getDefault().concat(sagaMiddleware) }); + sagaMiddleware.run(saga); + return store; +}; + +const flush = () => new Promise((r) => setTimeout(r, 50)); + +describe('watchTransition', () => { + test('simple commit — effect resolves, dispatches commit', async () => { + const effect = async () => {}; + const store = setupStore(function* () { + yield watchTransition(create, effect); + }); + + const item = createItem({ id: 'a', value: 'hello' }); + store.dispatch(create.stage(item)); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + expect(selectOptimistic(selectState)(state)).toEqual({ a: item }); + }); + + test('amend before commit — transforms payload via amend option', async () => { + const effect = async () => ({ serverId: 'server-123' }); + const store = setupStore(function* () { + yield watchTransition(create, effect, { + amend: (payload, result) => ({ ...payload, id: result.serverId }), + }); + }); + + const item = createItem({ id: 'temp', value: 'hello' }); + store.dispatch(create.stage(item)); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + expect(selectOptimistic(selectState)(state)).toEqual({ + 'server-123': { ...item, id: 'server-123' }, + }); + }); + + test('fail on error — DEFAULT mode dispatches fail', async () => { + const effect = async () => { + throw new Error('network error'); + }; + const store = setupStore(function* () { + yield watchTransition(edit, effect); + }); + + const seed = createItem({ id: 'a', value: 'original' }); + store.dispatch(create.stage(seed)); + store.dispatch(create.commit('a')); + + const item = createItem({ id: 'a', value: 'updated' }); + store.dispatch(edit.stage(item)); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(1); + expect(getTransitionMeta(state.transitions[0]).failed).toBe(true); + }); + + test('stash on error — REVERTIBLE mode dispatches stash', async () => { + const item = createItem({ id: 'a', value: 'existing' }); + const effect = async () => { + throw new Error('network error'); + }; + const store = setupStore(function* () { + yield watchTransition(remove, effect); + }); + + store.dispatch(create.stage(item)); + store.dispatch(create.commit('a')); + + store.dispatch(remove.stage({ id: 'a' })); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + expect(selectOptimistic(selectState)(state)).toEqual({ a: item }); + }); + + test('DISPOSABLE mode — fail drops the transition', async () => { + const effect = async () => { + throw new Error('network error'); + }; + const store = setupStore(function* () { + yield watchTransition(create, effect); + }); + + const item = createItem({ id: 'a', value: 'hello' }); + store.dispatch(create.stage(item)); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + expect(selectOptimistic(selectState)(state)).toEqual({}); + }); +}); + +describe('handleTransition', () => { + test('is independently callable as an inner generator', async () => { + const effect = async () => {}; + const store = setupStore(function* () { + yield takeEvery(edit.stage.match, handleTransition(edit, effect)); + }); + + const seed = createItem({ id: 'b', value: 'original' }); + store.dispatch(create.stage(seed)); + store.dispatch(create.commit('b')); + + const item = createItem({ id: 'b', value: 'manual' }); + store.dispatch(edit.stage(item)); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + expect(selectOptimistic(selectState)(state)).toEqual({ b: item }); + }); +}); + +describe('retryFailed', () => { + const retryAll = createAction('sagaFx::retryAll'); + + test('collects failed transitions and re-dispatches them', async () => { + let callCount = 0; + const effect = async () => { + callCount++; + if (callCount <= 1) throw new Error('fail first time'); + }; + + const store = setupStore(function* () { + yield watchTransition(edit, effect); + yield retryFailed(retryAll, (state: RootState) => selectFailures(state.sagaFx)); + }); + + const seed = createItem({ id: 'x', value: 'original' }); + store.dispatch(create.stage(seed)); + store.dispatch(create.commit('x')); + + const item = createItem({ id: 'x', value: 'hello' }); + store.dispatch(edit.stage(item)); + await flush(); + + let state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(1); + expect(getTransitionMeta(state.transitions[0]).failed).toBe(true); + + store.dispatch(retryAll()); + await flush(); + + state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + expect(selectOptimistic(selectState)(state)).toEqual({ x: item }); + }); + + test('handles empty failure list gracefully', async () => { + const store = setupStore(function* () { + yield retryFailed(retryAll, (state: RootState) => selectFailures(state.sagaFx)); + }); + + store.dispatch(retryAll()); + await flush(); + + const state = store.getState().sagaFx; + expect(state.transitions).toHaveLength(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 26f9f8a..3337fc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "paths": { "~test/*": ["./test/*"], "~usecases/*": ["./usecases/*"], - "~*": ["./src/*"] + "~saga/*": ["./src/saga/*"], + "~*": ["./src/core/*"] }, "skipLibCheck": true, "strict": true, diff --git a/usecases/sagas/App.tsx b/usecases/App.tsx similarity index 98% rename from usecases/sagas/App.tsx rename to usecases/App.tsx index 5d78ced..ab2fb03 100644 --- a/usecases/sagas/App.tsx +++ b/usecases/App.tsx @@ -12,7 +12,7 @@ import { createEpic, deleteEpic, editEpic } from '~usecases/lib/store/epics/acti import { updateProfile } from '~usecases/lib/store/profile/actions'; import { createProjectTodo, deleteProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; import type { ActivityEntry, Epic, Profile, ProjectTodo } from '~usecases/lib/store/types'; -import { retryAll } from '~usecases/sagas/saga'; +import { retryAll } from '~usecases/saga'; import { C, F, O } from '~usecases/lib/components/todo/CodeTags'; diff --git a/usecases/basic/App.tsx b/usecases/basic/App.tsx deleted file mode 100644 index 6edf88e..0000000 --- a/usecases/basic/App.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { type FC } from 'react'; -import { useDispatch, useStore } from 'react-redux'; - -import type { StagedAction } from '~transitions'; - -import { ActivityFeed } from '~usecases/lib/components/activity/ActivityFeed'; -import { ProfileCard } from '~usecases/lib/components/profile/ProfileCard'; -import { ProjectBoard } from '~usecases/lib/components/projects/ProjectBoard'; -import { Layout, type UsecaseDescription } from '~usecases/lib/components/todo/Layout'; -import { TodoApp } from '~usecases/lib/components/todo/TodoApp'; -import { useAutoRetry } from '~usecases/lib/hooks/useAutoRetry'; -import { activitySelectors } from '~usecases/lib/store/activity/reducer'; -import { dismissActivity, editActivity, logActivity } from '~usecases/lib/store/activity/actions'; -import { createEpic, deleteEpic, editEpic } from '~usecases/lib/store/epics/actions'; -import { epicsSelectors } from '~usecases/lib/store/epics/reducer'; -import { profileSelectors } from '~usecases/lib/store/profile/reducer'; -import { updateProfile } from '~usecases/lib/store/profile/actions'; -import { projectsSelectors } from '~usecases/lib/store/projects/reducer'; -import { createProjectTodo, deleteProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; -import type { ActivityEntry, Epic, Profile, ProjectTodo } from '~usecases/lib/store/types'; -import type { State } from '~usecases/lib/store/store'; -import { generateId, simulateAPIRequest } from '~usecases/lib/utils/mock-api'; - -import { C, F, O } from '~usecases/lib/components/todo/CodeTags'; - -const description: UsecaseDescription = { - subtitle: 'Component-level async — the transition lifecycle is managed directly in the component.', - howItWorks: [ - <>Component dispatches stage, awaits the API, then amends / commits or fails directly., - <>The full lifecycle (stage → API → amendcommit / fail) lives in the handler function., - <>Optimistic state is computed at the selector level via selectOptimistic., - <>Failed transitions can be edited in-place — a new stage overwrites the failed one, restarting the lifecycle., - ], -}; - -export const App: FC = () => { - const dispatch = useDispatch(); - - const handleCreateEpic = async (epic: Epic) => { - const transitionId = epic.id; - try { - dispatch(createEpic.stage(epic)); - await simulateAPIRequest(); - dispatch(createEpic.amend(transitionId, { ...epic, id: generateId() })); - dispatch(createEpic.commit(transitionId)); - } catch (error) { - dispatch(createEpic.fail(transitionId, error)); - } - }; - - const handleEditEpic = async (epic: Epic) => { - const transitionId = epic.id; - try { - dispatch(editEpic.stage(epic)); - await simulateAPIRequest(); - dispatch(editEpic.commit(transitionId)); - } catch (error) { - dispatch(editEpic.fail(transitionId, error)); - } - }; - - const handleDeleteEpic = async (epic: Epic) => { - const transitionId = epic.id; - try { - dispatch(deleteEpic.stage({ id: epic.id })); - await simulateAPIRequest(); - dispatch(deleteEpic.commit(transitionId)); - } catch (error) { - dispatch(deleteEpic.stash(transitionId)); - } - }; - - const handleUpdateProfile = async (update: Partial) => { - try { - dispatch(updateProfile.stage(update)); - await simulateAPIRequest(); - dispatch(updateProfile.commit('profile')); - } catch (error) { - dispatch(updateProfile.fail('profile', error)); - } - }; - - const handleCreateProjectTodo = async (todo: ProjectTodo) => { - const transitionId = `${todo.projectId}/${todo.id}`; - try { - dispatch(createProjectTodo.stage(todo)); - await simulateAPIRequest(); - dispatch(createProjectTodo.amend(transitionId, { ...todo, id: generateId() })); - dispatch(createProjectTodo.commit(transitionId)); - } catch (error) { - dispatch(createProjectTodo.fail(transitionId, error)); - } - }; - - const handleEditProjectTodo = async (todo: ProjectTodo) => { - const transitionId = `${todo.projectId}/${todo.id}`; - try { - dispatch(editProjectTodo.stage(todo)); - await simulateAPIRequest(); - dispatch(editProjectTodo.commit(transitionId)); - } catch (error) { - dispatch(editProjectTodo.fail(transitionId, error)); - } - }; - - const handleDeleteProjectTodo = async (todo: ProjectTodo) => { - const transitionId = `${todo.projectId}/${todo.id}`; - try { - dispatch(deleteProjectTodo.stage({ projectId: todo.projectId, id: todo.id })); - await simulateAPIRequest(); - dispatch(deleteProjectTodo.commit(transitionId)); - } catch (error) { - dispatch(deleteProjectTodo.stash(transitionId)); - } - }; - - const handleLogActivity = async (entry: ActivityEntry) => { - const transitionId = entry.id; - try { - dispatch(logActivity.stage(entry)); - await simulateAPIRequest(); - dispatch(logActivity.amend(transitionId, { ...entry, id: generateId() })); - dispatch(logActivity.commit(transitionId)); - } catch (error) { - dispatch(logActivity.fail(transitionId, error)); - } - }; - - const handleEditActivity = async (entry: ActivityEntry) => { - const transitionId = entry.id; - try { - dispatch(editActivity.stage(entry)); - await simulateAPIRequest(); - dispatch(editActivity.commit(transitionId)); - } catch (error) { - dispatch(editActivity.fail(transitionId, error)); - } - }; - - const handleDismissActivity = async (entry: ActivityEntry) => { - const transitionId = entry.id; - try { - dispatch(dismissActivity.stage({ id: entry.id })); - await simulateAPIRequest(); - dispatch(dismissActivity.commit(transitionId)); - } catch (error) { - dispatch(dismissActivity.stash(transitionId)); - } - }; - - /** Route failed transitions through the correct lifecycle handler on reconnect */ - const store = useStore(); - - const retryAction = (action: StagedAction) => { - 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(() => { - const state = store.getState(); - const failed = [ - ...epicsSelectors.selectFailures(state.epics), - ...profileSelectors.selectFailures(state.profile), - ...projectsSelectors.selectFailures(state.projects), - ...activitySelectors.selectFailures(state.activity), - ]; - failed.forEach(retryAction); - }); - - return ( - - -
- -
- -
- -
- - ); -}; diff --git a/usecases/basic/index.tsx b/usecases/basic/index.tsx deleted file mode 100644 index 04dd86f..0000000 --- a/usecases/basic/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, type FC } from 'react'; -import { Provider } from 'react-redux'; - -import { App } from '~usecases/basic/App'; -import { TransitionHistoryProvider } from '~usecases/lib/components/graph/TransitionHistoryProvider'; -import { useMockApi } from '~usecases/lib/components/mocks/MockApiProvider'; -import { createDebugStore } from '~usecases/lib/store/store'; - -const { store, eventBus } = createDebugStore(); - -const Usecase: FC = () => { - const mockApi = useMockApi(); - useEffect(() => mockApi.setStore(store), []); - - return ( - - - - - - ); -}; - -export default Usecase; diff --git a/usecases/index.tsx b/usecases/index.tsx index 8121923..e709901 100644 --- a/usecases/index.tsx +++ b/usecases/index.tsx @@ -1,272 +1,69 @@ import type { FC } from 'react'; +import { useEffect } from 'react'; import { createRoot } from 'react-dom/client'; -import { NavLink, Route, HashRouter as Router, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import createSagaMiddleware from 'redux-saga'; -import BasicUsecase from '~usecases/basic'; +import { App } from '~usecases/App'; +import { TransitionHistoryProvider } from '~usecases/lib/components/graph/TransitionHistoryProvider'; import { MockApiControls } from '~usecases/lib/components/mocks/MockApiControls'; -import { MockApiProvider } from '~usecases/lib/components/mocks/MockApiProvider'; -import { Logo, Stars } from '~usecases/lib/components/todo/Icons'; -import SagasUsecase from '~usecases/sagas'; -import ThunksUsecase from '~usecases/thunks'; +import { MockApiProvider, useMockApi } from '~usecases/lib/components/mocks/MockApiProvider'; +import { Logo } from '~usecases/lib/components/todo/Icons'; +import { createDebugStore } from '~usecases/lib/store/store'; +import { rootSaga } from '~usecases/saga'; import './styles.css'; -const usecases = [ - { key: 'Basic', path: '/basic', component: BasicUsecase, desc: 'Component-level async' }, - { key: 'Thunks', path: '/thunks', component: ThunksUsecase, desc: 'Thunk orchestration' }, - { key: 'Sagas', path: '/sagas', component: SagasUsecase, desc: 'Saga-driven lifecycle' }, -]; +const sagaMiddleware = createSagaMiddleware(); +const { store, eventBus } = createDebugStore(sagaMiddleware); +sagaMiddleware.run(rootSaga); -/** Banner — block letter OPTIMISTRON with gradient + terminal dots + stars */ -const BannerSvg: FC = () => ( - - - - - - - - - - - - - - - - - - - - - - - - {/* prettier-ignore */} - - - ██████╗ ██████╗ ████████╗██╗███╗ ███╗██╗███████╗████████╗██████╗ ██████╗ ███╗ ██╗ - ██╔═══██╗██╔══██╗╚══██╔══╝██║████╗ ████║██║██╔════╝╚══██╔══╝██╔══██╗██╔═══██╗████╗ ██║ - ██║ ██║██████╔╝ ██║ ██║██╔████╔██║██║███████╗ ██║ ██████╔╝██║ ██║██╔██╗ ██║ - ██║ ██║██╔═══╝ ██║ ██║██║╚██╔╝██║██║╚════██║ ██║ ██╔══██╗██║ ██║██║╚██╗██║ - ╚██████╔╝██║ ██║ ██║██║ ╚═╝ ██║██║███████║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ - ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ - - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - λς - - -); - -/** Lifecycle diagram — stage/commit/amend/fail/stash flow */ -const LifecycleSvg: FC = () => ( - - - - - - - - - - - - - - - {/* prettier-ignore */} - - stage ───▶ commit ✓ stage optimistically, commit on success - ├──────▶ amend ↻ update staged transition before committing - ├──────▶ fail ✗ flag as failed — keep for retry/UI feedback - └──────▶ stash ↩ revert — restore trailing if TRAILING dedupe - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - ✦ - - - λς - - -); - -const Home: FC = () => ( -
- -
-
- -
-

Optimistic state for Redux

- -
-

- A project management app demonstrating Optimistron — each section uses a different - state shape with optimistic CRUD. -

-

- singularState for the user profile,{' '} - nestedRecordState for project-grouped tasks,{' '} - recordState for the flat epic list, and{' '} - listState for the activity log. -

-
- -
- -
+const Usecase: FC = () => { + const mockApi = useMockApi(); + useEffect(() => mockApi.setStore(store), []); -
-

- Pick a usecase from the sidebar. Each one implements the same store with a different async pattern — - component-level, thunks, or sagas. -

-

- Use the Mock API controls to toggle offline mode, adjust latency, and trigger a sync to see how - Optimistron handles failures, retries, and conflict detection in real-time. -

-
-
-
-); + return ( + + + + + + ); +}; -export const App: FC = () => ( +export const Root: FC = () => ( - -
-
-
- -
-

Optimistron

- demos -
- -
+
+
+
+
+

Optimistron

+ sagas demo
-
+ +
+
- +
-
-
-
- -
+
+
+
+
+
-
+
-
- - - {usecases.map(({ key, path, component }) => ( - - ))} - -
+
+
- +
); const el = document.getElementById('root')!; const root = createRoot(el); -root.render(); +root.render(); requestAnimationFrame(() => el.classList.add('ready')); diff --git a/usecases/saga.ts b/usecases/saga.ts new file mode 100644 index 0000000..f0563bf --- /dev/null +++ b/usecases/saga.ts @@ -0,0 +1,43 @@ +import { createAction } from '@reduxjs/toolkit'; +import { watchTransition, retryFailed } from '~saga/index'; +import { activitySelectors } from '~usecases/lib/store/activity/reducer'; +import { dismissActivity, editActivity, logActivity } from '~usecases/lib/store/activity/actions'; +import { createEpic, deleteEpic, editEpic } from '~usecases/lib/store/epics/actions'; +import { epicsSelectors } from '~usecases/lib/store/epics/reducer'; +import { profileSelectors } from '~usecases/lib/store/profile/reducer'; +import { updateProfile } from '~usecases/lib/store/profile/actions'; +import { projectsSelectors } from '~usecases/lib/store/projects/reducer'; +import { createProjectTodo, deleteProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; +import type { State } from '~usecases/lib/store/store'; +import { generateId, simulateAPIRequest } from '~usecases/lib/utils/mock-api'; + +export const retryAll = createAction('optimistron::retryAll'); + +export function* rootSaga() { + yield retryFailed(retryAll, (state: State) => [ + ...epicsSelectors.selectFailures(state.epics), + ...profileSelectors.selectFailures(state.profile), + ...projectsSelectors.selectFailures(state.projects), + ...activitySelectors.selectFailures(state.activity), + ]); + + yield watchTransition(createEpic, simulateAPIRequest, { + amend: (payload) => ({ ...payload, id: generateId() }), + }); + yield watchTransition(editEpic, simulateAPIRequest); + yield watchTransition(deleteEpic, simulateAPIRequest); + + yield watchTransition(updateProfile, simulateAPIRequest); + + yield watchTransition(createProjectTodo, simulateAPIRequest, { + amend: (payload) => ({ ...payload, id: generateId() }), + }); + yield watchTransition(editProjectTodo, simulateAPIRequest); + yield watchTransition(deleteProjectTodo, simulateAPIRequest); + + yield watchTransition(logActivity, simulateAPIRequest, { + amend: (payload) => ({ ...payload, id: generateId() }), + }); + yield watchTransition(editActivity, simulateAPIRequest); + yield watchTransition(dismissActivity, simulateAPIRequest); +} diff --git a/usecases/sagas/index.tsx b/usecases/sagas/index.tsx deleted file mode 100644 index dc44d9e..0000000 --- a/usecases/sagas/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { FC } from 'react'; -import { Provider } from 'react-redux'; -import createSagaMiddleware from 'redux-saga'; - -import { useEffect } from 'react'; - -import { TransitionHistoryProvider } from '~usecases/lib/components/graph/TransitionHistoryProvider'; -import { useMockApi } from '~usecases/lib/components/mocks/MockApiProvider'; -import { createDebugStore } from '~usecases/lib/store/store'; -import { App } from '~usecases/sagas/App'; -import { rootSaga } from '~usecases/sagas/saga'; - -const sagaMiddleware = createSagaMiddleware(); -const { store, eventBus } = createDebugStore(sagaMiddleware); -sagaMiddleware.run(rootSaga); - -const Usecase: FC = () => { - const mockApi = useMockApi(); - useEffect(() => mockApi.setStore(store), []); - - return ( - - - - - - ); -}; - -export default Usecase; diff --git a/usecases/sagas/saga.ts b/usecases/sagas/saga.ts deleted file mode 100644 index c6dc386..0000000 --- a/usecases/sagas/saga.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createAction } from '@reduxjs/toolkit'; -import { call, put, select, takeEvery, takeLeading } from 'redux-saga/effects'; - -import type { StagedAction } from '~transitions'; -import { getTransitionMeta } from '~transitions'; -import { activitySelectors } from '~usecases/lib/store/activity/reducer'; -import { dismissActivity, editActivity, logActivity } from '~usecases/lib/store/activity/actions'; -import { createEpic, deleteEpic, editEpic } from '~usecases/lib/store/epics/actions'; -import { epicsSelectors } from '~usecases/lib/store/epics/reducer'; -import { profileSelectors } from '~usecases/lib/store/profile/reducer'; -import { updateProfile } from '~usecases/lib/store/profile/actions'; -import { projectsSelectors } from '~usecases/lib/store/projects/reducer'; -import { createProjectTodo, deleteProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; -import type { State } from '~usecases/lib/store/store'; -import { generateId, simulateAPIRequest } from '~usecases/lib/utils/mock-api'; - -export const retryAll = createAction('optimistron::retryAll'); - -function* retryAllFailed() { - const epics: State['epics'] = yield select((s: State) => s.epics); - const profile: State['profile'] = yield select((s: State) => s.profile); - const projects: State['projects'] = yield select((s: State) => s.projects); - const activity: State['activity'] = yield select((s: State) => s.activity); - - const failed: StagedAction[] = [ - ...epicsSelectors.selectFailures(epics), - ...profileSelectors.selectFailures(profile), - ...projectsSelectors.selectFailures(projects), - ...activitySelectors.selectFailures(activity), - ]; - - for (const action of failed) { - yield put(action); - } -} - -export function* rootSaga() { - yield takeLeading(retryAll.match, retryAllFailed); - yield takeEvery(createEpic.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(createEpic.amend(transitionId, { ...action.payload, id: generateId() })); - yield put(createEpic.commit(transitionId)); - } catch (error) { - yield put(createEpic.fail(transitionId, error)); - } - }); - - yield takeEvery(editEpic.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(editEpic.commit(transitionId)); - } catch (error) { - yield put(editEpic.fail(transitionId, error)); - } - }); - - yield takeEvery(deleteEpic.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(deleteEpic.commit(transitionId)); - } catch { - yield put(deleteEpic.stash(transitionId)); - } - }); - - yield takeEvery(updateProfile.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(updateProfile.commit(transitionId)); - } catch (error) { - yield put(updateProfile.fail(transitionId, error)); - } - }); - - yield takeEvery(createProjectTodo.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(createProjectTodo.amend(transitionId, { ...action.payload, id: generateId() })); - yield put(createProjectTodo.commit(transitionId)); - } catch (error) { - yield put(createProjectTodo.fail(transitionId, error)); - } - }); - - yield takeEvery(editProjectTodo.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(editProjectTodo.commit(transitionId)); - } catch (error) { - yield put(editProjectTodo.fail(transitionId, error)); - } - }); - - yield takeEvery(deleteProjectTodo.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(deleteProjectTodo.commit(transitionId)); - } catch { - yield put(deleteProjectTodo.stash(transitionId)); - } - }); - - yield takeEvery(logActivity.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(logActivity.amend(transitionId, { ...action.payload, id: generateId() })); - yield put(logActivity.commit(transitionId)); - } catch (error) { - yield put(logActivity.fail(transitionId, error)); - } - }); - - yield takeEvery(editActivity.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(editActivity.commit(transitionId)); - } catch (error) { - yield put(editActivity.fail(transitionId, error)); - } - }); - - yield takeEvery(dismissActivity.stage.match, function* (action) { - const transitionId = getTransitionMeta(action).id; - try { - yield call(simulateAPIRequest); - yield put(dismissActivity.commit(transitionId)); - } catch { - yield put(dismissActivity.stash(transitionId)); - } - }); -} diff --git a/usecases/thunks/App.tsx b/usecases/thunks/App.tsx deleted file mode 100644 index c2a0ccf..0000000 --- a/usecases/thunks/App.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { type FC } from 'react'; -import { useDispatch, useStore } from 'react-redux'; - -import type { StagedAction } from '~transitions'; - -import { ActivityFeed } from '~usecases/lib/components/activity/ActivityFeed'; -import { ProfileCard } from '~usecases/lib/components/profile/ProfileCard'; -import { ProjectBoard } from '~usecases/lib/components/projects/ProjectBoard'; -import { Layout, type UsecaseDescription } from '~usecases/lib/components/todo/Layout'; -import { TodoApp } from '~usecases/lib/components/todo/TodoApp'; -import { useAutoRetry } from '~usecases/lib/hooks/useAutoRetry'; -import { activitySelectors } from '~usecases/lib/store/activity/reducer'; -import { editActivity, logActivity } from '~usecases/lib/store/activity/actions'; -import { createEpic, editEpic } from '~usecases/lib/store/epics/actions'; -import { epicsSelectors } from '~usecases/lib/store/epics/reducer'; -import { profileSelectors } from '~usecases/lib/store/profile/reducer'; -import { updateProfile } from '~usecases/lib/store/profile/actions'; -import { projectsSelectors } from '~usecases/lib/store/projects/reducer'; -import { createProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; -import type { ActivityEntry, Epic, Profile, ProjectTodo } from '~usecases/lib/store/types'; -import type { State } from '~usecases/lib/store/store'; -import type { store } from '~usecases/thunks'; -import { - createEpicThunk, - createProjectTodoThunk, - deleteEpicThunk, - deleteProjectTodoThunk, - dismissActivityThunk, - editActivityThunk, - editEpicThunk, - editProjectTodoThunk, - logActivityThunk, - updateProfileThunk, -} from '~usecases/thunks/thunk'; - -import { C, F, O } from '~usecases/lib/components/todo/CodeTags'; - -const description: UsecaseDescription = { - subtitle: 'Redux thunks — lifecycle logic moved from components into thunks.', - howItWorks: [ - <>Component dispatches a thunk. No transition management in the component., - <>Each thunk encapsulates the full lifecycle: stage → API → amendcommit / fail., - <>Components only call dispatch(thunk(item))., - <>Failed transitions can be edited in-place — a new stage overwrites the failed one, restarting the lifecycle., - ], -}; - -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)); - const handleDeleteEpic = async ({ id }: Epic) => dispatch(deleteEpicThunk(id)); - const handleUpdateProfile = async (update: Partial) => dispatch(updateProfileThunk(update)); - const handleCreateProjectTodo = async (todo: ProjectTodo) => dispatch(createProjectTodoThunk(todo)); - const handleEditProjectTodo = async (todo: ProjectTodo) => dispatch(editProjectTodoThunk(todo)); - const handleDeleteProjectTodo = async (todo: ProjectTodo) => dispatch(deleteProjectTodoThunk(todo)); - const handleLogActivity = async (entry: ActivityEntry) => dispatch(logActivityThunk(entry)); - const handleEditActivity = async (entry: ActivityEntry) => dispatch(editActivityThunk(entry)); - const handleDismissActivity = async (entry: ActivityEntry) => dispatch(dismissActivityThunk(entry)); - - /** Route failed transitions through the correct thunk on reconnect */ - const store = useStore(); - - const retryAction = (action: StagedAction) => { - 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(() => { - const state = store.getState(); - const failed = [ - ...epicsSelectors.selectFailures(state.epics), - ...profileSelectors.selectFailures(state.profile), - ...projectsSelectors.selectFailures(state.projects), - ...activitySelectors.selectFailures(state.activity), - ]; - failed.forEach(retryAction); - }); - - return ( - - -
- -
- -
- -
- - ); -}; diff --git a/usecases/thunks/index.tsx b/usecases/thunks/index.tsx deleted file mode 100644 index 7ae67b1..0000000 --- a/usecases/thunks/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { FC } from 'react'; -import { useEffect } from 'react'; -import { Provider } from 'react-redux'; -import { thunk } from 'redux-thunk'; - -import { TransitionHistoryProvider } from '~usecases/lib/components/graph/TransitionHistoryProvider'; -import { useMockApi } from '~usecases/lib/components/mocks/MockApiProvider'; -import { createDebugStore } from '~usecases/lib/store/store'; -import { App } from '~usecases/thunks/App'; - -export const { store, eventBus } = createDebugStore(thunk); - -const Usecase: FC = () => { - const mockApi = useMockApi(); - useEffect(() => mockApi.setStore(store), []); - - return ( - - - - - - ); -}; - -export default Usecase; diff --git a/usecases/thunks/thunk.ts b/usecases/thunks/thunk.ts deleted file mode 100644 index fb495db..0000000 --- a/usecases/thunks/thunk.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Action } from 'redux'; -import type { ThunkAction } from 'redux-thunk'; - -import { dismissActivity, editActivity, logActivity } from '~usecases/lib/store/activity/actions'; -import { createEpic, deleteEpic, editEpic } from '~usecases/lib/store/epics/actions'; -import { updateProfile } from '~usecases/lib/store/profile/actions'; -import { createProjectTodo, deleteProjectTodo, editProjectTodo } from '~usecases/lib/store/projects/actions'; -import type { State } from '~usecases/lib/store/store'; -import type { ActivityEntry, Epic, Profile, ProjectTodo } from '~usecases/lib/store/types'; -import { generateId, simulateAPIRequest } from '~usecases/lib/utils/mock-api'; - -type Thunk = ThunkAction; - -export const createEpicThunk = (epic: Epic): Thunk => async (dispatch) => { - const transitionId = epic.id; - try { - dispatch(createEpic.stage(epic)); - await simulateAPIRequest(); - dispatch(createEpic.amend(transitionId, { ...epic, id: generateId() })); - dispatch(createEpic.commit(transitionId)); - } catch (error) { - dispatch(createEpic.fail(transitionId, error)); - } -}; - -export const editEpicThunk = (epic: Epic): Thunk => async (dispatch) => { - const transitionId = epic.id; - try { - dispatch(editEpic.stage(epic)); - await simulateAPIRequest(); - dispatch(editEpic.commit(transitionId)); - } catch (error) { - dispatch(editEpic.fail(transitionId, error)); - } -}; - -export const deleteEpicThunk = (id: string): Thunk => async (dispatch) => { - const transitionId = id; - try { - dispatch(deleteEpic.stage({ id })); - await simulateAPIRequest(); - dispatch(deleteEpic.commit(transitionId)); - } catch { - dispatch(deleteEpic.stash(transitionId)); - } -}; - -export const updateProfileThunk = (update: Partial): Thunk => async (dispatch) => { - try { - dispatch(updateProfile.stage(update)); - await simulateAPIRequest(); - dispatch(updateProfile.commit('profile')); - } catch (error) { - dispatch(updateProfile.fail('profile', error)); - } -}; - -export const createProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispatch) => { - const transitionId = `${todo.projectId}/${todo.id}`; - try { - dispatch(createProjectTodo.stage(todo)); - await simulateAPIRequest(); - dispatch(createProjectTodo.amend(transitionId, { ...todo, id: generateId() })); - dispatch(createProjectTodo.commit(transitionId)); - } catch (error) { - dispatch(createProjectTodo.fail(transitionId, error)); - } -}; - -export const editProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispatch) => { - const transitionId = `${todo.projectId}/${todo.id}`; - try { - dispatch(editProjectTodo.stage(todo)); - await simulateAPIRequest(); - dispatch(editProjectTodo.commit(transitionId)); - } catch (error) { - dispatch(editProjectTodo.fail(transitionId, error)); - } -}; - -export const deleteProjectTodoThunk = (todo: ProjectTodo): Thunk => async (dispatch) => { - const transitionId = `${todo.projectId}/${todo.id}`; - try { - dispatch(deleteProjectTodo.stage({ projectId: todo.projectId, id: todo.id })); - await simulateAPIRequest(); - dispatch(deleteProjectTodo.commit(transitionId)); - } catch { - dispatch(deleteProjectTodo.stash(transitionId)); - } -}; - -export const logActivityThunk = (entry: ActivityEntry): Thunk => async (dispatch) => { - const transitionId = entry.id; - try { - dispatch(logActivity.stage(entry)); - await simulateAPIRequest(); - dispatch(logActivity.amend(transitionId, { ...entry, id: generateId() })); - dispatch(logActivity.commit(transitionId)); - } catch (error) { - dispatch(logActivity.fail(transitionId, error)); - } -}; - -export const editActivityThunk = (entry: ActivityEntry): Thunk => async (dispatch) => { - const transitionId = entry.id; - try { - dispatch(editActivity.stage(entry)); - await simulateAPIRequest(); - dispatch(editActivity.commit(transitionId)); - } catch (error) { - dispatch(editActivity.fail(transitionId, error)); - } -}; - -export const dismissActivityThunk = (entry: ActivityEntry): Thunk => async (dispatch) => { - const transitionId = entry.id; - try { - dispatch(dismissActivity.stage({ id: entry.id })); - await simulateAPIRequest(); - dispatch(dismissActivity.commit(transitionId)); - } catch { - dispatch(dismissActivity.stash(transitionId)); - } -};