UNPKG

synapse-storage

Version:

Набор инструментов для управления состоянием и апи-запросами

337 lines (336 loc) 19.5 kB
import { Observable, OperatorFunction } from 'rxjs'; import { IStorage, IStorageBase } from '../../core'; import { Action, ActionsResult, DispatcherCore, DispatchFunction, ExtractResultType, WatcherFunction } from '../dispatcher'; import { ChunkRequestConsistent, ChunkRequestParallel } from './utils'; /** * Тип действия с типизированным payload */ export interface TypedAction<P> extends Action<P> { type: string; payload: P; } /** * Тип для внешних состояний — Observable или хранилище (IStorageBase), которое автоматически конвертируется в Observable */ export type ExternalStates = Record<string, Observable<any> | IStorageBase<any>>; /** * Контекст эффекта — объект с зависимостями, передаваемый третьим аргументом */ export interface EffectContext<TDispatcher = any, TServices extends Record<string, any> = Record<string, never>, TConfig extends Record<string, any> = Record<string, never>, TExternalDispatchers extends Record<string, DispatcherCore<any, any>> = Record<string, never>, TExternalStates extends ExternalStates = Record<string, never>> { /** Основной dispatcher текущего synapse */ dispatcher: TDispatcher; /** Внешние dispatcher'ы из других synapse */ externalDispatchers: TExternalDispatchers; /** Внешние состояния — Observable'ы от других хранилищ (Synapse.state$, или любой Observable) */ externalStates: TExternalStates; /** Сервисы (API-клиенты и т.д.) */ services: TServices; /** Глобальная конфигурация для эффектов */ config: TConfig; } /** * Тип для эффекта с доступом к состоянию и контексту — основной тип */ export type Effect<TState extends Record<string, any> = any, TDispatcher = any, TServices extends Record<string, any> = Record<string, never>, TConfig extends Record<string, any> = Record<string, never>, TExternalDispatchers extends Record<string, DispatcherCore<any, any>> = Record<string, never>, TExternalStates extends ExternalStates = Record<string, never>> = (action$: Observable<Action>, state$: Observable<TState>, context: EffectContext<TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>) => Observable<unknown>; /** * Опции конкретного эффекта. Прикрепляются к функции-эффекту через {@link EFFECT_OPTIONS} * (это делает `Effects.effect(fn, options)` из базового класса). EffectsModule читает их * при подписке. */ export interface EffectOptions { /** * Переподписаться на поток при непойманной ошибке вместо терминального завершения. * * - `true` — бесконечный немедленный resubscribe; * - `{ count, delay }` — лимит ретраев и задержка (мс) между ними (см. rxjs `retry`). * * По умолчанию (опция не задана) — текущее поведение: ошибка завершает эффект, * остальные продолжают работать. */ resubscribeOnError?: boolean | { count?: number; delay?: number; }; } /** * Symbol-маркер, под которым опции эффекта ({@link EffectOptions}) хранятся на функции-эффекте. * @internal */ export declare const EFFECT_OPTIONS: unique symbol; /** * Symbol-маркер с именем эффекта (имя поля class-слоя `Effects`). Проставляется * `Effects.getEffects()` для диагностики — EffectsModule использует его, чтобы в * предупреждении об упавшем эффекте назвать конкретный эффект. * @internal */ export declare const EFFECT_NAME: unique symbol; /** * Тип для получения типов действий диспетчера */ export type DispatcherActions<T> = T extends DispatcherCore<any, infer A> ? ActionsResult<A> : Record<string, DispatchFunction<any, any>>; /** * Конфигурация для валидации в validateMap */ export interface ValidateConfig { conditions: boolean[]; /** * Что сделать, если валидация не прошла. Необязательно: если не задано — эффект просто * ничего не делает (поток завершается без эмита). Это убирает повторяющийся бойлерплейт * `skipAction: () => d.loadX.reset()` там, где сбрасывать нечего. */ skipAction?: (() => any) | any | ((() => any) | any)[]; } /** * Утилиты для запросов в validateMap */ export interface ValidateMapRequestUtils { chunkRequest: ChunkRequestParallel; chunkRequestConsistent: ChunkRequestConsistent; } /** * Оператор для фильтрации действий по типу с сохранением типа payload */ export declare function ofType<T extends DispatchFunction<any, any> | WatcherFunction<any>>(actionFn: T): OperatorFunction<Action, TypedAction<T extends WatcherFunction<infer R> ? R : ExtractResultType<T>>>; /** * Оператор для фильтрации действий по нескольким типам с объединением типов payload * @param actionFns Массив функций действий */ export declare function ofTypes<T extends DispatchFunction<any, any>[]>(actionFns: [...T]): OperatorFunction<Action, TypedAction<ExtractResultType<T[number]>>>; /** * Оператор для ожидания выполнения всех указанных действий. * * **Важно:** Использует `combineLatest` — Observable не эмитит, пока КАЖДЫЙ из * указанных action не будет диспатчнут хотя бы один раз. Если хотя бы один action * никогда не будет вызван, поток зависнет навсегда без уведомления. * Убедитесь, что все указанные actions гарантированно будут диспатчнуты, * либо используйте `ofTypes` с ручной агрегацией при необходимости таймаута. * * @param actionFns Массив функций действий */ export declare function ofTypesWaitAll<T extends DispatchFunction<any, any>[]>(actionFns: [...T]): (source$: Observable<Action>) => Observable<{ [K in keyof T]: TypedAction<ExtractResultType<T[K]>>; }>; /** * Создает Observable с выбранными данными из состояния * @param state$ Поток состояния * @param selectors Селекторы для выбора частей состояния * @returns Observable с массивом выбранных значений */ export declare function selectorMap<TState, TResults extends any[]>(state$: Observable<TState>, ...selectors: { [K in keyof TResults]: (state: TState) => TResults[K]; }): Observable<TResults>; /** * Создает именованный объект вместо массива * @param state$ Поток состояния * @param selectors Объект с селекторами * @returns Observable с объектом выбранных значений */ export declare function selectorObject<TState, TResult extends Record<string, any>>(state$: Observable<TState>, selectors: { [K in keyof TResult]: (state: TState) => TResult[K]; }): Observable<TResult>; /** * Оператор наложения (flattening) для {@link requestMap} / {@link mutationMap}: задаёт стратегию * конкуренции между перекрывающимися срабатываниями (switchMap / exhaustMap / mergeMap / concatMap). * Сигнатура совпадает с rxjs-операторами, поэтому их можно передавать напрямую. */ export type FlattenOperator = <A, R>(project: (value: A) => Observable<R>) => OperatorFunction<A, R>; /** * Общая конфигурация обработки запроса. Используется и для чтения ({@link validateMap}), * и для записи ({@link mutationMap}) — единый словарь. */ export interface RequestMapConfig<T, Body, TResult> { /** Гейт перед запросом: `conditions` все true → запрос; иначе `skipAction` (или no-op). */ validator?: (value: T) => ValidateConfig; /** * Асинхронная сборка тела запроса ПЕРЕД apiCall (FormData, blob'ы, теги). Результат приходит * вторым аргументом в apiCall. Нет prepare → body = undefined. Для чтения обычно не нужен. */ prepare?: (value: T) => Body | Promise<Body>; /** Вызывается после успешной валидации, перед apiCall. Типичное использование — dispatch loading-статуса. */ loadingAction?: (value: T) => void; /** Вызывается при ошибке в apiCall (catchError). Получает ошибку + те же данные что loadingAction/apiCall. */ errorAction?: (error: any, value: T) => void; /** Сам запрос: из value (+ собранного prepare тела) строим поток. Успех обрабатывается внутри через apiResult. */ apiCall: (value: T, body: Body, utils: ValidateMapRequestUtils) => Observable<TResult>; } /** * Оператор для ЧТЕНИЯ (запросов-ресурсов): валидация → loading → apiCall, стратегия switchMap * («последний выигрывает», отменяет устаревший in-flight запрос). Для записи используйте * {@link mutationMap} — switchMap отменял бы in-flight мутацию (потеря ответа уже закоммиченной * записи, отмена первого сабмита вместо игнора дубля). * * @example * ```ts * action$.pipe( * ofType(d.loadPosts), * validateMap({ * validator: ([, { status }]) => ({ conditions: [status !== ApiStatus.Loading] }), * loadingAction: () => d.loadPosts.loading(), * errorAction: (err) => d.loadPosts.failure(getErrorMessage(err)), * apiCall: ([action]) => * fromRequest(api.getPosts.request(action.payload)).pipe( * apiResult((page) => { d.applyPosts(page); d.loadPosts.success() }), * ), * }), * ) * ``` */ export declare function validateMap<T, TResult = any>(config: { validator?: (value: T) => ValidateConfig; loadingAction?: (value: T) => void; errorAction?: (error: any, value: T) => void; apiCall: (value: T, utils: ValidateMapRequestUtils) => Observable<TResult>; }): OperatorFunction<T, any>; /** * Оператор для ЗАПИСИ (мутаций). Тот же словарь, что у {@link validateMap}, плюс два понятия: * - `flatten` — стратегия конкуренции (rxjs-оператор). У записи нет одного правильного варианта, * поэтому его выбирает вызывающий под смысл операции: * • `exhaustMap` — одиночная операция (форма create/update): дабл-сабмит игнорируется, * in-flight НЕ отменяется; * • `mergeMap` — операции над разными сущностями (delete/toggle/repost): параллельность; * • `concatMap` — строго по очереди. * - `prepare` — асинхронная сборка тела (FormData, blob'ы) перед запросом; результат приходит * вторым аргументом в apiCall. * * Успех/статусы ведутся ВНУТРИ apiCall через apiResult — единообразно с {@link validateMap}. * * @example * ```ts * action$.pipe( * ofType(d.createPost), * mutationMap({ * flatten: exhaustMap, * loadingAction: () => d.createPost.loading(), * errorAction: (err) => d.createPost.failure(getErrorMessage(err)), * prepare: (payload) => buildCreateBody(api, payload), * apiCall: (_payload, body) => * fromRequest(api.createPost.request({ body })).pipe( * apiResult((post) => { d.createPost.success(); d.prependPost(post) }), * ), * }), * ) * ``` */ export declare function mutationMap<T, Body = void, TResult = any>({ flatten, validator, prepare, loadingAction, errorAction, apiCall, }: { flatten: FlattenOperator; } & RequestMapConfig<T, Body, TResult>): OperatorFunction<T, any>; /** * Метаданные ответа API, доступные в колбэках apiResult. */ export interface ApiResultMeta { status: number; statusText: string; headers: Headers; fromCache?: boolean; } /** * Ошибка API-запроса. Бросается apiResult при !result.ok. * Ловится errorAction в validateMap. */ export declare class ApiError extends Error { readonly originalError: any; readonly meta: ApiResultMeta; constructor(originalError: any, meta: ApiResultMeta); } /** * Оператор для обработки успешного результата API-запроса (QueryResult). * * При `result.ok` — вызывает callback с `data` и `meta`. * При `!result.ok` — бросает `ApiError`, который ловится `errorAction` в `validateMap`. * * @example * ```ts * // Простой случай * validateMap({ * errorAction: (err) => dispatcher.dispatch.loadError(String(err)), * apiCall: () => from(api.request('getList', params)).pipe( * apiResult((data) => dispatcher.dispatch.loadSuccess(data)), * ), * }) * * // С доступом к headers (пагинация) * apiResult((data, meta) => { * const total = Number(meta.headers.get('X-Total-Count')) * dispatcher.dispatch.loadSuccess({ items: data, total }) * }) * ``` */ export declare function apiResult<TData, TResult = void>(onSuccess: (data: TData, meta: ApiResultMeta) => TResult | Promise<TResult>): OperatorFunction<{ ok: boolean; data?: TData; error?: any; status?: number; statusText?: string; headers?: Headers; fromCache?: boolean; }, TResult>; /** * Класс для управления эффектами с поддержкой доступа к состоянию и контексту * Основной класс, который следует использовать */ export declare class EffectsModule<TState extends Record<string, any> = any, TDispatcher = any, TServices extends Record<string, any> = Record<string, never>, TConfig extends Record<string, any> = Record<string, never>, TExternalDispatchers extends Record<string, DispatcherCore<any, any>> = Record<string, never>, TExternalStates extends ExternalStates = Record<string, never>> { private storage; private dispatcher; private externalDispatchers; private services; private config; private effects; private subscriptions; private running; private action$; private externalStates; /** * Поток состояния */ readonly state$: Observable<TState>; /** * Создает модуль эффектов * @param storage Хранилище состояния * @param dispatcher Основной dispatcher текущего synapse * @param externalDispatchers Внешние dispatcher'ы из других synapse * @param services Сервисы (API-клиенты и т.д.) * @param config Глобальная конфигурация для всех эффектов * @param externalStates Внешние состояния (Observable'ы от других хранилищ) */ constructor(storage: IStorage<TState>, dispatcher: TDispatcher & { actions: Observable<Action>; }, externalDispatchers?: TExternalDispatchers, services?: TServices, config?: TConfig, externalStates?: TExternalStates); /** * Нормализует externalStates: конвертирует IStorageBase в Observable, пропускает Observable как есть */ private normalizeExternalStates; /** * Подписывается на действия от основного dispatcher'а и внешних dispatcher'ов */ private subscribeToDispatchers; add(effect: Effect<TState, TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>): this; /** * Добавляет несколько эффектов * @param effects Эффекты для добавления * @returns Текущий модуль */ addEffects(effects: Effect<TState, TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>[]): this; /** * Запускает все эффекты * @returns Текущий модуль */ start(): Promise<this>; /** * Останавливает все эффекты * @returns Текущий модуль */ stop(): this; /** * Подписывается на конкретный эффект * @param effect Эффект для подписки */ private subscribeToEffect; } /** * Вспомогательная функция для создания типизированного эффекта */ export declare function createEffect<TState extends Record<string, any>, TDispatcher = any, TServices extends Record<string, any> = Record<string, never>, TConfig extends Record<string, any> = Record<string, never>, TExternalDispatchers extends Record<string, DispatcherCore<any, any>> = Record<string, never>, TExternalStates extends ExternalStates = Record<string, never>>(effect: Effect<TState, TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>): Effect<TState, TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>; /** * Объединяет несколько эффектов в один * @param effects Эффекты для объединения * @returns Объединенный эффект */ export declare function combineEffects<TState extends Record<string, any>, TDispatcher = any, TServices extends Record<string, any> = Record<string, never>, TConfig extends Record<string, any> = Record<string, never>, TExternalDispatchers extends Record<string, DispatcherCore<any, any>> = Record<string, never>, TExternalStates extends ExternalStates = Record<string, never>>(...effects: Effect<TState, TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>[]): Effect<TState, TDispatcher, TServices, TConfig, TExternalDispatchers, TExternalStates>;