synapse-storage
Version:
Набор инструментов для управления состоянием и апи-запросами
337 lines (336 loc) • 19.5 kB
TypeScript
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>;