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
406 changes: 213 additions & 193 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

306 changes: 121 additions & 185 deletions README.md

Large diffs are not rendered by default.

44 changes: 20 additions & 24 deletions src/actions/crud.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { PathIds } from './types';
import type { DeleteDTO, UpdateDTO } from './types';

/**
* 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.
*
* Single-key overload (unchanged):
* Single-key overload:
* ```ts
* const crud = crudPrepare<Item>('id');
* ```
Expand All @@ -24,44 +24,40 @@ import type { PathIds } from './types';
export function crudPrepare<T extends Record<string, any>>(): <const Keys extends readonly [keyof T & string, ...(keyof T & string)[]]>(
keys: Keys,
) => {
create: (item: T) => { payload: { item: T }; transitionId: string };
update: (...args: [...PathIds<Keys>, Partial<T>]) => {
payload: { path: PathIds<Keys>; item: Partial<T> };
transitionId: string;
};
remove: (...args: PathIds<Keys>) => { payload: { path: PathIds<Keys> }; transitionId: string };
create: (item: T) => { payload: T; transitionId: string };
update: (dto: UpdateDTO<T, Keys>) => { payload: UpdateDTO<T, Keys>; transitionId: string };
remove: (dto: DeleteDTO<T, Keys>) => { payload: DeleteDTO<T, Keys>; transitionId: string };
};

export function crudPrepare<T extends Record<string, any>>(
key: keyof T & string,
): {
create: (item: T) => { payload: { item: T }; transitionId: string };
update: (id: string, item: Partial<T>) => { payload: { id: string; item: Partial<T> }; transitionId: string };
remove: (id: string) => { payload: { id: string }; transitionId: string };
create: (item: T) => { payload: T; transitionId: string };
update: (dto: Partial<T>) => { payload: Partial<T>; transitionId: string };
remove: (dto: Partial<T>) => { payload: Partial<T>; transitionId: string };
};

export function crudPrepare<T extends Record<string, any>>(key?: keyof T & string) {
export function crudPrepare<T extends Record<string, any>>(key?: keyof T & string): any {
if (key !== undefined) {
return {
create: (item: T) => ({ payload: { item }, transitionId: String(item[key]) }),
update: (id: string, item: Partial<T>) => ({ payload: { id, item }, transitionId: id }),
remove: (id: string) => ({ payload: { id }, transitionId: id }),
create: (item: T) => ({ payload: item, transitionId: String(item[key]) }),
update: (dto: Partial<T>) => ({ payload: dto, transitionId: String(dto[key]) }),
remove: (dto: Partial<T>) => ({ payload: dto, transitionId: String(dto[key]) }),
};
}

return <const Keys extends readonly [keyof T & string, ...(keyof T & string)[]]>(keys: Keys) => ({
create: (item: T) => ({
payload: { item },
payload: item,
transitionId: keys.map((k) => String(item[k])).join('/'),
}),
update: (...args: [...PathIds<Keys>, Partial<T>]) => {
const path = args.slice(0, keys.length) as unknown as PathIds<Keys>;
const item = args[keys.length] as Partial<T>;
return { payload: { path, item }, transitionId: (path as string[]).join('/') };
},
remove: (...args: PathIds<Keys>) => ({
payload: { path: args },
transitionId: (args as unknown as string[]).join('/'),
update: (dto: Partial<T>) => ({
payload: dto,
transitionId: keys.map((k) => String(dto[k])).join('/'),
}),
remove: (dto: Record<string, any>) => ({
payload: dto,
transitionId: keys.map((k) => String(dto[k])).join('/'),
}),
});
}
9 changes: 9 additions & 0 deletions src/actions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,12 @@ export type TransitionPayloadAction<Type extends string, Op extends Operation, P
>;

export type { PathMap as PathIds } from '~/utils/types';

/** Picks the identity keys from T — the "address" of an entity */
export type ItemPath<T, Keys extends readonly (keyof T & string)[]> = Pick<T, Keys[number]>;

/** Partial update DTO: all fields optional, identity keys required */
export type UpdateDTO<T, Keys extends readonly (keyof T & string)[]> = Partial<T> & ItemPath<T, Keys>;

