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