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