UNPKG

synapse-storage

Version:

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

118 lines (111 loc) 7.3 kB
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