import { Action, AnyAction, MutableStore, ModelActionContext, ModelActionMap, ModelEffectsMap, ModelWithMethods, MapActionsUnion, Equality, ModelEffectsContext, ModelActionCreators, Domain, StoreContext, Dispatch, TaskHelper, TaskOptions, ActionMatcher, } from "../types"; import { isPromiseLike } from "../utils"; import { withUse } from "../withUse"; /** * Internal type for a registered fallback handler. */ interface FallbackEntry { matchers: ActionMatcher[] | null; // null = catch-all handler: (state: TState, action: Action) => TState; } /** * Create action creators and reducer from an action map. * Internal helper used by model(). */ function createModelActions>( actionMap: TActionMap, fallbackEntries: FallbackEntry[] ) { // Create action creators const actionCreators: Record any> = {}; for (const key of Object.keys(actionMap)) { actionCreators[key] = (...args: any[]) => ({ type: key, args, }); } // Create reducer that handles both action map and fallbacks const reducer = ( state: TState, action: MapActionsUnion | AnyAction ): TState => { // First, try action map handlers const handler = actionMap[action.type]; if (handler) { return handler(state, ...(action as any).args); } // Then, run ALL matched fallback handlers in sequence let currentState = state; for (const entry of fallbackEntries) { // null matchers = catch-all if (entry.matchers === null) { currentState = entry.handler(currentState, action as Action); } else { // Check if any matcher matches const matches = entry.matchers.some((m) => m.match(action as Action)); if (matches) { currentState = entry.handler(currentState, action as Action); } } } return currentState; }; return { actionCreators, reducer }; } /** * Create a model from a store, action map, and optional effects map. * Model IS the store with bound action/effect methods attached. * This means models can be used anywhere a store is expected (useSelector, derived, etc.) */ export function createModel< TState, TActionMap extends ModelActionMap, TEffectsMap extends ModelEffectsMap< TState, MapActionsUnion > >( store: MutableStore>, actionCreators: Record any>, effectsMap: TEffectsMap = {} as TEffectsMap ): ModelWithMethods { // Bind actions to store.dispatch const boundActions: Record void> = {}; for (const [key, creator] of Object.entries(actionCreators)) { boundActions[key] = (...args: any[]) => { store.dispatch(creator(...args)); }; } // Effects are already bound (context captured in closure) // Just attach them directly to the model // Model IS the store with bound methods attached // Spread store properties + bound actions + effects const model = withUse({ ...store, ...boundActions, ...effectsMap, }); return model as ModelWithMethods; } /** * Create the action builder context with helpers and fallback entry collection. */ export function createActionContext(initial: TState): { ctx: ModelActionContext; entries: FallbackEntry[]; } { const entries: FallbackEntry[] = []; const ctx: ModelActionContext = { reducers: { reset: () => initial, set: (_, value: TState) => value, }, on: ( actionOrHandler: | ActionMatcher | ActionMatcher[] | ((state: TState, action: AnyAction) => TState), handler?: (state: TState, action: Action) => TState ): void => { // Overload 1: catch-all handler (single function argument) if (typeof actionOrHandler === "function" && !handler) { entries.push({ matchers: null, handler: actionOrHandler as (state: TState, action: Action) => TState, }); return; } // Overload 2 & 3: action matcher(s) + handler const matchers = Array.isArray(actionOrHandler) ? actionOrHandler : [actionOrHandler as ActionMatcher]; entries.push({ matchers, handler: handler!, }); }, }; return { ctx, entries }; } /** * Create a task helper that wraps async operations with lifecycle dispatching. * Callbacks can return Action (auto-dispatched) or void (listener only). * Accepts any PromiseLike (native Promises, Bluebird, jQuery Deferreds, etc.) */ function createTaskHelper(dispatch: (action: Action) => void): TaskHelper { // Helper to dispatch only if callback returns an action const maybeDispatch = (action: Action | void) => { if (action) dispatch(action); }; return (( promiseOrFn: | PromiseLike | ((...args: TArgs) => PromiseLike), options: TaskOptions ): any => { const { start, done, fail, end } = options; // If it's a thenable, wrap it directly if (isPromiseLike(promiseOrFn)) { if (start) maybeDispatch(start()); return Promise.resolve(promiseOrFn) .then((result) => { if (done) maybeDispatch(done(result)); if (end) maybeDispatch(end(undefined, result)); return result; }) .catch((error) => { if (fail) maybeDispatch(fail(error)); if (end) maybeDispatch(end(error, undefined)); throw error; // Re-throw }); } // If it's a function, return wrapped function with same signature return (...args: TArgs): Promise => { if (start) maybeDispatch(start()); return Promise.resolve(promiseOrFn(...args)) .then((result) => { if (done) maybeDispatch(done(result)); if (end) maybeDispatch(end(undefined, result)); return result; }) .catch((error) => { if (fail) maybeDispatch(fail(error)); if (end) maybeDispatch(end(error, undefined)); throw error; // Re-throw }); }; }) as TaskHelper; } /** * Configuration for buildModel. */ export interface BuildModelConfig< TState, TActionMap extends ModelActionMap, TEffectsMap > { name: string; initial: TState; actions: (ctx: ModelActionContext) => TActionMap; effects?: ( ctx: ModelEffectsContext< TState, TActionMap, MapActionsUnion > ) => TEffectsMap; equals?: Equality; /** Parent domain - passed internally by domain.model() */ domain: Domain; } /** * Build a model from the domain.model() call. * This is the main entry point called from domain.ts. */ export function buildModel< TState, TActionMap extends ModelActionMap, TEffectsMap extends ModelEffectsMap< TState, MapActionsUnion > >( config: BuildModelConfig ): ModelWithMethods { const { name, initial, actions: actionBuilder, effects: effectsBuilder, equals, domain, } = config; const createStore = ( reducer: (state: TState, action: any) => TState ): MutableStore => domain.store({ name, initial, reducer, equals }); // 1. Create action context with helpers (also collects fallback entries via ctx.on()) const { ctx: actionCtx, entries: fallbackEntries } = createActionContext(initial); // 2. Build action map from builder (ctx.on() calls populate fallbackEntries) const actionMap = actionBuilder(actionCtx); // 3. Create action creators and reducer (with fallback entries) const { actionCreators, reducer } = createModelActions( actionMap, fallbackEntries ); // 6. Create the store with optional equality const store = createStore(reducer); // 7. Create task helper with dispatch const task = createTaskHelper(store.dispatch); // 8. Create effects context with full context for closure capture const effectsContext: ModelEffectsContext< TState, TActionMap, MapActionsUnion > = { task, actions: actionCreators as ModelActionCreators, initial, dispatch: store.dispatch as Dispatch< StoreContext>, MapActionsUnion >, getState: store.getState, domain, }; // 9. Get effects map if provided (effects capture context in closure) const effectsMap = effectsBuilder?.(effectsContext) ?? ({} as TEffectsMap); // 10. Create and return the model return createModel( store, actionCreators, effectsMap ); }