UNPKG

synapse-storage

Version:

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

474 lines (467 loc) 21.6 kB
import { Observable } from "rxjs"; import { StorageStatus } from "../storage/index.js"; /** * Reference equality (===) — поведение по умолчанию */ function defaultEquals(a, b) { return a === b; } const MAX_DEEP_EQUAL_DEPTH = 10; /** * Глубокое сравнение объектов по структуре с защитой от циклических ссылок и лимитом глубины. * Используется только при явной передаче через options.equals. */ function deepEquals(a, b, depth = 0, visited = new WeakSet()) { if (a === b) return true; if (a == null || b == null) return false; if (typeof a !== typeof b) return false; if (depth > MAX_DEEP_EQUAL_DEPTH) return false; if (typeof a !== 'object') return a === b; // Защита от циклических ссылок if (visited.has(a)) return false; visited.add(a); if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for(let i = 0; i < a.length; i++){ if (!deepEquals(a[i], b[i], depth + 1, visited)) return false; } return true; } if (Array.isArray(a) || Array.isArray(b)) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; return keysA.every((key)=>{ if (!Object.prototype.hasOwnProperty.call(b, key)) return false; return deepEquals(a[key], b[key], depth + 1, visited); }); } /** * Оборачивает state в Proxy для отслеживания обращений к ключам верхнего уровня. * Возвращает проксированный state и Set с именами ключей, к которым обратился селектор. */ function trackDependencies(state) { const accessedKeys = new Set(); const proxy = new Proxy(state, { get (target, prop, receiver) { if (typeof prop === 'string') { accessedKeys.add(prop); } return Reflect.get(target, prop, receiver); } }); return { proxy, accessedKeys }; } // Мемоизирует функцию селектора для оптимизации + трекинг зависимостей через Proxy function memoizeSelector(selectorFn, equals = defaultEquals) { let lastState; let lastResult; let hasResult = false; let trackedKeys = null; const memoized = function(state) { // Если это первый вызов или состояние изменилось if (!hasResult || lastState !== state) { // Трекаем зависимости через Proxy при каждом реальном пересчёте const { proxy, accessedKeys } = trackDependencies(state); const newResult = selectorFn(proxy); trackedKeys = accessedKeys; // Проверяем, изменился ли результат if (!hasResult || !equals(newResult, lastResult)) { lastResult = newResult; } lastState = state; hasResult = true; } return lastResult; }; return { memoized, getTrackedKeys: ()=>trackedKeys }; } class SelectorSubscription { name; getState; equals; logger; id; subscribers = new Set(); lastValue; hasValue = false; constructor(name, getState, equals = defaultEquals, logger){ this.name = name; this.getState = getState; this.equals = equals; this.logger = logger; this.id = name; this.logger?.debug(`[${this.id}] SelectorSubscription created`); } notify() { try { const newValue = this.getState(); // Проверка на изменение значения с использованием функции сравнения if (!this.hasValue || !this.equals(newValue, this.lastValue)) { this.logger?.debug(`[${this.id}] Value changed, notify()`); this.lastValue = newValue; this.hasValue = true; for (const subscriber of this.subscribers){ try { subscriber.notify(newValue); } catch (error) { this.logger?.error(`[${this.id}] Ошибка в уведомлении подписчика`, { error }); } } } } catch (error) { this.logger?.error(`[${this.id}] Ошибка в notify()`, { error }); throw error; } } subscribe(subscriber) { this.subscribers.add(subscriber); // Отправляем текущее значение синхронно if (this.hasValue) { try { subscriber.notify(this.lastValue); } catch (error) { this.logger?.error(`[${this.id}] Ошибка в первоначальном уведомлении`, { error }); } } else { // Вычисляем значение синхронно this.notify(); } return ()=>{ this.subscribers.delete(subscriber); }; } cleanup() { this.subscribers.clear(); this.lastValue = undefined; this.hasValue = false; } getLastValue() { return this.lastValue; } getValue() { if (!this.hasValue) { this.lastValue = this.getState(); this.hasValue = true; } return this.lastValue; } getId() { return this.id; } } class SelectorModule { source; logger; storageName; selectorIdCounter = 0; subscriptions = new Map(); localSelectorCache = new Map(); // Флаг для батчинга обновлений batchUpdateInProgress = false; pendingUpdates = new Set(); // Снятие подписки на статус источника (см. конструктор) — вызывается в destroy(). disposeStatusListener; constructor(source, logger){ this.source = source; this.logger = logger; this.storageName = source.name; // Селекторы конструируются ДО storage.initialize() (так устроен пайплайн createSynapse: // фабрика создаёт селекторы, и только потом buildSynapse зовёт initialize()). На этот // момент getStateSync() === {}. combine-селектор при создании эагерно подписывается на // зависимости и СИНХРОННО вычисляет их (SelectorSubscription.subscribe при !hasValue), // т.е. кеширует значение от пустого стора. А performInitialize наполняет _stateCache // молча, без storage:update, — значит этот кеш сам по себе уже не обновится. // Поэтому на переходе источника в READY принудительно пересчитываем все подписки. // Порядок Map = порядок создания, а зависимость всегда создаётся раньше зависимого // (combine([this.api]) невозможен до this.api) → пересчёт идёт «снизу вверх» корректно. this.disposeStatusListener = this.source.onStatusChange((status)=>{ if (status.status === StorageStatus.READY) { this.subscriptions.forEach((sub)=>{ try { sub.notify(); } catch (error) { this.logger?.error(`[${sub.getId()}] Ошибка пересчёта на READY`, { error }); } }); } }); } /** * Собирает публичный `SelectorAPI` поверх подписки. `$` — Observable-вид с той же * семантикой, что у `subscribe`: синхронный снапшот при подписке + emit на изменение. * * `sourceReadiness` переопределяет дефолтную (локальную) готовность источника: для * combined-селекторов туда передаётся агрегированная готовность по всем источникам * зависимостей (см. {@link createCombinedSelector}). */ buildSelectorApi(id, subscription, getState, sourceReadiness) { return { select: ()=>getState(), selectSync: ()=>subscription.getValue(), subscribe: (subscriber)=>subscription.subscribe(subscriber), getId: ()=>id, $: new Observable((observer)=>subscription.subscribe({ notify: (value)=>observer.next(value) })), isSourceReady: sourceReadiness?.isSourceReady ?? this.isSourceReady, onSourceStatusChange: sourceReadiness?.onSourceStatusChange ?? this.onSourceStatusChange }; } isSourceReady = ()=>{ return this.source.initStatus.status === StorageStatus.READY; }; onSourceStatusChange = (callback)=>{ return this.source.onStatusChange((status)=>{ callback(status.status === StorageStatus.READY); }); }; /** * Генерирует уникальное имя для селектора через автоинкрементный счётчик */ generateName() { return `${this.storageName}_selector_${this.selectorIdCounter++}`; } /** * Обрабатывает отложенные обновления синхронно */ processPendingUpdates() { if (this.pendingUpdates.size === 0 || this.batchUpdateInProgress) return; this.batchUpdateInProgress = true; try { // Копируем список селекторов для обновления const subscriptionsToUpdate = Array.from(this.pendingUpdates); this.pendingUpdates.clear(); // Обновляем все ожидающие селекторы синхронно for (const id of subscriptionsToUpdate){ const subscription = this.subscriptions.get(id); if (subscription) { try { subscription.notify(); } catch (error) { this.logger?.error(`Ошибка уведомления подписчика ${id}`, { error }); } } } } catch (error) { this.logger?.error('Ошибка обработки ожидающих обновлений', { error }); } finally{ this.batchUpdateInProgress = false; // Если появились новые обновления во время обработки, запускаем процесс снова if (this.pendingUpdates.size > 0) { this.processPendingUpdates(); } } } createSelector(selectorOrDeps, resultFnOrOptions, optionsArg) { // Определяем, какую перегрузку используем const isSimpleSelector = !Array.isArray(selectorOrDeps); // Извлекаем options const options = isSimpleSelector ? resultFnOrOptions || {} : optionsArg || {}; const hasExplicitName = !!options.name; // Используем предоставленное имя или генерируем уникальный ID const selectorId = options.name || this.generateName(); // Кеширование по имени — только для явно именованных селекторов if (hasExplicitName && this.localSelectorCache.has(selectorId)) { return this.localSelectorCache.get(selectorId).api; } // Создаем новый селектор let result; let dependencies; let unsubscribeFunctions = []; if (isSimpleSelector) { // Простой селектор с мемоизацией и трекингом зависимостей const { memoized, getTrackedKeys } = memoizeSelector(selectorOrDeps, options.equals || defaultEquals); const created = this.createSimpleSelector(memoized, getTrackedKeys, { ...options, name: selectorId, equals: options.equals || defaultEquals }); result = created.api; unsubscribeFunctions = created.unsubscribeFunctions; } else { // Комбинированный селектор dependencies = selectorOrDeps; const created = this.createCombinedSelector(dependencies, resultFnOrOptions, { ...options, name: selectorId, equals: options.equals || defaultEquals }); result = created.api; unsubscribeFunctions = created.unsubscribeFunctions; } // Сохраняем в кеши this.localSelectorCache.set(selectorId, { api: result, dependencies, unsubscribeFunctions }); return result; } createSimpleSelector(selector, getTrackedKeys, options) { // Синхронное получение состояния из кеша const getState = ()=>{ const state = this.source.getStateSync(); return selector(state); }; const subscription = new SelectorSubscription(options.name, getState, options.equals || defaultEquals, this.logger); const id = subscription.getId(); this.subscriptions.set(id, subscription); // Подписка на обновления хранилища с фильтрацией по changedPaths const unsubscribeFromStorage = this.source.subscribeToAll((event)=>{ if (event?.type === 'storage:update') { // Фильтруем по changedPaths: если зависимости известны и нет пересечения — пропускаем const changedPaths = event?.changedPaths; const deps = getTrackedKeys(); if (changedPaths && deps && deps.size > 0) { const hasRelevantChange = changedPaths.some((path)=>{ // changedPaths может содержать вложенные пути ("users.0.name") // берём ключ верхнего уровня для сравнения с deps const topLevelKey = path.split('.')[0]; return deps.has(topLevelKey); }); if (!hasRelevantChange) return; } this.pendingUpdates.add(id); this.processPendingUpdates(); } }); const unsubscribeFunctions = [ unsubscribeFromStorage ]; return { api: this.buildSelectorApi(id, subscription, getState), unsubscribeFunctions }; } createCombinedSelector(selectors, resultFn, options) { // Мемоизация для combined selectors: поэлементное сравнение аргументов по ссылке (как reselect) let lastArgs; let lastResult; let hasResult = false; const equals = options.equals || defaultEquals; const memoizedResultFn = (args)=>{ // Проверяем, изменился ли хотя бы один аргумент if (hasResult && lastArgs && lastArgs.length === args.length) { let argsEqual = true; for(let i = 0; i < args.length; i++){ if (lastArgs[i] !== args[i]) { argsEqual = false; break; } } if (argsEqual) return lastResult; } const newResult = resultFn(...args); // Сохраняем аргументы без создания нового массива — переиспользуем переданный lastArgs = args; if (!hasResult || !equals(newResult, lastResult)) { lastResult = newResult; } hasResult = true; return lastResult; }; const getState = ()=>{ const values = selectors.map((s)=>s.selectSync()); return memoizedResultFn(values); }; const subscription = new SelectorSubscription(options.name, getState, options.equals || defaultEquals, this.logger); const id = subscription.getId(); this.subscriptions.set(id, subscription); // Батчинг обновлений через microtask — без искусственной задержки let pendingNotify = false; let destroyed = false; const triggerUpdate = ()=>{ if (!pendingNotify) { pendingNotify = true; queueMicrotask(()=>{ pendingNotify = false; if (!destroyed) { try { subscription.notify(); } catch (error) { this.logger?.error(`[${id}] Ошибка в объединенном уведомлении:`, { error }); } } }); } }; const unsubscribeFunctions = selectors.map((selector)=>selector.subscribe({ notify: ()=>{ triggerUpdate(); } })); // При уничтожении предотвращаем выполнение отложенного microtask unsubscribeFunctions.push(()=>{ destroyed = true; }); // Агрегированная готовность источников: combined-селектор «готов», только когда готов // ЛОКАЛЬНЫЙ источник И все источники зависимостей (важно для cross-store combine, где // зависимости приходят из других сторов с собственным lifecycle). const aggregatedSourceReadiness = { isSourceReady: ()=>this.isSourceReady() && selectors.every((dep)=>dep.isSourceReady()), onSourceStatusChange: (callback)=>{ const emit = ()=>callback(aggregatedSourceReadiness.isSourceReady()); const disposers = [ this.onSourceStatusChange(emit), ...selectors.map((dep)=>dep.onSourceStatusChange(emit)) ]; return ()=>disposers.forEach((dispose)=>dispose()); } }; return { api: this.buildSelectorApi(id, subscription, getState, aggregatedSourceReadiness), unsubscribeFunctions }; } /** * Точечно удаляет селектор по id: снимает его подписки на хранилище, чистит подписку * и кэш. Остальные селекторы модуля не затрагиваются. Имя кэша совпадает с id подписки * (`generateName()`/`options.name`), поэтому достаточно одного ключа. */ removeSelector(id) { const subscription = this.subscriptions.get(id); if (subscription) { subscription.cleanup(); this.subscriptions.delete(id); } this.pendingUpdates.delete(id); const cached = this.localSelectorCache.get(id); if (cached) { cached.unsubscribeFunctions.forEach((unsub)=>unsub()); this.localSelectorCache.delete(id); } } destroy() { // Снимаем подписку на статус источника this.disposeStatusListener(); // Очищаем все подписки this.subscriptions.forEach((sub)=>sub.cleanup()); this.subscriptions.clear(); // Очищаем список ожидающих обновлений this.pendingUpdates.clear(); // Очищаем подписки из локального кеша this.localSelectorCache.forEach((cached)=>{ cached.unsubscribeFunctions.forEach((unsub)=>unsub()); }); this.localSelectorCache.clear(); } } export { SelectorModule, deepEquals }; //# sourceMappingURL=selector.module.js.map