('id');
```
-You can implement the `StateHandler` interface for any state shape — the built-in handlers are just the common cases.
+You can implement the `StateHandler` interface for any shape — the built-ins are just the common cases.
---
-## Reducer Config
+## Reducer Configuration
-The 4th argument to `optimistron()` accepts three modes:
-
-### Auto-wired (zero boilerplate)
-
-Pass a CRUD action map — the handler's built-in `wire` method routes `crudPrepare` payloads automatically:
+The 4th argument to `optimistron()` supports three modes:
+**Auto-wired** — zero boilerplate, handler routes payloads:
```typescript
optimistron('todos', initial, handler, {
- create: createTodo,
- update: editTodo,
- remove: deleteTodo,
+ create: createTodo, update: editTodo, remove: deleteTodo,
});
```
-### Hybrid (auto-wired + fallback)
-
-Auto-wire CRUD and handle custom actions in a fallback reducer:
-
+**Hybrid** — auto-wire + fallback for custom actions:
```typescript
optimistron('todos', initial, handler, {
- create: createTodo,
- update: editTodo,
- remove: deleteTodo,
- reducer: ({ getState }, action) => {
- if (sync.match(action)) return /* custom logic */;
- return getState();
- },
+ create: createTodo, update: editTodo, remove: deleteTodo,
+ reducer: ({ getState }, action) => { /* custom logic */ },
});
```
-### Manual (full control)
-
-Pass a function — the current behavior, nothing changes:
-
+**Manual** — full control via `BoundStateHandler`:
```typescript
optimistron('todos', initial, handler, ({ getState, create, update, remove }, action) => {
- if (createTodo.match(action)) return create(action.payload.item);
- if (editTodo.match(action)) return update(action.payload.id, action.payload.item);
- if (deleteTodo.match(action)) return remove(action.payload.id);
- return getState();
+ if (createTodo.match(action)) return create(action.payload);
+ if (editTodo.match(action)) return update(action.payload);
+ if (deleteTodo.match(action)) return remove(action.payload);
+ return getState();
});
```
-All three modes are fully backwards compatible. The CRUD map only requires `{ match }` — an `ActionMatcher` type guard — from each action creator. `optimistron()` uses function overloads to infer the expected payload types from the handler, so mismatched action creators are caught at compile time.
+---
+
+## Transition Modes
+
+Declared per action type — controls what happens on re-stage and failure:
+
+| Mode | On re-stage | On fail | Typical use |
+|------|-------------|---------|-------------|
+| `DEFAULT` | Overwrite | Flag as failed | Edits |
+| `DISPOSABLE` | Overwrite | Drop transition | Creates |
+| `REVERTIBLE` | Store trailing | Revert to previous | Deletes |
---
## Selectors
-### `selectOptimistic`
-
-Returned from `optimistron()`. Replays pending transitions onto committed state at read-time. Wrap with `createSelector` for memoization:
+### Optimistic state
```typescript
-import { createSelector } from '@reduxjs/toolkit';
-
const selectTodos = createSelector(
- (state: RootState) => state.todos,
- selectOptimistic((todos) => Object.values(todos.state)),
+ (state: RootState) => state.todos,
+ selectOptimistic((todos) => Object.values(todos.state)),
);
```
### Per-entity status
-All transition selectors take a `transitionId` and a `TransitionState` — no global store shape required:
-
-```typescript
-import {
- selectIsOptimistic,
- selectIsFailed,
- selectIsConflicting,
- selectFailedTransition,
- selectConflictingTransition,
- selectRetryCount,
-} from '@lostsolution/optimistron';
-
-selectIsOptimistic(id)(state.todos) // true if transition is pending
-selectIsFailed(id)(state.todos) // true if transition has failed
-selectIsConflicting(id)(state.todos) // true if transition conflicts with committed state
-
-selectFailedTransition(id)(state.todos) // StagedAction | undefined
-selectConflictingTransition(id)(state.todos) // StagedAction | undefined
-selectRetryCount(id)(state.todos) // number of times this transition was re-staged after failure
-```
-
-### Aggregate selectors
-
```typescript
-import { selectFailedTransitions, selectAllFailedTransitions } from '@lostsolution/optimistron';
+import { selectIsOptimistic, selectIsFailed, selectIsConflicting } from '@lostsolution/optimistron';
-selectFailedTransitions(state.todos) // all failed in one slice
-selectAllFailedTransitions(state.todos, state.projects, state.activity) // all failed across slices
+selectIsOptimistic(id)(state.todos) // pending?
+selectIsFailed(id)(state.todos) // failed?
+selectIsConflicting(id)(state.todos) // stale conflict?
```
----
-
-## Transition Modes
-
-`TransitionMode` controls re-staging and failure behavior per action type — declared at the `createTransitions` site:
-
-```typescript
-import { createTransitions, TransitionMode } from '@lostsolution/optimistron';
-
-const createTodo = createTransitions('todos::add', TransitionMode.DISPOSABLE)(crud.create);
-const editTodo = createTransitions('todos::edit')(crud.update); // DEFAULT
-const deleteTodo = createTransitions('todos::delete', TransitionMode.REVERTIBLE)(crud.remove);
-```
-
-| Mode | On re-stage | On fail | Use case |
-|------|-------------|---------|----------|
-| **`DEFAULT`** | Overwrite | Flag as failed | Edits — consumer retries manually |
-| **`DISPOSABLE`** | Overwrite | Drop transition | Creates — entity never existed server-side |
-| **`REVERTIBLE`** | Store trailing | Stash (revert to trailing) | Deletes — undo on failure |
-
### Retry
-Retry is a consumer concern — timing, backoff, and reconnect logic belong in your app layer. The library provides the building blocks:
-
```typescript
import { retryTransition, selectFailedTransition, selectRetryCount } from '@lostsolution/optimistron';
const failed = selectFailedTransition(id)(state.todos);
-if (failed) {
- const retries = selectRetryCount(id)(state.todos);
- if (retries < 3) dispatch(retryTransition(failed)); // strips failure flags, re-stages
+if (failed && selectRetryCount(id)(state.todos) < 3) {
+ dispatch(retryTransition(failed));
}
```
-### Multi-slice failure aggregation
+### Aggregate failures
```typescript
import { selectAllFailedTransitions } from '@lostsolution/optimistron';
-
-const allFailed = selectAllFailedTransitions(state.todos, state.projects, state.activity);
+selectAllFailedTransitions(state.todos, state.projects, state.activity);
```
---
-## Roadmap
-
-- **Batch transitions** — stage multiple entities under a single correlation ID. Commit/fail/stash the batch atomically.
-- **Devtools integration** — Redux DevTools timeline visualization for transitions, sanitization events, and conflict detection.
-- **Persistence adapters** — serialize/rehydrate pending transitions across page reloads (localStorage, IndexedDB).
-- **Transition expiry / TTL** — automatic cleanup of stale failed transitions after a configurable time window.
-
----
-
## Development
```bash
-bun test # run tests (coverage threshold 90%)
-bun run build:esm # build to lib/
+bun test # tests with coverage (threshold 90%)
+bun run build:esm # build to lib/
```
-See `usecases/` for working examples demonstrating state handlers (`recordState`, `singularState`, `nestedRecordState`, `listState`) with basic async, thunks, and sagas.
+See `usecases/` for working examples with basic async, thunks, and sagas.
+
+---
+
+## Deep Dive
+
+For internals, design decisions, and the full API reference, see [ARCHITECTURE.md](./ARCHITECTURE.md).
diff --git a/src/actions/crud.ts b/src/actions/crud.ts
index c8df053..8eb4107 100644
--- a/src/actions/crud.ts
+++ b/src/actions/crud.ts
@@ -1,4 +1,4 @@
-import type { PathIds } from './types';
+import type { DeleteDTO, UpdateDTO } from './types';
/**
* Factory for CRUD prepare functions that couple transitionId to entityId.
@@ -6,7 +6,7 @@ import type { PathIds } from './types';
* means dispatching `stage(entity)` automatically tracks the transition by the
* entity's own ID.
*
- * Single-key overload (unchanged):
+ * Single-key overload:
* ```ts
* const crud = crudPrepare- ('id');
* ```
@@ -24,44 +24,40 @@ import type { PathIds } from './types';
export function crudPrepare>(): (
keys: Keys,
) => {
- create: (item: T) => { payload: { item: T }; transitionId: string };
- update: (...args: [...PathIds, Partial]) => {
- payload: { path: PathIds; item: Partial };
- transitionId: string;
- };
- remove: (...args: PathIds) => { payload: { path: PathIds }; transitionId: string };
+ create: (item: T) => { payload: T; transitionId: string };
+ update: (dto: UpdateDTO) => { payload: UpdateDTO; transitionId: string };
+ remove: (dto: DeleteDTO) => { payload: DeleteDTO; transitionId: string };
};
export function crudPrepare>(
key: keyof T & string,
): {
- create: (item: T) => { payload: { item: T }; transitionId: string };
- update: (id: string, item: Partial) => { payload: { id: string; item: Partial }; transitionId: string };
- remove: (id: string) => { payload: { id: string }; transitionId: string };
+ create: (item: T) => { payload: T; transitionId: string };
+ update: (dto: Partial) => { payload: Partial; transitionId: string };
+ remove: (dto: Partial) => { payload: Partial; transitionId: string };
};
-export function crudPrepare>(key?: keyof T & string) {
+export function crudPrepare>(key?: keyof T & string): any {
if (key !== undefined) {
return {
- create: (item: T) => ({ payload: { item }, transitionId: String(item[key]) }),
- update: (id: string, item: Partial) => ({ payload: { id, item }, transitionId: id }),
- remove: (id: string) => ({ payload: { id }, transitionId: id }),
+ create: (item: T) => ({ payload: item, transitionId: String(item[key]) }),
+ update: (dto: Partial) => ({ payload: dto, transitionId: String(dto[key]) }),
+ remove: (dto: Partial) => ({ payload: dto, transitionId: String(dto[key]) }),
};
}
return (keys: Keys) => ({
create: (item: T) => ({
- payload: { item },
+ payload: item,
transitionId: keys.map((k) => String(item[k])).join('/'),
}),
- update: (...args: [...PathIds, Partial]) => {
- const path = args.slice(0, keys.length) as unknown as PathIds;
- const item = args[keys.length] as Partial;
- return { payload: { path, item }, transitionId: (path as string[]).join('/') };
- },
- remove: (...args: PathIds) => ({
- payload: { path: args },
- transitionId: (args as unknown as string[]).join('/'),
+ update: (dto: Partial) => ({
+ payload: dto,
+ transitionId: keys.map((k) => String(dto[k])).join('/'),
+ }),
+ remove: (dto: Record) => ({
+ payload: dto,
+ transitionId: keys.map((k) => String(dto[k])).join('/'),
}),
});
}
diff --git a/src/actions/types.ts b/src/actions/types.ts
index 2666fa4..2618076 100644
--- a/src/actions/types.ts
+++ b/src/actions/types.ts
@@ -39,3 +39,12 @@ export type TransitionPayloadAction;
export type { PathMap as PathIds } from '~/utils/types';
+
+/** Picks the identity keys from T — the "address" of an entity */
+export type ItemPath = Pick;
+
+/** Partial update DTO: all fields optional, identity keys required */
+export type UpdateDTO = Partial & ItemPath;
+
+/** Delete DTO: just the identity keys */
+export type DeleteDTO = ItemPath;
diff --git a/src/index.ts b/src/index.ts
index 0278ca9..1f0d8e5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,5 @@
-export { optimistron } from './optimistron';
export { createTransition, createTransitions, crudPrepare } from './actions';
+export { optimistron } from './optimistron';
export {
selectAllFailedTransitions,
selectConflictingTransition,
@@ -11,17 +11,18 @@ export {
selectRetryCount,
} from './selectors/selectors';
export { listState } from './state/list';
-export { recordState, nestedRecordState } from './state/record';
+export { nestedRecordState, recordState } from './state/record';
export { singularState } from './state/singular';
-export { TransitionMode, Operation, OptimisticMergeResult, isTransition, retryTransition } from './transitions';
+export { isTransition, Operation, OptimisticMergeResult, retryTransition, TransitionMode } from './transitions';
export type { HandlerReducer, ReducerConfig } from './reducer';
export type { ActionMatcher, BoundStateHandler, CrudActionMap, StateHandler, TransitionState, VersioningOptions, WiredStateHandler } from './state/types';
-export type { RecordStateOptions, NestedRecordStateOptions } from './state/record';
-export type { SingularStateOptions } from './state/singular';
+export type { DeleteDTO, ItemPath, UpdateDTO } from './actions/types';
export type { ListStateOptions } from './state/list';
-export type { StringKeys, PathMap as PathIds, MaybeNull } from './utils/types';
+export type { NestedRecordStateOptions, RecordStateOptions } from './state/record';
+export type { SingularStateOptions } from './state/singular';
+export type { MaybeNull, PathMap as PathIds, StringKeys } from './utils/types';
export type { RecordState as IndexedState, RecursiveRecordState as NestedRecord, PathOf } from './state/record';
-export type { TransitionAction, StagedAction, CommittedAction, Transition } from './transitions';
+export type { CommittedAction, StagedAction, Transition, TransitionAction } from './transitions';
diff --git a/src/optimistron.ts b/src/optimistron.ts
index d1f12e9..d6a1eb1 100644
--- a/src/optimistron.ts
+++ b/src/optimistron.ts
@@ -1,6 +1,5 @@
import type { Action, Reducer } from 'redux';
-import { warn } from './utils/logger';
import { bindReducer, resolveReducer, type BoundReducer, type HandlerReducer, type ReducerConfig } from './reducer';
import { createSelectOptimistic } from './selectors/internal';
import { bindStateFactory, buildTransitionState, transitionStateFactory } from './state/factory';
@@ -15,6 +14,7 @@ import {
toCommit,
type StagedAction,
} from './transitions';
+import { warn } from './utils/logger';
import type { Maybe } from './utils/types';
/** Applies a staged transition as a commit via the bound reducer.
@@ -35,7 +35,7 @@ type OptimistronOptions = {
};
/** Manual mode — full control via a reducer function */
-export function optimistron
(
+export function optimistron(
namespace: string,
initialState: S,
handler: StateHandler,
@@ -44,7 +44,7 @@ export function optimistron;
/** Auto-wire mode — CRUD action map routed via handler's wire method */
-export function optimistron(
+export function optimistron(
namespace: string,
initialState: S,
handler: WiredStateHandler,
@@ -52,7 +52,7 @@ export function optimistron;
-export function optimistron(
+export function optimistron(
namespace: string,
initialState: S,
handler: StateHandler,
diff --git a/src/reducer.ts b/src/reducer.ts
index 85eb959..4671587 100644
--- a/src/reducer.ts
+++ b/src/reducer.ts
@@ -3,19 +3,19 @@ import type { BoundStateHandler, CrudActionMap, StateHandler, TransitionState, W
export type BoundReducer = (state: TransitionState, action: Action) => State;
-export type HandlerReducer = (
- boundStateHandler: BoundStateHandler,
+export type HandlerReducer = (
+ boundStateHandler: BoundStateHandler,
action: Action,
) => State;
/** Consumer-facing reducer config: either a function (manual) or a CRUD map (auto-wired) */
-export type ReducerConfig =
+export type ReducerConfig =
| HandlerReducer
| (CrudActionMap & { reducer?: HandlerReducer });
/** Runtime shape of the CRUD config branch — matches ActionMatcher's runtime interface.
* Type-level safety is enforced at `optimistron()` call sites via overloads. */
-type CrudConfigRuntime = {
+type CrudConfigRuntime = {
create?: { match(action: { type: string; [key: string]: unknown }): boolean };
update?: { match(action: { type: string; [key: string]: unknown }): boolean };
remove?: { match(action: { type: string; [key: string]: unknown }): boolean };
@@ -24,10 +24,10 @@ type CrudConfigRuntime = WiredStateHandler>;
+type WiredHandler = WiredStateHandler>;
/** Resolves a `ReducerConfig` to a `HandlerReducer` — auto-wires CRUD maps via the handler's `wire` method */
-export const resolveReducer = (
+export const resolveReducer = (
handler: StateHandler,
config: ReducerConfig,
): HandlerReducer => {
@@ -49,7 +49,7 @@ export const resolveReducer = (
+ (
reducer: HandlerReducer,
bindState: (state: S) => BoundStateHandler,
): BoundReducer =>
diff --git a/src/state/factory.ts b/src/state/factory.ts
index 10f4f00..082f87d 100644
--- a/src/state/factory.ts
+++ b/src/state/factory.ts
@@ -2,32 +2,34 @@ import type { TransitionAction } from '~/transitions';
import type { BoundStateHandler, StateHandler, TransitionState } from './types';
export const bindStateFactory =
- (
- handler: StateHandler,
- ) =>
- (state: State): BoundStateHandler => ({
- create: (...args: CreateParams) => handler.create(state, ...args),
- update: (...args: UpdateParams) => handler.update(state, ...args),
- remove: (...args: DeleteParams) => handler.remove(state, ...args),
- merge: (incoming: State) => handler.merge(state, incoming),
+ (handler: StateHandler) =>
+ (state: S): BoundStateHandler => ({
+ create: (dto: C) => handler.create(state, dto),
+ update: (dto: U) => handler.update(state, dto),
+ remove: (dto: D) => handler.remove(state, dto),
+ merge: (incoming: S) => handler.merge(state, incoming),
getState: () => state,
});
-export const buildTransitionState = (state: State, transitions: TransitionAction[]): TransitionState => {
- const transitionState = { state } as TransitionState;
+export const buildTransitionState = (state: S, transitions: TransitionAction[]): TransitionState => {
+ const transitionState = { state } as TransitionState;
/* make transitions non-enumerable to avoid consumers
* from unintentionally accessing them when iterating */
Object.defineProperties(transitionState, {
- transitions: { value: transitions, enumerable: false, writable: true },
+ transitions: {
+ value: transitions,
+ enumerable: false,
+ writable: true,
+ },
});
return transitionState;
};
export const transitionStateFactory =
- (prev: TransitionState) =>
- (state: State, transitions: TransitionAction[]): TransitionState => {
+ (prev: TransitionState) =>
+ (state: S, transitions: TransitionAction[]): TransitionState => {
if (state === prev.state && transitions === prev.transitions) return prev;
return buildTransitionState(state, transitions);
};
diff --git a/src/state/list.ts b/src/state/list.ts
index 2643fa0..ec254d0 100644
--- a/src/state/list.ts
+++ b/src/state/list.ts
@@ -1,46 +1,49 @@
-import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types';
-import type { StringKeys } from '~/utils/types';
import { OptimisticMergeResult } from '~/transitions';
+import type { StringKeys } from '~/utils/types';
+import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types';
export type ListStateOptions = VersioningOptions & { key: StringKeys };
-/** Typed CRUD action map for list state */
-type ListCrudMap = CrudActionMap<{ item: T }, { id: string; item: Partial }, { id: string }>;
-
/**
* Creates a `StateHandler` for ordered list state (`T[]`).
* Items are identified by a string key property on `T`.
* Useful when insertion order matters or consumers need array semantics.
+ * Handler types use `Partial` for update/remove DTOs — narrower types
+ * are enforced at dispatch time via `crudPrepare`.
* - `compare` determines if an incoming item is newer/conflicting
- * - `eq` checks deep equality beyond versioning */
+ * - `eq` checks deep equality beyond versioning
+ */
export const listState = >({
key,
compare,
eq,
-}: ListStateOptions): WiredStateHandler], [itemId: string], ListCrudMap> => ({
+}: ListStateOptions): WiredStateHandler, Partial, CrudActionMap, Partial>> => ({
create: (state: T[], item: T) => {
if (state.some((entry) => entry[key] === item[key])) return state;
return [...state, item];
},
- update: (state: T[], itemId: string, partial: Partial) => {
- const idx = state.findIndex((entry) => entry[key as StringKeys] === itemId);
+ update: (state: T[], dto: Partial) => {
+ const itemId = String(dto[key]);
+ const idx = state.findIndex((entry) => entry[key] === itemId);
if (idx === -1) return state;
+
const next = [...state];
- next[idx] = { ...state[idx], ...partial };
+ next[idx] = { ...state[idx], ...dto };
return next;
},
- remove: (state: T[], itemId: string) => {
- const idx = state.findIndex((entry) => entry[key as StringKeys] === itemId);
+ remove: (state: T[], dto: Partial) => {
+ const itemId = String(dto[key]);
+ const idx = state.findIndex((entry) => entry[key] === itemId);
if (idx === -1) return state;
return state.filter((_, i) => i !== idx);
},
wire: (bound, action, actions) => {
- if (actions.create && actions.create.match(action)) return bound.create(action.payload.item);
- if (actions.update && actions.update.match(action)) return bound.update(action.payload.id, action.payload.item);
- if (actions.remove && actions.remove.match(action)) return bound.remove(action.payload.id);
+ if (actions.create?.match(action)) return bound.create(action.payload);
+ if (actions.update?.match(action)) return bound.update(action.payload);
+ if (actions.remove?.match(action)) return bound.remove(action.payload);
return undefined;
},
diff --git a/src/state/record.ts b/src/state/record.ts
index 21050e6..49020a0 100644
--- a/src/state/record.ts
+++ b/src/state/record.ts
@@ -1,9 +1,9 @@
-import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types';
-import type { Maybe, StringKeys } from '~/utils/types';
-import type { PathMap } from '~/utils/types';
-import type { Obj } from '~/utils/path';
-import { getAt, setAt, removeAt } from '~/utils/path';
+import type { DeleteDTO, UpdateDTO } from '~/actions/types';
import { OptimisticMergeResult } from '~/transitions';
+import type { Obj } from '~/utils/path';
+import { getAt, removeAt, setAt } from '~/utils/path';
+import type { Maybe, StringKeys } from '~/utils/types';
+import type { CrudActionMap, VersioningOptions, WiredStateHandler } from '~state/types';
export type RecordStateOptions = VersioningOptions & { key: StringKeys };
export type NestedRecordStateOptions[]> = VersioningOptions & { keys: Keys };
@@ -22,12 +22,6 @@ export type RecursiveRecordState = Keys exten
* `PathOf<['groupId', 'itemId']>` = `{ groupId: string; itemId: string }` */
export type PathOf = { [K in Keys[number]]: string };
-/** Typed CRUD action map for nested record state */
-type NestedRecordCrudMap = CrudActionMap<{ item: T }, { path: PathMap; item: Partial }, { path: PathMap }>;
-
-/** Typed CRUD action map for flat record state */
-type RecordCrudMap = CrudActionMap<{ item: T }, { id: string; item: Partial }, { id: string }>;
-
/**
* Creates a `StateHandler` for nested record-based state.
* Curried to support partial type application — fix `T`, infer `Keys`:
@@ -46,15 +40,15 @@ export const nestedRecordState =
eq,
}: NestedRecordStateOptions): WiredStateHandler<
RecursiveRecordState,
- [item: T],
- [...PathMap, Partial],
- [...PathMap],
- NestedRecordCrudMap
+ T,
+ UpdateDTO,
+ DeleteDTO,
+ CrudActionMap, DeleteDTO>
> => {
type State = RecursiveRecordState;
- /** Extracts path IDs from an item using the keys tuple */
- const extractPath = (item: T): string[] => keys.map((k) => item[k]);
+ /** Extracts path IDs from a DTO using the keys tuple */
+ const extractPath = (dto: Record): string[] => keys.map((k) => String(dto[k]));
/** Recursive merge at a given depth. `depth` counts down from `keys.length`.
* Defers the `{ ...existing }` spread until the first mutation is detected
@@ -117,49 +111,47 @@ export const nestedRecordState =
return {
create: (state, item) => setAt(state, extractPath(item), item),
- update: (state, ...args) => {
- const ids = (args as unknown[]).slice(0, keys.length) as string[];
- const partial = args[keys.length] as Partial;
- const existing = getAt(state, ids) as Maybe;
+ update: (state, dto) => {
+ const path = extractPath(dto);
+ const existing = getAt(state, path) as Maybe;
if (!existing) return state;
- return setAt(state, ids, { ...existing, ...partial });
+ return setAt(state, path, { ...existing, ...dto });
},
- remove: (state, ...args) => {
- const ids = (args as unknown[]).slice(0, keys.length) as string[];
- return removeAt(state, ids);
- },
+ remove: (state, dto) => removeAt(state, extractPath(dto as Record)),
merge: (existing, incoming) => mergeAtDepth(existing, incoming, keys.length) as State,
wire: (bound, action, actions) => {
- if (actions.create && actions.create.match(action)) return bound.create(action.payload.item);
- if (actions.update && actions.update.match(action)) return bound.update(...action.payload.path, action.payload.item);
- if (actions.remove && actions.remove.match(action)) return bound.remove(...action.payload.path);
+ if (actions.create?.match(action)) return bound.create(action.payload);
+ if (actions.update?.match(action)) return bound.update(action.payload);
+ if (actions.remove?.match(action)) return bound.remove(action.payload);
return undefined;
},
};
};
/** Creates a `StateHandler` for a flat record-based state (`Record`).
- * This is a depth-1 specialization of `nestedRecordState`. */
+ * This is a depth-1 specialization of `nestedRecordState`.
+ * Handler types use `Partial` for update/remove DTOs — narrower types
+ * are enforced at dispatch time via `crudPrepare`. */
export const recordState = >({
key,
compare,
eq,
-}: RecordStateOptions): WiredStateHandler, [item: T], [itemId: string, partialItem: Partial], [itemId: string], RecordCrudMap> => {
+}: RecordStateOptions): WiredStateHandler, T, Partial, Partial, CrudActionMap, Partial>> => {
const nested = nestedRecordState()({ keys: [key], compare, eq });
return {
create: nested.create,
merge: nested.merge,
- update: (state, itemId, partialItem) => nested.update(state, itemId, partialItem),
- remove: (state, itemId) => nested.remove(state, itemId),
+ update: (state, dto) => nested.update(state, dto as any),
+ remove: (state, dto) => nested.remove(state, dto as any),
wire: (bound, action, actions) => {
- if (actions.create && actions.create.match(action)) return bound.create(action.payload.item);
- if (actions.update && actions.update.match(action)) return bound.update(action.payload.id, action.payload.item);
- if (actions.remove && actions.remove.match(action)) return bound.remove(action.payload.id);
+ if (actions.create?.match(action)) return bound.create(action.payload);
+ if (actions.update?.match(action)) return bound.update(action.payload);
+ if (actions.remove?.match(action)) return bound.remove(action.payload);
return undefined;
},
};
diff --git a/src/state/singular.ts b/src/state/singular.ts
index a4e7f0c..e061dc8 100644
--- a/src/state/singular.ts
+++ b/src/state/singular.ts
@@ -1,12 +1,9 @@
+import { OptimisticMergeResult } from '~/transitions';
import type { MaybeNull } from '~/utils/types';
import type { CrudActionMap, VersioningOptions, WiredStateHandler } from './types';
-import { OptimisticMergeResult } from '~/transitions';
export type SingularStateOptions = VersioningOptions;
-/** Typed CRUD action map for singular state */
-type SingularCrudMap = CrudActionMap<{ item: T }, { item: Partial }, Record>;
-
/**
* Creates a `StateHandler` for single-object state (`MaybeNull`).
* Suited for cases like user profile, settings, or any singleton entity.
@@ -15,15 +12,15 @@ type SingularCrudMap = CrudActionMap<{ item: T }, { item: Partial }, Recor
export const singularState = ({
compare,
eq,
-}: SingularStateOptions): WiredStateHandler, [item: T], [partial: Partial], [], SingularCrudMap> => ({
+}: SingularStateOptions): WiredStateHandler, T, Partial, void, CrudActionMap, void>> => ({
create: (_: MaybeNull, item: T) => item,
update: (state: MaybeNull, partial: Partial) => (state ? { ...state, ...partial } : state),
remove: (state: MaybeNull) => (state !== null ? null : state),
wire: (bound, action, actions) => {
- if (actions.create && actions.create.match(action)) return bound.create(action.payload.item);
- if (actions.update && actions.update.match(action)) return bound.update(action.payload.item);
- if (actions.remove && actions.remove.match(action)) return bound.remove();
+ if (actions.create?.match(action)) return bound.create(action.payload);
+ if (actions.update?.match(action)) return bound.update(action.payload);
+ if (actions.remove?.match(action)) return bound.remove(undefined as void);
return undefined;
},
diff --git a/src/state/types.ts b/src/state/types.ts
index 51cb144..d69b028 100644
--- a/src/state/types.ts
+++ b/src/state/types.ts
@@ -31,34 +31,31 @@ export type CrudActionMap = {
remove?: ActionMatcher