UNPKG

synapse-storage

Version:

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

468 lines (457 loc) 22.9 kB
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