synapse-storage
Version:
Набор инструментов для управления состоянием и апи-запросами
350 lines (343 loc) • 17.6 kB
JavaScript
import { handleCleanupError, handleOperationError } from "../../_utils/error-handling.util.js";
import { CacheUtils } from "../utils/cache.util.js";
/**
* Менеджер хранилища для API
* Объединяет в себе функционал хранилища и управления кэшем
*/ class QueryStorage {
storageExternal;
globalCacheConfig;
/** Экземпляр хранилища */ storage = null;
cleanupInterval = null;
/** Индекс тегов: tag → Set<cacheKey> для быстрой инвалидации */ tagIndex = new Map();
/** Подписчики на событие инвалидации кэша (шина для авто-рефетча хуков) */ invalidateListeners = new Set();
/** Настройки кэша по умолчанию */ defaultCacheOptions = {
ttl: 5 * 60 * 1000,
cleanup: {
enabled: true,
interval: 10 * 60 * 1000
},
invalidateOnError: true
};
/** Флаг завершённой инициализации */ _initialized = false;
/** Промис текущей инициализации */ _initPromise = null;
constructor(storageExternal, globalCacheConfig){
this.storageExternal = storageExternal;
this.globalCacheConfig = globalCacheConfig;
}
async initialize() {
if (this._initialized) return this;
if (this._initPromise) return this._initPromise;
this._initPromise = this._doInitialize();
return this._initPromise;
}
async _doInitialize() {
try {
// 1. Создаем хранилище
await this.createStorage();
// 2. Перестраиваем индекс тегов из существующих записей в storage
await this.rebuildTagIndex();
// 3. Запускаем периодическую очистку, если это указано в настройках
this.startCleanupInterval();
this._initialized = true;
return this;
} catch (error) {
this._initPromise = null;
throw error;
}
}
async createStorage() {
try {
// Резолвим storage: может быть инстанс или фабрика
const s = typeof this.storageExternal === 'function' ? await this.storageExternal() : this.storageExternal;
await s.initialize();
this.storage = s;
} catch (error) {
handleOperationError('QueryStorage: storage initialization error', error);
}
}
startCleanupInterval() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Получаем настройки очистки
const cleanupConfig = typeof this.globalCacheConfig === 'object' ? this.globalCacheConfig.cleanup : this.defaultCacheOptions.cleanup;
// Запускаем интервал очистки, если он включен
if (cleanupConfig?.enabled && cleanupConfig.interval) {
this.cleanupInterval = setInterval(()=>{
this.cleanup().catch((err)=>handleCleanupError('QueryStorage: cache cleanup error', err));
}, cleanupConfig.interval);
}
}
/**
* Получает экземпляр хранилища
*/ getStorage() {
return this.storage;
}
/**
* Является ли текущее хранилище синхронным (Memory/LocalStorage).
* Только для таких хранилищ доступно синхронное чтение кэша ({@link getCachedResultSync}).
*/ isSyncStorage() {
if (!this.storage) return false;
// Явный флаг — источник истины. Когда его нет (внешний адаптер со старой minor-версии),
// откатываемся на эвристику по type: неизвестное считаем sync, кроме известных async-типов.
return this.storage.isSync ?? (this.storage.type !== 'indexedDB' && this.storage.type !== 'worker');
}
/**
* Подписка на событие инвалидации кэша. Колбэк получает список тегов, которые
* были инвалидированы (через мутацию с `invalidatesTags` или ручную инвалидацию).
* Используется хуками для авто-рефетча активных запросов после мутаций.
*/ onCacheInvalidate(listener) {
this.invalidateListeners.add(listener);
return ()=>this.invalidateListeners.delete(listener);
}
/** Уведомляет подписчиков шины об инвалидации указанных тегов */ emitCacheInvalidate(tags) {
if (!tags.length || !this.invalidateListeners.size) return;
this.invalidateListeners.forEach((listener)=>listener(tags));
}
/**
* Синхронное чтение результата из кэша (fast-path для SSR-гидрации).
* Работает только на синхронных хранилищах (Memory/LocalStorage) — читает из
* снапшота `getStateSync()` без async-тика, поэтому данные доступны уже на
* первом рендере и не возникает «вспышки» loading. Для async-хранилищ
* (IndexedDB) и протухших записей возвращает `undefined`.
*
* В отличие от {@link getCachedResult}, НЕ мутирует метаданные и не удаляет
* протухшие записи (чистое чтение, безопасно вызывать во время рендера).
*/ getCachedResultSync(cacheKey) {
if (!this.storage || !this.isSyncStorage()) return undefined;
const state = this.storage.getStateSync();
const cachedEntry = state[String(cacheKey)];
if (!cachedEntry?.metadata) return undefined;
if (CacheUtils.isExpired(cachedEntry.metadata)) return undefined;
return cachedEntry.data;
}
/**
* Создает ключ кэша для запроса с учетом заголовков
* @param endpoint Имя эндпоинта
* @param params Параметры запроса (все что посчитаем нужным)
*/ createCacheKey(endpoint, params) {
return CacheUtils.createApiKey(endpoint, params);
}
/**
* Получает результат запроса из кэша
*/ async getCachedResult(cacheKey) {
if (!this.storage) throw new Error('Хранилище не инициализировано');
const cachedEntry = await this.storage.get(cacheKey);
if (!cachedEntry) return undefined;
// Проверяем срок годности кэша
if (CacheUtils.isExpired(cachedEntry.metadata)) {
this.removeKeyFromTagIndex(String(cacheKey), cachedEntry.metadata.tags);
await this.storage.remove(cacheKey);
return undefined;
}
// Обновляем метаданные кэша (счетчик доступа, время обновления)
const updatedEntry = {
...cachedEntry,
metadata: CacheUtils.updateMetadata(cachedEntry.metadata)
};
await this.storage.set(cacheKey, updatedEntry);
return cachedEntry.data;
}
/**
* Сохраняет результат запроса в кэш
* @param cacheKey Ключ кэша
* @param data Данные для кэширования
* @param cacheOptions Метаданные
* @param cacheParams Параметры которые влияли на создание ключа
* @param tags Тэги эндпоинта
*/ async setCachedResult(cacheKey, data, cacheOptions, cacheParams, tags) {
if (!this.storage) throw new Error('Хранилище не инициализировано');
// Создаем метаданные кэша
const cacheMetadata = CacheUtils.createMetadata(cacheOptions.ttl, tags);
// Создаем запись кэша
const cacheEntry = {
data,
metadata: cacheMetadata,
params: cacheParams
};
await this.storage.set(cacheKey, cacheEntry);
// Обновляем индекс тегов
const keyStr = String(cacheKey);
for (const tag of tags){
let keys = this.tagIndex.get(tag);
if (!keys) {
keys = new Set();
this.tagIndex.set(tag, keys);
}
keys.add(keyStr);
}
}
/**
* Проверяет, должен ли запрос быть кэширован
* @param endpointConfig Конфигурация эндпоинта
* @param options Опции запроса
* @param method HTTP-метод запроса (только GET кэшируется по REST-стандарту)
* @returns true если запрос должен кэшироваться
*/ shouldCache(endpointConfig, options, method) {
// Мутации (POST/PUT/DELETE/PATCH) не кэшируются по REST-стандарту
if (method && method !== 'GET') return false;
// Если глобальный кэш отключен, возвращаем false
if (this.globalCacheConfig === false) return false;
// Если эндпоинт явно отключает кэш, возвращаем false
if (endpointConfig?.cache === false) return false;
// Если по какой то причине указали время кэша 0
if (typeof endpointConfig?.cache === 'object' && endpointConfig?.cache.ttl === 0) return false;
// Если при вызове самого запроса явно указали НЕ кэшировать
if (options?.disableCache === true) return false;
// Если настройки нигде не указаны - по умолчанию НЕ кэшируем
if (this.globalCacheConfig === undefined && endpointConfig?.cache === undefined) return false;
return true;
}
/**
* Создает итоговую конфигурацию кэширования для конкретного эндпоинта
* Объединяет глобальный конфиг с текущим
* @param endpointConfig Конфигурация эндпоинта
*/ createCacheConfig(endpointConfig) {
// Создаем опции по умолчанию
let resultConfig = this.defaultCacheOptions;
// Если в глобальном конфиге кэш передан как объект а не boolean - по умолчанию станет он
if (typeof this.globalCacheConfig === 'object') {
resultConfig = this.globalCacheConfig;
}
// Если в настройках эндпоинта кэш как объект - дополняем этими параметрами итоговый объект кэша
if (typeof endpointConfig?.cache === 'object') {
const endpointCache = endpointConfig.cache;
resultConfig = {
...resultConfig,
...endpointCache
};
}
return resultConfig;
}
/**
* Инвалидирует кэш по тегам (использует индекс для O(1) поиска по тегу)
* @param tags Теги для инвалидации
*/ async invalidateCacheByTags(tags) {
if (!this.storage) throw new Error('Хранилище не инициализировано');
// Собираем все ключи для удаления через индекс
const keysToRemove = new Set();
for (const tag of tags){
const keys = this.tagIndex.get(tag);
if (keys) {
keys.forEach((k)=>keysToRemove.add(k));
this.tagIndex.delete(tag);
}
}
// Удаляем из остальных тегов индекса (ключ может быть в нескольких тегах)
for (const key of keysToRemove){
for (const [tag, keys] of this.tagIndex){
keys.delete(key);
if (keys.size === 0) this.tagIndex.delete(tag);
}
}
// Удаляем записи из хранилища
await Promise.all([
...keysToRemove
].map((key)=>this.storage.remove(key)));
// Уведомляем шину — активные подписчики (хуки) сделают рефетч
this.emitCacheInvalidate(tags);
}
/**
* Инвалидирует кэш по ключу
* @param cacheKey Ключ кэша
*/ async invalidateCache(cacheKey) {
if (!this.storage) throw new Error('Хранилище не инициализировано');
// Читаем теги записи для очистки индекса
const cachedEntry = await this.storage.get(cacheKey);
if (cachedEntry) {
this.removeKeyFromTagIndex(String(cacheKey), cachedEntry.metadata.tags);
}
await this.storage.remove(cacheKey);
// Уведомляем шину тегами удалённой записи
if (cachedEntry?.metadata?.tags?.length) {
this.emitCacheInvalidate(cachedEntry.metadata.tags);
}
}
/**
* Выполняет очистку всех просроченных записей кэша
*/ async cleanup() {
if (!this.storage) {
throw new Error('Хранилище не инициализировано');
}
const keys = await this.storage.keys();
for (const key of keys){
const value = await this.storage.get(key);
if (value && CacheUtils.isExpired(value.metadata)) {
this.removeKeyFromTagIndex(String(key), value.metadata.tags);
await this.storage.remove(key);
}
}
}
/**
* Уничтожает хранилище и освобождает ресурсы
*/ async destroy() {
// Останавливаем интервал очистки
if (this.cleanupInterval) {
globalThis.clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Очищаем индекс тегов
this.tagIndex.clear();
// Очищаем подписчиков шины инвалидации
this.invalidateListeners.clear();
// Очищаем хранилище
if (this.storage) {
await this.storage.destroy();
this.storage = null;
}
// Сбрасываем состояние инициализации
this._initialized = false;
this._initPromise = null;
}
/**
* Гидрация кэша снапшотом (SSR/server-state). Заменяет состояние хранилища и
* перестраивает индекс тегов, чтобы инвалидация по тегам работала сразу после
* переноса с сервера. Абсолютные `expiresAt` в метаданных переживают перенос,
* поэтому TTL продолжает считаться корректно.
*/ async hydrate(state) {
if (!this.storage) throw new Error('Хранилище не инициализировано');
await this.storage.hydrate(state);
await this.rebuildTagIndex();
}
/**
* Перестраивает индекс тегов из существующих записей в storage
* Вызывается при инициализации для восстановления после перезагрузки
*/ async rebuildTagIndex() {
if (!this.storage) return;
this.tagIndex.clear();
const keys = await this.storage.keys();
for (const key of keys){
const entry = await this.storage.get(key);
if (!entry?.metadata?.tags) continue;
// Удаляем протухшие записи сразу
if (CacheUtils.isExpired(entry.metadata)) {
await this.storage.remove(key);
continue;
}
const keyStr = String(key);
for (const tag of entry.metadata.tags){
let tagKeys = this.tagIndex.get(tag);
if (!tagKeys) {
tagKeys = new Set();
this.tagIndex.set(tag, tagKeys);
}
tagKeys.add(keyStr);
}
}
}
/**
* Удаляет ключ из индекса тегов
*/ removeKeyFromTagIndex(key, tags) {
if (!tags) return;
for (const tag of tags){
const keys = this.tagIndex.get(tag);
if (keys) {
keys.delete(key);
if (keys.size === 0) this.tagIndex.delete(tag);
}
}
}
}
export { QueryStorage };
//# sourceMappingURL=query-storage.js.map