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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches:
- main
pull_request:

jobs:
deploy:
Expand Down
71 changes: 47 additions & 24 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,27 @@ Internals, API, and advanced patterns. For quick start, see [README.md](./README

## Entity Identity

Every transition carries a string ID. This ID is the **stable link between a transition and its entity in state**. `createTodo.stage(todo.id, todo)` — the first argument becomes the transition ID, used by every subsequent operation to locate the right entry.

Any derivation works. What matters: one ID, one entity. Because:
Every transition carries a string ID — the **stable link between a transition and its entity in state**. One ID, one entity. Because:

- **Sanitization** replays by ID — shared IDs cause shadowing
- **Selectors** look up by ID — ambiguous IDs break lookups
- **Dedupe** matches on ID

For `Record<string, T>`, the simplest approach is using the entity key directly:
**The recommended default is `transitionId === entityId`.** Use `crudPrepare` to couple them:

```typescript
indexedStateFactory<Todo>({ itemIdKey: 'id', /* ... */ });
// stage('abc', entity) → state['abc'] = entity
const crud = crudPrepare<Todo>('id');
const createTodo = createTransitions('todos::add')(crud.create);

dispatch(createTodo.stage(todo)); // transitionId auto-detected from todo.id
dispatch(createTodo.amend(tid, amended)); // explicit — targets original transition
dispatch(createTodo.commit(tid)); // explicit
```

**Why STAGE-only auto-detection:** `stage` initiates a new transition — the entity *is* the transition. But `amend`/`commit`/`fail`/`stash` target an *existing* transition the consumer already holds a reference to. Auto-detecting on `amend` is a pitfall: it shares `stagePA`, so an amended entity with a server-assigned ID would target the wrong transition.

For edge-cases where `transitionId !== entityId` (batch ops, correlation IDs, server-assigned IDs with temp tokens), write custom prepare functions and pass transitionId as the first argument — the explicit path works for all operations including `stage`.

---

## Versioning & Conflicts
Expand Down Expand Up @@ -150,12 +156,11 @@ const profileHandler: StateHandler<
`DedupeMode.TRAILING` — undo-on-failure for destructive ops:

```typescript
const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)(
(id: string) => ({ payload: { id } })
);
const crud = crudPrepare<Todo>('id');
const deleteTodo = createTransitions('todos::delete', DedupeMode.TRAILING)(crud.remove);

dispatch(deleteTodo.stage(id, id)); // gone from UI
dispatch(deleteTodo.stash(id)); // back — restored from trailing
dispatch(deleteTodo.stage(id)); // gone from UI (transitionId auto-detected)
dispatch(deleteTodo.stash(id)); // back — restored from trailing
```

Replaced transitions are stored as fallback. `stash` restores instead of dropping.
Expand All @@ -171,12 +176,14 @@ Transport-agnostic. Works with anything:

```typescript
const handleCreate = async (todo: Todo) => {
dispatch(createTodo.stage(todo.id, todo));
const transitionId = todo.id;
dispatch(createTodo.stage(todo)); // auto-detect transitionId
try {
await api.create(todo);
dispatch(createTodo.commit(todo.id));
dispatch(createTodo.amend(transitionId, { ...todo, id: serverId })); // explicit
dispatch(createTodo.commit(transitionId));
} catch (e) {
dispatch(createTodo.fail(todo.id, e));
dispatch(createTodo.fail(transitionId, e));
}
};
```
Expand All @@ -190,12 +197,14 @@ const handleCreate = async (todo: Todo) => {
const createTodoThunk =
(todo: Todo): ThunkAction<void, RootState, void, Action> =>
async (dispatch) => {
dispatch(createTodo.stage(todo.id, todo));
const transitionId = todo.id;
dispatch(createTodo.stage(todo));
try {
await api.create(todo);
dispatch(createTodo.commit(todo.id));
dispatch(createTodo.amend(transitionId, { ...todo, id: serverId }));
dispatch(createTodo.commit(transitionId));
} catch (e) {
dispatch(createTodo.fail(todo.id, e));
dispatch(createTodo.fail(transitionId, e));
}
};
```
Expand All @@ -207,12 +216,13 @@ const createTodoThunk =

```typescript
function* createTodoSaga(action: ReturnType<typeof createTodo.stage>) {
const { id } = getTransitionMeta(action);
const transitionId = getTransitionMeta(action).id;
try {
yield call(api.create, action.payload.todo);
yield put(createTodo.commit(id));
yield call(api.create, action.payload.item);
yield put(createTodo.amend(transitionId, { ...action.payload.item, id: serverId }));
yield put(createTodo.commit(transitionId));
} catch (e) {
yield put(createTodo.fail(id, e));
yield put(createTodo.fail(transitionId, e));
}
}
```
Expand Down Expand Up @@ -243,14 +253,27 @@ Returned from `optimistron()`. Replays transitions before selecting. Memoize wit
selectOptimistic((todos) => Object.values(todos.state))
```

### `crudPrepare(itemIdKey)`

Factory for CRUD prepare functions that couple `transitionId === entityId`. Recommended default for indexed state.

```typescript
const crud = crudPrepare<Todo>('id');
// crud.create(item) → { payload: { item }, transitionId: item.id }
// crud.update(id, partial) → { payload: { id, item: partial }, transitionId: id }
// crud.remove(id) → { payload: { id }, transitionId: id }
```

### `createTransitions(type, dedupe?)(prepare)`

Creates `.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`. Per-operation preparators supported:
Creates `.stage`, `.amend`, `.commit`, `.fail`, `.stash`, `.match`.

`stage` auto-detects `transitionId` when prepare returns it (e.g. via `crudPrepare`). All other operations require explicit `transitionId` as first argument. Per-operation preparators supported:

```typescript
createTransitions('todos::add')({
stage: (item: Todo) => ({ payload: { item } }),
commit: (serverItem: Todo) => ({ payload: { item: serverItem } }),
stage: (item: Todo) => ({ payload: { item }, transitionId: item.id }),
commit: () => ({ payload: {} }),
});
```

Expand Down
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ No `isLoading`, `error`, `isOptimistic` flags. A pending transition means loadin

```typescript
import { configureStore, createSelector } from '@reduxjs/toolkit';
import { optimistron, createTransitions, indexedStateFactory } from '@lostsolution/optimistron';
import { optimistron, createTransitions, crudPrepare, indexedStateFactory } from '@lostsolution/optimistron';

type Todo = { id: string; value: string; done: boolean; revision: number };

const createTodo = createTransitions('todos::add')((todo: Todo) => ({ payload: { todo } }));
const editTodo = createTransitions('todos::edit')((id: string, todo: Todo) => ({ payload: { id, todo } }));
const deleteTodo = createTransitions('todos::delete')((id: string) => ({ payload: { id } }));
const crud = crudPrepare<Todo>('id');
const createTodo = createTransitions('todos::add')(crud.create);
const editTodo = createTransitions('todos::edit')(crud.update);
const deleteTodo = createTransitions('todos::delete')(crud.remove);

const { reducer: todos, selectOptimistic } = optimistron(
'todos',
Expand All @@ -67,8 +68,8 @@ const { reducer: todos, selectOptimistic } = optimistron(
eq: (a) => (b) => a.done === b.done && a.value === b.value,
}),
({ getState, create, update, remove }, action) => {
if (createTodo.match(action)) return create(action.payload.todo);
if (editTodo.match(action)) return update(action.payload.id, action.payload.todo);
if (createTodo.match(action)) return create(action.payload.item);
if (editTodo.match(action)) return update(action.payload.id, action.payload.item);
if (deleteTodo.match(action)) return remove(action.payload.id);
return getState();
},
Expand All @@ -81,8 +82,8 @@ const selectTodos = createSelector(
selectOptimistic((todos) => Object.values(todos.state)),
);

dispatch(createTodo.stage(todo.id, todo)); // UI updates instantly
dispatch(createTodo.commit(todo.id)); // persist on success
dispatch(createTodo.stage(todo)); // transitionId auto-detected from entity ID
dispatch(createTodo.commit(todo.id)); // persist on success
dispatch(createTodo.fail(todo.id, error)); // flag on error
```

Expand All @@ -92,7 +93,11 @@ dispatch(createTodo.fail(todo.id, error)); // flag on error

### Transition IDs

Every transition is tracked by a string ID — the first argument to `.stage()`, `.commit()`, etc. This ID is the **consistent key** between a transition and the entity it describes. It's how `selectIsFailed(id)` and `selectIsOptimistic(id)` infer per-entity status. Any stable derivation works, but each ID should resolve to exactly one entity.
Every transition is tracked by a string ID — the **stable link** between a transition and the entity it describes. It's how `selectIsFailed(id)` and `selectIsOptimistic(id)` infer per-entity status.

**The recommended default is `transitionId === entityId`.** Use `crudPrepare` to couple them — `stage(entity)` automatically derives the transition ID from the entity's own key. For `amend`/`commit`/`fail`/`stash`, pass the transition ID explicitly (you already have it from the initial `stage`).

For edge-cases where transitionId must differ from entityId (batch ops, correlation IDs, server-assigned IDs with temp tokens), write custom prepare functions and pass transitionId as the first argument.

### Versioning

Expand All @@ -106,6 +111,17 @@ Entities need a **monotonically increasing version** — `revision`, `updatedAt`

---

## Roadmap

- **Batch transitions** — stage multiple entities under a single correlation ID. Commit/fail/stash the batch atomically.
- **More state factories** — `singleEntityFactory` for scalar state, `normalizedStateFactory` for relational stores with foreign key handling.
- **Retry strategies** — configurable retry policies for failed transitions (exponential backoff, max attempts) built into the transition lifecycle.
- **Devtools integration** — Redux DevTools timeline visualization for transitions, sanitization events, and conflict detection.
- **Persistence adapters** — serialize/rehydrate pending transitions across page reloads (localStorage, IndexedDB).
- **Middleware hooks** — `onConflict`, `onStale`, `onSanitize` callbacks for custom side-effects without reducer coupling.

---

## Development

```bash
Expand Down
101 changes: 71 additions & 30 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,45 @@ type EmptyPayload = { payload: never };
type PA_Empty = () => EmptyPayload;
type PA_Error = (error: unknown) => EmptyPayload & { error: Error };

const emptyPA = () => ({ payload: {} });
const errorPA = (error: unknown) => ({ error: error instanceof Error ? error.message : error, payload: {} });

/** Extracts the payload type from a PrepareAction */
type PreparePayload<PA extends PrepareAction<any>> = ReturnType<PA>['payload'];

/** Extracts the error type from a PrepareAction, or `never` if none */
type PrepareError<PA extends PrepareAction<any>> = ReturnType<PA> extends { error: infer E } ? E : never;

/** Merges transition meta with any extra meta from a PrepareAction */
type ActionMeta<Op extends Operation, PA extends PrepareAction<any>> = TransitionMeta<Op> &
(ReturnType<PA> extends { meta: infer M } ? M : object);

/** Resolves the arguments signature for a transition action creator.
* STAGE auto-detects transitionId when prepare returns it;
* all other operations require explicit transitionId as first arg. */
type TransitionArgs<Op extends Operation, PA extends PrepareAction<any>> = Op extends Operation.STAGE
? ReturnType<PA> extends { transitionId: string }
? Parameters<PA>
: [transitionId: string, ...Parameters<PA>]
: [transitionId: string, ...Parameters<PA>];

export type TransitionWithPreparedPayload<
ActionType extends TransitionNamespace,
Op extends Operation,
PA extends PrepareAction<any>,
> = ActionCreatorWithPreparedPayload<
[transitionId: string, ...Parameters<PA>],
ReturnType<PA>['payload'],
TransitionArgs<Op, PA>,
PreparePayload<PA>,
ActionType,
ReturnType<PA> extends { error: infer E } ? E : never,
TransitionMeta<Op> & (ReturnType<PA> extends { meta: infer M } ? M : object)
PrepareError<PA>,
ActionMeta<Op, PA>
>;

export type TransitionPayloadAction<
Type extends string,
Op extends Operation,
PA extends PrepareAction<any>,
> = PayloadAction<
ReturnType<PA>['payload'],
Type,
TransitionMeta<Op> & (ReturnType<PA> extends { meta: infer M } ? M : object),
ReturnType<PA> extends { error: infer E } ? E : never
>;
> = PayloadAction<PreparePayload<PA>, Type, ActionMeta<Op, PA>, PrepareError<PA>>;

/** Helper action matcher function that will match the supplied
* namespace when the transition operation is of type COMMIT */
Expand All @@ -51,26 +68,45 @@ const prepareTransition = <PA extends PrepareAction<any>>(
},
});

/** This function creates a transition action creator by extending RTK's `createAction`
* utility. Due to limitations with the type inference of `PrepareAction`, a type cast
* is required, as we cannot handle all variadic cases of the parameters of the `prepare`
* function. This arises due to the specific requirements of `createTransition` which
* necessitates passing a `transitionId` followed by parameters inferred from `PrepareAction`.
* In contrast, the `PrepareAction` type is defined to accept any number of arguments of
* any type. */
/** Resolves transitionId and hydrates transition meta onto a prepare result.
* For STAGE, auto-detects transitionId from prepare result when it returns
* `transitionId` — coupling transitionId to entityId for the common CRUD case.
* All other operations require explicit transitionId as the first argument,
* since they target an existing transition the consumer already holds a
* reference to. This also avoids a subtle pitfall: amend shares stagePA, so
* auto-detecting from an amended entity (which may carry a server-assigned ID)
* would target the wrong transition.
*
* For edge-cases where transitionId !== entityId (batch ops, correlation IDs),
* omit `transitionId` from the prepare return — the explicit path works for
* all operations including STAGE. */
export const resolveTransition =
(operation: Operation, dedupe: DedupeMode) =>
<PA extends PrepareAction<any>>(prepare: PA) =>
(...args: any[]) => {
if (operation === Operation.STAGE) {
const result = prepare(...args);
if (result && 'transitionId' in result) {
const id = String(result.transitionId);
return prepareTransition(result, { id, operation, dedupe });
}
}

const [transitionId, ...rest] = args;
return prepareTransition(prepare(...rest), { id: transitionId as string, operation, dedupe });
};

/** Creates a transition action creator by wrapping RTK's `createAction`.
* Type-cast required due to RTK's `PrepareAction` not supporting the variadic
* `[transitionId, ...prepareArgs]` signature inference. */
export const createTransition =
<Type extends TransitionNamespace, Op extends Operation>(
type: Type,
operation: Op,
dedupe: DedupeMode = DedupeMode.OVERWRITE,
) =>
<PA extends PrepareAction<any>>(prepare: PA): TransitionWithPreparedPayload<Type, Op, PA> =>
createAction(type, ((transitionId: string, ...params: Parameters<PA>) =>
prepareTransition(prepare(...params), {
id: transitionId,
operation,
dedupe,
})) as PrepareAction<any>);
createAction(type, resolveTransition(operation, dedupe)(prepare));

/** Generates transition actions for a specified transition type. By default, it uses the
* `OVERWRITE` dedupe strategy, which overwrites transitions with the same `transitionId` in
Expand All @@ -95,13 +131,6 @@ export const createTransitions =
},
) => {
const noOptions = typeof options === 'function';
const emptyPA = () => ({ payload: {} });

const errorPA = (error: unknown) => ({
error: error instanceof Error ? error.message : error,
payload: {},
});

const stagePA = noOptions ? options : options.stage;
const commitPA = noOptions ? emptyPA : (options.commit ?? emptyPA);
const failPA = noOptions ? errorPA : (options.fail ?? errorPA);
Expand All @@ -116,3 +145,15 @@ export const createTransitions =
match: createCommitMatcher<Type, PA_Stage>(type),
};
};

/** Factory for CRUD prepare functions that couple transitionId to entityId.
* This is the recommended default for indexed state — transitionId === entityId
* means dispatching `stage(entity)` automatically tracks the transition by the
* entity's own ID. For edge-cases where transitionId must differ from entityId
* (batch operations, server-assigned IDs with correlation tokens), write custom
* prepare functions and pass transitionId explicitly as the first argument. */
export const crudPrepare = <T extends Record<string, any>>(itemIdKey: keyof T & string) => ({
create: (item: T) => ({ payload: { item }, transitionId: String(item[itemIdKey]) }),
update: (id: string, item: Partial<T>) => ({ payload: { id, item }, transitionId: id }),
remove: (id: string) => ({ payload: { id }, transitionId: id }),
});
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { optimistron } from './optimistron';
export { createTransition, createTransitions } from './actions';
export { createTransition, createTransitions, crudPrepare } from './actions';
export {
selectConflictingTransition,
selectFailedTransition,
Expand Down
Loading