import { BehaviorSubject, Observable } from 'rxjs'; import { Controller } from './controller'; import { Query, QueryOptions } from './query'; import { mapQuery } from './queryMappers'; import { STORE_EVENT_BUS } from './storeEvents'; import { setInternalStoreFlag, setStateMutationName } from './storeMetadata'; import { DEFAULT_COMPARATOR, isReadonlyArray } from './utils'; let STORE_SEQ_NUMBER = 0; /** * A function to update a state. * * It is recommended to return a new state or the previous one. * * Actually, the function can change the state in place, but it is responsible * for a developer to provide `comparator` function to the store which handles * the changes. * * For making changes use a currying function to provide arguments: * ```ts * const addPizzaToCart = (name: string): StateMutation> => * (state) => ([...state, name]); * ``` * * @param state a previous state * @returns a next state */ export type StateMutation = (state: State) => State; /** * A record of factories which create state mutations. */ export type StateUpdates = Readonly< Record StateMutation> >; /** * Declare a record of factories for creating state mutations. */ export function declareStateUpdates(): < Updates extends StateUpdates = StateUpdates, >( updates: Updates, ) => Updates; /** * Declare a record of factories for creating state mutations. */ export function declareStateUpdates< State, Updates extends StateUpdates = StateUpdates, >(stateExample: State, updates: Updates): Updates; export function declareStateUpdates< State, Updates extends StateUpdates = StateUpdates, >( stateExample?: State, updates?: Updates, ): | Updates | ( = StateUpdates>( updates: Updates, ) => Updates) { if (updates) { return updates; } return (updates) => updates; } /** * Returns a mutation which applies all provided mutations for a state. * * You can use this helper to apply multiple changes at the same time. */ export function pipeStateMutations( mutations: ReadonlyArray>, ): StateMutation { return (state) => mutations.reduce((nextState, mutation) => mutation(nextState), state); } /** * Read-only interface of a store. */ export type StoreQuery = Readonly< Query & { /** * Returns a part of the state as `Observable` * The result observable produces distinct values by default. * * @example * ```ts * const state: StateReader<{form: {login: 'foo'}}> = // ... * const value$ = state.select((state) => state.form.login); * ``` */ select: ( selector: (state: State) => R, options?: QueryOptions, ) => Observable; /** * Returns a part of the state as `Query`. * The result query produces distinct values by default. * * @example * ```ts * const state: StateReader<{form: {login: 'foo'}}> = // ... * const query = state.query((state) => state.form.login); * ``` * */ query: ( selector: (state: State) => R, options?: QueryOptions, ) => Query; /** * Cast the store to a narrowed `Query` type. */ asQuery: () => Query; } >; /** * @internal * Updates the state by provided mutations * */ export type StoreUpdateFunction = ( mutation: | StateMutation | ReadonlyArray | undefined | null | false>, ) => void; /** Function which changes a state of the store */ export type StoreUpdate = (...args: Args) => void; /** Record of store update functions */ export type StoreUpdates< State, Updates extends StateUpdates, > = Readonly<{ [K in keyof Updates]: StoreUpdate>; }>; /** * Store of a state */ export type Store = Controller< StoreQuery & { id: number; name?: string; /** Sets a new state to the store */ set: (state: State) => void; /** Updates the store by provided mutations */ update: StoreUpdateFunction; } >; /** Store of a state with updating functions */ export type StoreWithUpdates< State, Updates extends StateUpdates, > = Readonly & { updates: StoreUpdates }>; type StateMutationQueue = ReadonlyArray< StateMutation | undefined | null | false >; export type StoreOptions = Readonly<{ name?: string; /** A comparator for detecting changes between old and new states */ comparator?: (prevState: State, nextState: State) => boolean; /** Callback is called when the store is destroyed */ onDestroy?: () => void; }>; /** @internal */ export type InternalStoreOptions = Readonly< StoreOptions & { internal?: boolean } >; /** * Creates the state store. * * @param initialState Initial state * @param options Parameters for the store */ export function createStore( initialState: State, options?: StoreOptions, ): Store { const stateComparator = options?.comparator ?? DEFAULT_COMPARATOR; const store$: BehaviorSubject = new BehaviorSubject(initialState); const state$ = store$.asObservable(); let isUpdating = false; let pendingMutations: StateMutationQueue | undefined; const store: Store = { id: ++STORE_SEQ_NUMBER, name: options?.name, value$: state$, get(): State { return store$.value; }, select( selector: (state: State) => R, options?: QueryOptions, ): Observable { return this.query(selector, options).value$; }, query( selector: (state: State) => R, options?: QueryOptions, ): Query { return mapQuery(this, selector, options); }, asQuery: () => store, set(nextState: State) { apply([() => nextState]); }, update, destroy() { store$.complete(); STORE_EVENT_BUS.next({ type: 'destroyed', store }); options?.onDestroy?.(); }, }; function update( mutation: | StateMutation | ReadonlyArray | undefined | null | false>, ) { if (isReadonlyArray(mutation)) { apply(mutation); } else { apply([mutation]); } } function apply(mutations: StateMutationQueue) { if (isUpdating) { pendingMutations = (pendingMutations ?? []).concat(mutations); return; } const prevState = store$.value; let nextState = prevState; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; if (mutation) { const stateBeforeMutation = nextState; nextState = mutation(nextState); STORE_EVENT_BUS.next({ type: 'mutation', store, mutation, nextState, prevState: stateBeforeMutation, }); } } if (stateComparator(prevState, nextState)) { return; } isUpdating = true; store$.next(nextState); STORE_EVENT_BUS.next({ type: 'updated', store, nextState, prevState, }); isUpdating = false; if (pendingMutations?.length) { const mutationsToApply = pendingMutations; pendingMutations = []; apply(mutationsToApply); } } if ((options as InternalStoreOptions | undefined)?.internal) { setInternalStoreFlag(store); } STORE_EVENT_BUS.next({ type: 'created', store }); return store; } /** Creates StateUpdates for updating the store by provided state mutations */ export function createStoreUpdates>( storeUpdate: Store['update'], stateUpdates: Updates, ): StoreUpdates { const updates: any = {}; Object.entries(stateUpdates).forEach(([key, mutationFactory]) => { (updates as any)[key] = (...args: any[]) => { const mutation = mutationFactory(...args); setStateMutationName(mutation, key); storeUpdate(mutation); }; }); return updates; } /** Creates a proxy for the store with "updates" to change a state by provided mutations */ export function withStoreUpdates< State, Updates extends StateUpdates = StateUpdates, >(store: Store, updates: Updates): StoreWithUpdates { const storeUpdates: StoreUpdates = createStoreUpdates< State, Updates >(store.update, updates); return { ...store, updates: storeUpdates }; }