UNPKG

synapse-storage

Version:

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

294 lines (285 loc) 13.1 kB
import { DispatcherCore } from "./dispatcher.module.js"; import { resolvePath, setByPath } from "./path.util.js"; import { ApiStatus } from "./standalone.js"; /** * Маркер метода финализации диспетчера. * * Имя экшена/вотчера берётся из имени поля класса, но прочитать имена полей можно * только ПОСЛЕ полного конструирования инстанса (инициализаторы полей derived-класса * выполняются после конструктора базового класса). Поэтому имена назначаются отдельным * шагом-финализацией: * * 1. **Сборщик `createSynapse(factory)`** вызывает `dispatcher[FINALIZE]()` до старта * эффектов (эффекты читают `actionType` при сборке пайплайна). * 2. **Ленивая само-финализация** — страховка для standalone-использования и тестов: * первый dispatch экшена или первое обращение к реестру `dispatch`/`watchers` * финализирует инстанс, если это ещё не сделано. */ const FINALIZE = Symbol('synapse.dispatcher.finalize'); /** Имена членов базового класса, которые нельзя переопределять полями-экшенами. */ const RESERVED_NAMES = new Set([ 'storage', 'action$', 'actions', 'dispatch', 'watchers', 'use', 'destroy' ]); function isWrapper(value) { return typeof value === 'function' && (value._type === 'dispatch' || value._type === 'watchers'); } /** * Публичный class-based слой диспетчера. Экшены объявляются как поля класса через * фабрики `this.action` / `this.signal` / `this.apiActions` / `this.keyedApiActions` * / `this.watcher`. Имя экшена = имя поля. * * Внутреннее состояние базы — hard-private (`#`-поля/методы): их имена в отдельном * namespace и НЕ конфликтуют с полями-экшенами подкласса. Запрещённые имена экшенов — * только `protected`/публичная поверхность из `RESERVED_NAMES`. * * @example * ```ts * class PostsDispatcher extends Dispatcher<PostsState> { * readonly loadPosts = this.apiActions<PostsFindAllParams>((s) => s.api.postsRequest) * readonly mounted = this.signal<FeedLifecyclePayload>('Лента смонтирована') * readonly applyPosts = this.action((store, page: PostsFeedResponseDto) => * store.update((s) => { s.list = page.data })) * } * ``` */ class Dispatcher { storage; /** Движок, поверх которого работает class-слой. */ #core; /** Реестры по имени (наполняются при финализации). Делят ссылку с движком. */ #dispatch; #watchers; #finalized = false; /** Поток всех экшенов модуля (его потребляет EffectsModule). */ action$; constructor(storage, options){ this.storage = storage; this.#core = new DispatcherCore({ storage, middlewares: options?.middlewares }); this.#dispatch = this.#core.dispatch; this.#watchers = this.#core.watchers; this.action$ = this.#core.actions; } // ── Публичные реестры: обращение к ним финализирует (страховка) ────────────── /** Реестр экшенов по имени — для middleware/devtools. */ get dispatch() { this.#ensureFinalized(); return this.#dispatch; } get watchers() { this.#ensureFinalized(); return this.#watchers; } /** Алиас потока экшенов для совместимости с EffectsModule (`dispatcher.actions`). */ get actions() { return this.action$; } // ── Фабрики для class fields ───────────────────────────────────────────────── /** * Экшен: handler в «рецептной» сигнатуре `(storage, params) => result`. * payload экшена = возвращаемое значение handler'а. */ action(handler, options) { const inner = this.#core.createAction({ type: options?.type, meta: options?.meta, action: (params)=>handler(this.storage, params) }, options?.memoize ? { memoize: options.memoize } : undefined); return this.#wrapDispatch(inner); } /** Чистый сигнал: `(_store, p) => p`. `description` уходит в meta. */ signal(description) { return this.action((_storage, payload)=>payload, description ? { meta: { description } } : undefined); } /** Вызываемая группа жизненного цикла API-запроса. Сам вызов = init (намерение). */ apiActions(accessor) { const path = resolvePath(accessor); const write = (storage, request)=>storage.update((s)=>setByPath(s, path, request)); const init = this.action((storage, payload)=>{ write(storage, { status: ApiStatus.Idle, error: null }); return payload; }); init.loading = this.action((storage)=>write(storage, { status: ApiStatus.Loading, error: null })); init.success = this.action((storage)=>write(storage, { status: ApiStatus.Success, error: null })); init.failure = this.action((storage, error)=>write(storage, { status: ApiStatus.Error, error })); init.reset = this.action((storage)=>write(storage, { status: ApiStatus.Reset, error: null })); return this.#markApiGroup(init); } /** То же для статусов по ключу (`Record<string, ApiRequestState>`). */ keyedApiActions(accessor) { const path = resolvePath(accessor); const write = (storage, key, request)=>storage.update((s)=>setByPath(s, [ ...path, key ], request)); const init = this.action((storage, payload)=>{ write(storage, payload.key, { status: ApiStatus.Idle, error: null }); return payload; }); init.loading = this.action((storage, key)=>{ write(storage, key, { status: ApiStatus.Loading, error: null }); return key; }); init.success = this.action((storage, key)=>{ write(storage, key, { status: ApiStatus.Success, error: null }); return key; }); init.reset = this.action((storage, key)=>{ write(storage, key, { status: ApiStatus.Reset, error: null }); return key; }); init.failure = this.action((storage, payload)=>{ write(storage, payload.key, { status: ApiStatus.Error, error: payload.error }); return payload; }); return this.#markApiGroup(init); } watcher(config) { const inner = this.#core.createWatcher(config); return this.#wrapWatcher(inner); } // ── Жизненный цикл ─────────────────────────────────────────────────────────── use(...middlewares) { this.#core.use(...middlewares); return this; } destroy() { // Движок отпишет вотчеры (реестр общий) и завершит action$. this.#core.destroy(); } /** * Финализация: скан own enumerable полей, назначение имён (`_assignType(имя поля)`) * и регистрация в реестрах `dispatch`/`watchers`. Идемпотентна. */ [FINALIZE]() { if (this.#finalized) return; this.#finalized = true; // Для детекции полей-алиасов (одна функция под двумя именами). const seen = new Map(); for (const [name, value] of Object.entries(this)){ if (!isWrapper(value)) continue; if (RESERVED_NAMES.has(name)) { throw new Error(`Dispatcher: поле "${name}" конфликтует с зарезервированным членом базового класса. Переименуйте экшен.`); } if (seen.has(value)) { throw new Error(`Dispatcher: поле "${name}" является алиасом поля "${seen.get(value)}" — один экшен не может иметь два имени. Объявите отдельный экшен.`); } seen.set(value, name); if (value._apiGroup) { this.#finalizeApiGroup(name, value); } else if (value._type === 'watchers') { this.#finalizeNamed(name, value, this.#watchers); } else { this.#finalizeNamed(name, value, this.#dispatch); } } } // ── Внутреннее ─────────────────────────────────────────────────────────────── #ensureFinalized() { if (!this.#finalized) this[FINALIZE](); } /** Назначает имя через `_assignType` (если тип не был задан явно) и регистрирует обёртку. */ #finalizeNamed(name, wrapper, registry) { if (typeof wrapper._inner._assignType === 'function') { wrapper._inner._assignType(name); } registry[name] = wrapper; } #finalizeApiGroup(name, init) { this.#finalizeNamed(name, init, this.#dispatch); const group = init._apiGroup; this.#finalizeNamed(`${name}:loading`, group.loading, this.#dispatch); this.#finalizeNamed(`${name}:success`, group.success, this.#dispatch); this.#finalizeNamed(`${name}:failure`, group.failure, this.#dispatch); this.#finalizeNamed(`${name}:reset`, group.reset, this.#dispatch); } #markApiGroup(init) { const w = init; w._apiGroup = { loading: init.loading, success: init.success, failure: init.failure, reset: init.reset }; return init; } /** * Оборачивает функцию-экшен движка: на первый вызов лениво финализирует диспетчер, * затем делегирует. `actionType`/`meta` форвардятся «вживую» (значение появляется * после `_assignType` при финализации). */ #wrapDispatch(inner) { const wrapper = (params)=>{ this.#ensureFinalized(); return inner(params); }; wrapper._type = 'dispatch'; wrapper._inner = inner; Object.defineProperty(wrapper, 'actionType', { get: ()=>inner.actionType, enumerable: true, configurable: true }); Object.defineProperty(wrapper, 'meta', { get: ()=>inner.meta, enumerable: true, configurable: true }); return wrapper; } #wrapWatcher(inner) { const wrapper = ()=>{ this.#ensureFinalized(); return inner(); }; wrapper._type = 'watchers'; wrapper._inner = inner; Object.defineProperty(wrapper, 'actionType', { get: ()=>inner.actionType, enumerable: true, configurable: true }); Object.defineProperty(wrapper, 'meta', { get: ()=>inner.meta, enumerable: true, configurable: true }); Object.defineProperty(wrapper, 'unsubscribe', { value: ()=>inner.unsubscribe(), enumerable: true, configurable: true }); return wrapper; } } export { Dispatcher, FINALIZE }; //# sourceMappingURL=dispatcher.base.js.map