synapse-storage
Version:
Набор инструментов для управления состоянием и апи-запросами
118 lines (111 loc) • 7.3 kB
JavaScript
import { SynapseError } from "../../_utils/error-handling.util.js";
import { SelectorModule, deepEquals } from "./selector.module.js";
/**
* Определяет, передан ли в конструктор готовый модуль селекторов или storage.
* Модуль распознаётся по методу `createSelector` (у `IStorage` его нет).
*/ function isSelectorModule(source) {
return typeof source.createSelector === 'function';
}
/**
* Публичный class-based слой селекторов. Селекторы объявляются как поля класса через
* фабрики `this.select` / `this.combine` / `this.keyed` — поля сразу настоящие
* `SelectorAPI` (eager-материализация, никаких рецептов).
*
* Внешние селекторы (cross-store) передаются параметрами конструктора подкласса:
* parameter properties присваиваются ДО инициализаторов полей, поэтому `this.core`
* в полях доступен корректно.
*
* Внутреннее состояние базы — hard-private (`#`-поля/методы): их имена в отдельном
* namespace и НЕ конфликтуют с полями-селекторами подкласса (можно объявить селектор
* `track`, `module` и т.п.). Зарезервированы лишь `protected`/публичные члены
* (`select`/`combine`/`keyed`/`destroy`) — их именами селекторы называть нельзя.
*
* @example
* ```ts
* class PostsSelectors extends Selectors<PostsState> {
* constructor(storage: IStorage<PostsState>, private readonly core: CoreSelectors) {
* super(storage)
* }
*
* private readonly api = this.select((s) => s.api)
* readonly list = this.select((s) => s.list)
* readonly isPostsLoading = this.combine([this.api], (a) => a.postsRequest.status === 'loading')
* // cross-store: пересчитывается при изменении чужого стора
* readonly currentUserId = this.combine([this.core.profile], (p) => p?.user_info?.id ?? null)
* }
* ```
*/ class Selectors {
/** Модуль, поверх которого работает class-слой. */ #module;
/** Владеем ли мы модулем (создали из storage) — только тогда destroy уничтожает его целиком. */ #ownsModule;
/** id всех созданных нами селекторов — для точечной очистки общего (чужого) модуля. */ #ownSelectorIds = [];
/** Кэши keyed-фабрик — очищаются при destroy. */ #keyedCaches = [];
/** Принимает `storage` (создаёт и владеет своим `SelectorModule`) либо готовый модуль. */ constructor(source){
if (isSelectorModule(source)) {
this.#module = source;
this.#ownsModule = false;
} else {
this.#module = new SelectorModule(source);
this.#ownsModule = true;
}
}
/** Простой селектор: мемоизация по ссылке стейта + трекинг затронутых ключей. */ select(selector, options) {
return this.#track(this.#module.createSelector(selector, options));
}
/** Combined-селектор; зависимости — любые `SelectorAPI`, в т.ч. из других сторов. */ combine(deps, fn, options) {
if (process.env.NODE_ENV !== 'production') this.#assertDepsDefined(deps);
return this.#track(this.#module.createSelector(deps, fn, options));
}
/**
* Dev-проверка зависимостей `combine`. Ловит частую ловушку: cross-store eager-селектор
* (`this.combine([this.core.x], …)`, где `core` — parameter property конструктора) при
* `useDefineForClassFields: true` (дефолт target ES2022) видит `this.core === undefined`
* на момент инициализатора поля — зависимость молча оказывается `undefined`, селектор не
* пересчитывается и тихо отдаёт мусор. Бросаем понятную ошибку вместо тихого сбоя.
*/ #assertDepsDefined(deps) {
const badIndex = deps.findIndex((dep)=>dep == null || typeof dep.subscribe !== 'function');
if (badIndex !== -1) {
throw new SynapseError(`combine(): зависимость #${badIndex} === ${String(deps[badIndex])} (не SelectorAPI). ` + 'Похоже, cross-store селектор инициализируется ДО присваивания parameter property ' + '(`this.<dep>` ещё undefined в момент инициализатора поля). Включите ' + '`"useDefineForClassFields": false` в tsconfig, либо создавайте такие селекторы ' + 'в теле конструктора после `super()`.', 'Selectors.combine');
}
}
/**
* Параметрический (keyed) селектор: один `SelectorAPI` на ключ (кэш по ключу).
*
* Слайсы соседних ключей живут под общим родителем (`s.byTarget[key]`), а storage при
* обновлении пере-клонирует всю ветку — поэтому ссылка соседнего ключа не сохраняется.
* Чтобы обновление ключа A не уведомляло подписчиков ключа B, keyed-селекторы по
* умолчанию сравнивают значения структурно (`deepEquals`). Опции можно переопределить.
*/ keyed(fn, options) {
const cache = new Map();
this.#keyedCaches.push(cache);
return (key)=>{
let api = cache.get(key);
if (!api) {
api = this.#track(this.#module.createSelector(fn(key), {
equals: deepEquals,
...options
}));
cache.set(key, api);
}
return api;
};
}
/**
* Уничтожает свой модуль (только если владеет им). Если модуль передан снаружи —
* удаляет из него лишь свои селекторы, чужие не трогает.
*/ destroy() {
if (this.#ownsModule) {
this.#module.destroy();
} else {
for (const id of this.#ownSelectorIds){
this.#module.removeSelector(id);
}
}
this.#keyedCaches.forEach((cache)=>cache.clear());
}
/** Регистрирует id селектора для последующей точечной очистки общего модуля. */ #track(api) {
this.#ownSelectorIds.push(api.getId());
return api;
}
}
export { Selectors };
//# sourceMappingURL=selectors.base.js.map