/** Delete DTO: just the identity keys */
export type DeleteDTO<T, Keys extends readonly (keyof T & string)[]> = ItemPath<T, Keys>;
15 changes: 8 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { optimistron } from './optimistron';
export { createTransition, createTransitions, crudPrepare } from './actions';
export { optimistron } from './optimistron';
export {
selectAllFailedTransitions,
selectConflictingTransition,
Expand All @@ -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';
8 changes: 4 additions & 4 deletions src/optimistron.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -35,7 +35,7 @@ type OptimistronOptions = {
};

/** Manual mode — full control via a reducer function */
export function optimistron<S, C extends unknown[], U extends unknown[], D extends unknown[]>(
export function optimistron<S, C, U, D>(
namespace: string,
initialState: S,
handler: StateHandler<S, C, U, D>,
Expand All @@ -44,15 +44,15 @@ export function optimistron<S, C extends unknown[], U extends unknown[], D exten
): OptimistronResult<S>;

/** Auto-wire mode — CRUD action map routed via handler's wire method */
export function optimistron<S, C extends unknown[], U extends unknown[], D extends unknown[], A>(
export function optimistron<S, C, U, D, A>(
namespace: string,
initialState: S,
handler: WiredStateHandler<S, C, U, D, A>,
config: A & { reducer?: HandlerReducer<S, C, U, D> },
options?: OptimistronOptions,
): OptimistronResult<S>;

export function optimistron<S, C extends unknown[], U extends unknown[], D extends unknown[]>(
export function optimistron<S, C, U, D>(
namespace: string,
initialState: S,
handler: StateHandler<S, C, U, D>,
Expand Down
14 changes: 7 additions & 7 deletions src/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import type { BoundStateHandler, CrudActionMap, StateHandler, TransitionState, W

export type BoundReducer<State = any> = (state: TransitionState<State>, action: Action) => State;

export type HandlerReducer<State, CreateParams extends unknown[], UpdateParams extends unknown[], DeleteParams extends unknown[]> = (
boundStateHandler: BoundStateHandler<State, CreateParams, UpdateParams, DeleteParams>,
export type HandlerReducer<State, C = any, U = any, D = any> = (
boundStateHandler: BoundStateHandler<State, C, U, D>,
action: Action,
) => State;

/** Consumer-facing reducer config: either a function (manual) or a CRUD map (auto-wired) */
export type ReducerConfig<S, C extends unknown[], U extends unknown[], D extends unknown[]> =
export type ReducerConfig<S, C = any, U = any, D = any> =
| HandlerReducer<S, C, U, D>
| (CrudActionMap & { reducer?: HandlerReducer<S, C, U, D> });

/** 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<S, C extends unknown[], U extends unknown[], D extends unknown[]> = {
type CrudConfigRuntime<S, C, U, D> = {
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 };
Expand All @@ -24,10 +24,10 @@ type CrudConfigRuntime<S, C extends unknown[], U extends unknown[], D extends un

/** Narrows CrudConfigRuntime into the WiredStateHandler's Actions param
* after the `'wire' in handler` runtime check */
type WiredHandler<S, C extends unknown[], U extends unknown[], D extends unknown[]> = WiredStateHandler<S, C, U, D, CrudConfigRuntime<S, C, U, D>>;
type WiredHandler<S, C, U, D> = WiredStateHandler<S, C, U, D, CrudConfigRuntime<S, C, U, D>>;

/** Resolves a `ReducerConfig` to a `HandlerReducer` — auto-wires CRUD maps via the handler's `wire` method */
export const resolveReducer = <S, C extends unknown[], U extends unknown[], D extends unknown[]>(
export const resolveReducer = <S, C, U, D>(
handler: StateHandler<S, C, U, D>,
config: ReducerConfig<S, C, U, D>,
): HandlerReducer<S, C, U, D> => {
Expand All @@ -49,7 +49,7 @@ export const resolveReducer = <S, C extends unknown[], U extends unknown[], D ex
};

export const bindReducer =
<S, C extends unknown[], U extends unknown[], D extends unknown[]>(
<S, C, U, D>(
reducer: HandlerReducer<S, C, U, D>,
bindState: (state: S) => BoundStateHandler<S, C, U, D>,
): BoundReducer<S> =>
Expand Down
28 changes: 15 additions & 13 deletions src/state/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,34 @@ import type { TransitionAction } from '~/transitions';
import type { BoundStateHandler, StateHandler, TransitionState } from './types';

export const bindStateFactory =
<State, CreateParams extends unknown[], UpdateParams extends unknown[], DeleteParams extends unknown[]>(
handler: StateHandler<State, CreateParams, UpdateParams, DeleteParams>,
) =>
(state: State): BoundStateHandler<State, CreateParams, UpdateParams, DeleteParams> => ({
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),
<S, C, U, D>(handler: StateHandler<S, C, U, D>) =>
(state: S): BoundStateHandler<S, C, U, D> => ({
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: State, transitions: TransitionAction[]): TransitionState<State> => {
const transitionState = { state } as TransitionState<State>;
export const buildTransitionState = <S>(state: S, transitions: TransitionAction[]): TransitionState<S> => {
const transitionState = { state } as TransitionState<S>;

/* 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 =
<State>(prev: TransitionState<State>) =>
(state: State, transitions: TransitionAction[]): TransitionState<State> => {
<S>(prev: TransitionState<S>) =>
(state: S, transitions: TransitionAction[]): TransitionState<S> => {
if (state === prev.state && transitions === prev.transitions) return prev;
return buildTransitionState(state, transitions);
};
33 changes: 18 additions & 15 deletions src/state/list.ts
Original file line number Diff line number Diff line change
@@ -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<T> = VersioningOptions<T> & { key: StringKeys<T> };

/** Typed CRUD action map for list state */
type ListCrudMap<T> = CrudActionMap<{ item: T }, { id: string; item: Partial<T> }, { 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<T>` 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 = <T extends Record<string, any>>({
key,
compare,
eq,
}: ListStateOptions<T>): WiredStateHandler<T[], [item: T], [itemId: string, partial: Partial<T>], [itemId: string], ListCrudMap<T>> => ({
}: ListStateOptions<T>): WiredStateHandler<T[], T, Partial<T>, Partial<T>, CrudActionMap<T, Partial<T>, Partial<T>>> => ({
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<T>) => {
const idx = state.findIndex((entry) => entry[key as StringKeys<T>] === itemId);
update: (state: T[], dto: Partial<T>) => {
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<T>] === itemId);
remove: (state: T[], dto: Partial<T>) => {
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;
},

Expand Down
Loading