synapse-storage
Version:
Набор инструментов для управления состоянием и апи-запросами
468 lines (457 loc) • 22.9 kB
JavaScript
import { EMPTY, Observable, Subject, combineLatest, from, merge, of, pipe } from "rxjs";
import { catchError, filter, map, mergeMap, retry, share, switchMap, take } from "rxjs/operators";
import { handleCallbackError, logError } from "../../_utils/error-handling.util.js";
import { chunkRequestConsistent, chunkRequestParallel, isStorage, toObservable } from "./utils/index.js";
/**
* Symbol-маркер, под которым опции эффекта ({@link EffectOptions}) хранятся на функции-эффекте.
* @internal
*/ const EFFECT_OPTIONS = Symbol('synapse.effect.options');
/**
* Symbol-маркер с именем эффекта (имя поля class-слоя `Effects`). Проставляется
* `Effects.getEffects()` для диагностики — EffectsModule использует его, чтобы в
* предупреждении об упавшем эффекте назвать конкретный эффект.
* @internal
*/ const EFFECT_NAME = Symbol('synapse.effect.name');
/**
* Оператор для фильтрации действий по типу с сохранением типа payload
*/ function ofType(actionFn) {
const { actionType } = actionFn;
if (!actionType) {
logError('ofType: action function does not have actionType property', actionFn, null, 'warn');
return filter(()=>false);
}
// Улучшенная реализация с явными типами
return (source$)=>{
return source$.pipe(filter((action)=>action !== undefined && action.type === actionType));
};
}
/**
* Оператор для фильтрации действий по нескольким типам с объединением типов payload
* @param actionFns Массив функций действий
*/ function ofTypes(actionFns) {
// Получаем типы действий
const actionTypes = actionFns.map((fn)=>fn.actionType).filter(Boolean);
if (actionTypes.length === 0) {
logError('ofTypes: no valid action types found in array', actionFns, null, 'warn');
return filter(()=>false);
}
// Улучшенная реализация с явными типами
return (source$)=>{
return source$.pipe(filter((action)=>action !== undefined && actionTypes.includes(action.type)));
};
}
/**
* Оператор для ожидания выполнения всех указанных действий.
*
* **Важно:** Использует `combineLatest` — Observable не эмитит, пока КАЖДЫЙ из
* указанных action не будет диспатчнут хотя бы один раз. Если хотя бы один action
* никогда не будет вызван, поток зависнет навсегда без уведомления.
* Убедитесь, что все указанные actions гарантированно будут диспатчнуты,
* либо используйте `ofTypes` с ручной агрегацией при необходимости таймаута.
*
* @param actionFns Массив функций действий
*/ function ofTypesWaitAll(actionFns) {
return (source$)=>{
// Создаем потоки для каждого типа действия
const actionTypes = actionFns.map((fn)=>fn.actionType).filter(Boolean);
if (actionTypes.length === 0) {
logError('ofTypesWaitAll: no valid action types found in array', actionFns, null, 'warn');
return of([]);
}
// Для каждого типа действия создаем поток,
// который берет первое срабатывание
const actionStreams = actionTypes.map((type, index)=>source$.pipe(filter((action)=>action.type === type), take(1), map((action)=>// Сохраняем ассоциацию с индексом, чтобы соответствовать
// порядку в исходном массиве actionFns
({
index,
action
}))));
// Ждем, пока все потоки выдадут значения, и сортируем результаты
// по индексу для сохранения порядка
return combineLatest(actionStreams).pipe(map((results)=>{
// Сортируем по индексу
results.sort((a, b)=>a.index - b.index);
// Убираем индекс и возвращаем только действия
return results.map((r)=>r.action);
}));
};
}
/**
* Создает Observable с выбранными данными из состояния
* @param state$ Поток состояния
* @param selectors Селекторы для выбора частей состояния
* @returns Observable с массивом выбранных значений
*/ function selectorMap(state$, ...selectors) {
return state$.pipe(map((state)=>{
return selectors.map((selector)=>selector(state));
}));
}
/**
* Создает именованный объект вместо массива
* @param state$ Поток состояния
* @param selectors Объект с селекторами
* @returns Observable с объектом выбранных значений
*/ function selectorObject(state$, selectors) {
return state$.pipe(map((state)=>{
const result = {};
for (const [key, selector] of Object.entries(selectors)){
result[key] = selector(state);
}
return result;
}));
}
/**
* Общее ядро обработки запроса. Накладывает на поток триггеров единый пайп:
* [validator] → loadingAction → [prepare] → apiCall → (apiResult success) / errorAction.
*
* Стратегию конкуренции задаёт `flatten`:
* - `switchMap` — последний выигрывает, отменяет in-flight (ЧТЕНИЕ, см. {@link validateMap});
* - `exhaustMap` — одиночная операция, дабл-сабмит игнорируется, in-flight НЕ отменяется (формы);
* - `mergeMap` — независимые операции над разными сущностями (реальная параллельность);
* - `concatMap` — строго по очереди.
*
* catchError стоит ВНУТРИ проекции flatten — ошибка одного запроса не валит весь поток эффекта
* (важно для mergeMap: падение одного удаления не убивает остальные).
* @internal
*/ function requestMap(flatten, { validator, prepare, loadingAction, errorAction, apiCall }) {
return pipe(flatten((pipeData)=>{
/**
* Функция вызова API-метода
*/ const callApi = ()=>{
if (loadingAction) loadingAction(pipeData);
// нет prepare → пустое тело; иначе резол body (sync/async) перед запросом
const body$ = prepare ? from(Promise.resolve(prepare(pipeData))) : of(undefined);
const apiCall$ = body$.pipe(mergeMap((body)=>apiCall(pipeData, body, {
chunkRequest: chunkRequestParallel,
chunkRequestConsistent: chunkRequestConsistent
})));
if (!errorAction) return apiCall$;
return apiCall$.pipe(catchError((err)=>{
errorAction(err, pipeData);
return EMPTY;
}));
};
/**
* Если валидацию не используем - сразу вызываем запрос
*/ if (!validator) return callApi();
const validateConfig = validator(pipeData);
const { conditions, skipAction } = validateConfig;
const conditionMet = conditions.every(Boolean);
/**
* Если валидация не пройдена - вызываем экшн сброса.
* skipAction не задан → ничего не делаем (no-op по умолчанию).
*/ if (!conditionMet) {
if (skipAction === undefined) return EMPTY;
if (Array.isArray(skipAction)) {
return of(...skipAction.filter(Boolean).map((action)=>typeof action === 'function' ? action() : action));
}
return of(typeof skipAction === 'function' ? skipAction() : skipAction);
}
return callApi();
}));
}
/**
* Оператор для ЧТЕНИЯ (запросов-ресурсов): валидация → 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() }),
* ),
* }),
* )
* ```
*/ function validateMap(config) {
return requestMap(switchMap, {
validator: config.validator,
loadingAction: config.loadingAction,
errorAction: config.errorAction,
// adapter: публичный apiCall чтения принимает (value, utils); ядро зовёт (value, body, utils)
apiCall: (value, _body, utils)=>config.apiCall(value, utils)
});
}
/**
* Оператор для ЗАПИСИ (мутаций). Тот же словарь, что у {@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) }),
* ),
* }),
* )
* ```
*/ function mutationMap({ flatten, validator, prepare, loadingAction, errorAction, apiCall }) {
return requestMap(flatten, {
validator,
prepare,
loadingAction,
errorAction,
apiCall
});
}
/**
* Ошибка API-запроса. Бросается apiResult при !result.ok.
* Ловится errorAction в validateMap.
*/ class ApiError extends Error {
originalError;
meta;
constructor(originalError, meta){
super(typeof originalError === 'string' ? originalError : originalError?.message ?? 'API request failed'), this.originalError = originalError, this.meta = meta;
this.name = 'ApiError';
}
}
/**
* Оператор для обработки успешного результата 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 })
* })
* ```
*/ function apiResult(onSuccess) {
return pipe(switchMap((result)=>{
const meta = {
status: result.status ?? 0,
statusText: result.statusText ?? '',
headers: result.headers ?? new Headers(),
fromCache: result.fromCache
};
if (result.ok && result.data !== undefined) {
const out = onSuccess(result.data, meta);
return from(Promise.resolve(out));
}
throw new ApiError(result.error ?? 'Unknown error', meta);
}));
}
/**
* Класс для управления эффектами с поддержкой доступа к состоянию и контексту
* Основной класс, который следует использовать
*/ class EffectsModule {
storage;
dispatcher;
externalDispatchers;
services;
config;
effects = [];
subscriptions = [];
running = false;
action$ = new Subject();
externalStates;
/**
* Поток состояния
*/ state$;
/**
* Создает модуль эффектов
* @param storage Хранилище состояния
* @param dispatcher Основной dispatcher текущего synapse
* @param externalDispatchers Внешние dispatcher'ы из других synapse
* @param services Сервисы (API-клиенты и т.д.)
* @param config Глобальная конфигурация для всех эффектов
* @param externalStates Внешние состояния (Observable'ы от других хранилищ)
*/ constructor(storage, dispatcher, externalDispatchers = {}, services = {}, config = {}, externalStates = {}){
this.storage = storage;
this.dispatcher = dispatcher;
this.externalDispatchers = externalDispatchers;
this.services = services;
this.config = config;
// Нормализуем externalStates: конвертируем storage → Observable
this.externalStates = this.normalizeExternalStates(externalStates);
// Создаем поток состояния
this.state$ = new Observable((observer)=>{
// Отправляем начальное состояние
Promise.resolve(this.storage.getState()).then((state)=>observer.next(state));
// Подписываемся на все изменения
const unsubscribe = this.storage.subscribeToAll(()=>{
Promise.resolve(this.storage.getState()).then((state)=>observer.next(state));
});
// Отписываемся при завершении
return ()=>unsubscribe();
}).pipe(share());
}
/**
* Нормализует externalStates: конвертирует IStorageBase в Observable, пропускает Observable как есть
*/ normalizeExternalStates(states) {
const normalized = {};
for (const [key, value] of Object.entries(states)){
normalized[key] = isStorage(value) ? toObservable(value) : value;
}
return normalized;
}
/**
* Подписывается на действия от основного dispatcher'а и внешних dispatcher'ов
*/ subscribeToDispatchers() {
// Основной dispatcher
const mainSub = this.dispatcher.actions.subscribe((action)=>{
this.action$.next(action);
});
this.subscriptions.push(mainSub);
// Внешние dispatcher'ы
for (const [_, dispatcher] of Object.entries(this.externalDispatchers)){
const subscription = dispatcher.actions.subscribe((action)=>{
this.action$.next(action);
});
this.subscriptions.push(subscription);
}
}
add(effect) {
this.effects.push(effect);
if (this.running) {
this.subscribeToEffect(effect, this.effects.length - 1);
}
return this;
}
/**
* Добавляет несколько эффектов
* @param effects Эффекты для добавления
* @returns Текущий модуль
*/ addEffects(effects) {
effects.forEach((effect)=>this.add(effect));
return this;
}
/**
* Запускает все эффекты
* @returns Текущий модуль
*/ async start() {
if (this.running) {
return this;
}
// Ждем готовности основного хранилища
await this.storage.waitForReady();
// Переподписываемся на dispatchers (подписки были очищены в stop())
this.subscribeToDispatchers();
this.effects.forEach((effect, index)=>this.subscribeToEffect(effect, index));
this.running = true;
return this;
}
/**
* Останавливает все эффекты
* @returns Текущий модуль
*/ stop() {
this.subscriptions.forEach((sub)=>sub.unsubscribe());
this.subscriptions = [];
this.action$.complete();
this.action$ = new Subject();
this.running = false;
return this;
}
/**
* Подписывается на конкретный эффект
* @param effect Эффект для подписки
*/ subscribeToEffect(effect, index = 0) {
try {
const context = {
dispatcher: this.dispatcher,
externalDispatchers: this.externalDispatchers,
externalStates: this.externalStates,
services: this.services,
config: this.config
};
let stream$ = effect(this.action$.asObservable(), this.state$, context);
// resubscribeOnError: переподписываемся на поток вместо терминального завершения.
// Лимит ретраев исчерпан → ошибка уходит в терминальный catchError ниже
// (эффект умирает, остальные продолжают работать).
const options = effect[EFFECT_OPTIONS];
const resubscribeOnError = options?.resubscribeOnError;
const resubscribes = !!resubscribeOnError;
if (resubscribeOnError) {
const config = resubscribeOnError === true ? {} : resubscribeOnError;
stream$ = stream$.pipe(retry({
count: config.count ?? Infinity,
delay: config.delay,
resetOnSuccess: true
}));
}
// Имя эффекта (поле class-слоя Effects) — для понятного предупреждения; иначе индекс.
const effectLabel = effect[EFFECT_NAME] ?? `#${index}`;
const output$ = stream$.pipe(catchError((err)=>{
// Поток эффекта дошёл до терминальной ошибки → этот эффект БОЛЬШЕ не реагирует
// на экшены (остальные живы). Громкое сообщение, чтобы это не прошло незаметно.
const tail = resubscribes ? 'resubscribeOnError исчерпал лимит ретраев.' : 'Чтобы эффект переподписывался после ошибки, добавьте { resubscribeOnError: true } в this.effect(fn, …).';
handleCallbackError(`EffectsModule: эффект "${effectLabel}" УПАЛ и больше не будет реагировать на экшены (поток завершён). ${tail}`, err);
return of(null);
}));
const subscription = output$.subscribe((result)=>{
if (result === null || result === undefined) {
return;
}
if (typeof result === 'function') {
try {
result();
} catch (callError) {
handleCallbackError('EffectsModule: error calling effect result function', callError);
}
}
});
this.subscriptions.push(subscription);
} catch (setupError) {
handleCallbackError('EffectsModule: error setting up effect', setupError);
}
}
}
/**
* Вспомогательная функция для создания типизированного эффекта
*/ function createEffect(effect) {
return effect;
}
/**
* Объединяет несколько эффектов в один
* @param effects Эффекты для объединения
* @returns Объединенный эффект
*/ function combineEffects(...effects) {
return (action$, state$, context)=>{
const outputs = effects.map((effect)=>{
try {
return effect(action$, state$, context);
} catch (error) {
handleCallbackError('combineEffects: error in one of combined effects', error);
return of(null);
}
});
return merge(...outputs);
};
}
export { ApiError, EFFECT_NAME, EFFECT_OPTIONS, EffectsModule, apiResult, combineEffects, createEffect, mutationMap, ofType, ofTypes, ofTypesWaitAll, selectorMap, selectorObject, validateMap };
//# sourceMappingURL=effects.module.js.map