UNPKG

synapse-storage

Version:

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

585 lines (562 loc) 22.9 kB
import { batchingMiddleware } from "../middlewares/storage-batching.middleware.js"; import { loggerMiddleware } from "../middlewares/storage-logger.middleware.js"; import { shallowCompareMiddleware } from "../middlewares/storage-shallow-compare.middleware.js"; import { StorageEvents } from "../storage.interface.js"; import { AsyncMiddlewareModule, VALUE_NOT_CHANGED } from "../utils/middleware-module.js"; import { decideMigration } from "../utils/migration.util.js"; import { createDummyState, extractPath } from "../utils/path-selector.util.js"; import { createLazyClone, findChangedPaths, isEqual } from "../utils/state-diff.util.js"; import { getValueByPath } from "./path.utils.js"; import { GLOBAL_SUBSCRIPTION_KEY, StorageCore } from "./storage-core.js"; /** * Базовый класс для асинхронных хранилищ (IndexedDB). * * Все CRUD-операции возвращают Promise. * Совместим с текущим API (переименован из BaseStorage). */ class AsyncBaseStorage extends StorageCore { config; /** Async-хранилище: синхронное чтение кэша недоступно. */ isSync = false; middlewareModule; initializedMiddlewares = null; selectorPathCache = new WeakMap(); /** Версии ключей для защиты от race condition в subscribeByKey (async get). */ keyVersions = new Map(); /** Инкремент версии ключа при каждом изменении — читается в subscribeByKey. */ trackKeyVersion(keyStr) { this.keyVersions.set(keyStr, (this.keyVersions.get(keyStr) ?? 0) + 1); } constructor(config, eventEmitter, logger){ super(config, eventEmitter, logger), this.config = config; this.middlewareModule = new AsyncMiddlewareModule({ getState: ()=>this.getRawState(), doGet: this.doGet.bind(this), doSet: this.doSet.bind(this), doUpdate: this.doUpdate.bind(this), doDelete: this.doDelete.bind(this), doClear: this.doClear.bind(this), doKeys: this.doKeys.bind(this), notifySubscribers: this.notifySubscribers.bind(this) }); } // ─── Lifecycle hooks ──────────────────────────────────────────────────────── async performInitialize() { await this.doInitialize(); this._stateCache = await this.getRawState(); } async performCleanup() { // Обходим публичный clear() (избегая ensureReady после _isDestroyed = true) await this.doClear(); await this.doDestroy(); await this.cleanupMiddlewares(); } /** * Прогоняет `cleanup()` по всем инициализированным мидлварам (закрывает их каналы/ * таймеры) и сбрасывает список. Вынесено отдельно, чтобы переопределения * performCleanup, СОЗНАТЕЛЬНО пропускающие doClear() (WorkerCacheStorage, * IndexedDBStorage — персистентные/разделяемые данные), всё равно освобождали * ресурсы мидлвар и не текли после destroy() (R8). */ async cleanupMiddlewares() { if (this.initializedMiddlewares) { await Promise.all(this.initializedMiddlewares.map(async (middleware)=>{ if ('cleanup' in middleware) { await middleware.cleanup?.(); } })); this.initializedMiddlewares = null; } } // ─── Middleware initialization ────────────────────────────────────────────── initializeMiddlewares() { if (this.config.middlewares && !this.initializedMiddlewares) { this.initializedMiddlewares = this.config.middlewares(()=>this.getDefaultMiddleware()); this.initializedMiddlewares.forEach((middleware)=>this.middlewareModule.use(middleware)); } } getDefaultMiddleware() { return { batching: (options = {})=>batchingMiddleware(options), shallowCompare: (options = {})=>shallowCompareMiddleware(options), logger: (options = {})=>loggerMiddleware(options) }; } async initializeWithMiddlewares() { try { const state = await this.getRawState(); const hasExistingState = Object.keys(state).length > 0; // Миграция выключена (version не задан) — прежнее поведение: засеять initialState на пустом. if (this.config.version === undefined) { if (!hasExistingState && this.config.initialState) { await this.middlewareModule.dispatch({ type: 'init', value: this.config.initialState }); } return; } const decision = decideMigration({ hasExisting: hasExistingState, existingState: state, persistedVersion: await this.readPersistedVersion(), targetVersion: this.config.version, migrate: this.config.migrate }); switch(decision.kind){ case 'seed': { if (this.config.initialState) { await this.middlewareModule.dispatch({ type: 'init', value: this.config.initialState }); } await this.writePersistedVersion(this.config.version); break; } case 'migrate': { await this.middlewareModule.dispatch({ type: 'reset', value: decision.state }); await this.writePersistedVersion(this.config.version); break; } case 'bump': { await this.writePersistedVersion(this.config.version); break; } case 'none': break; } } catch (error) { this.logger?.error('Ошибка инициализации хранилища', { error }); throw error; } } // ─── Persisted schema version (persist-migration) ─────────────────────────── /** Читает сохранённую версию схемы. По умолчанию `undefined` (нет персистентности). */ async readPersistedVersion() { return undefined; } /** Сохраняет версию схемы рядом с данными. По умолчанию no-op. */ async writePersistedVersion(_version) {} // ─── Internal state access ────────────────────────────────────────────────── async getRawState() { try { const value = await this.doGet(''); return value || {}; } catch (error) { this.logger?.error('Error getting state', { error }); throw error; } } // ─── Public async API ─────────────────────────────────────────────────────── async get(key) { this.ensureReady(); try { const metadata = { operation: 'get', timestamp: Date.now(), key }; const finalResult = await this.middlewareModule.dispatch({ type: 'get', key, metadata }); // Чтения не эмитят событий — это горячий путь, а STORAGE_SELECT нигде не потребляется. return finalResult; } catch (error) { this.logger?.error('Error getting value', { key, error }); throw error; } } async set(key, value) { this.ensureReady(); try { const metadata = { operation: 'set', timestamp: Date.now(), key }; const finalResult = await this.middlewareModule.dispatch({ type: 'set', key, value, metadata }); if (finalResult === VALUE_NOT_CHANGED) return; this._stateCache = await this.getRawState(); const keyStr = key.toString(); const changedPaths = [ keyStr ]; this.notifySubscribers(key, finalResult); this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_UPDATE, key, value: finalResult, changedPaths }); await this.emitEvent({ type: StorageEvents.STORAGE_UPDATE, payload: { key, value: finalResult, changedPaths } }); } catch (error) { this.logger?.error('Error setting value', { key, error }); throw error; } } async update(updater) { this.ensureReady(); try { const metadata = { operation: 'update', timestamp: Date.now() }; const currentState = await this.getState(); const newState = createLazyClone(currentState); updater(newState); const changedPaths = findChangedPaths(currentState, newState); if (changedPaths.size === 0) { if (this.logger?.debug) { this.logger.debug('No changes detected in update'); } return; } if (this.logger?.debug) { this.logger.debug('Changed paths:', { paths: Array.from(changedPaths) }); } const changedTopLevelKeys = new Set(); for (const path of changedPaths){ changedTopLevelKeys.add(path.split('.')[0]); } const updates = Array.from(changedTopLevelKeys).map((key)=>{ return { key, value: newState[key] }; }); const result = await this.middlewareModule.dispatch({ type: 'update', value: updates, metadata: { ...metadata, batchUpdate: true, changedPaths: Array.from(changedPaths) } }); let updatedValues = {}; if (Array.isArray(result)) { result.forEach((update)=>{ if (update && typeof update === 'object' && 'key' in update && 'value' in update) { updatedValues[update.key] = update.value; } }); } else if (result && typeof result === 'object') { updatedValues = { ...result }; } const actuallyChangedKeys = Object.keys(updatedValues).filter((key)=>!isEqual(currentState[key], updatedValues[key])); if (actuallyChangedKeys.length === 0) { if (this.logger?.debug) { this.logger.debug('No actual changes after middleware processing'); } return; } const finalUpdates = {}; actuallyChangedKeys.forEach((key)=>{ finalUpdates[key] = updatedValues[key]; }); if (this.logger?.debug) { this.logger.debug('Notifying subscribers about changes:', { keys: actuallyChangedKeys }); } this._stateCache = { ...currentState, ...finalUpdates }; this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_UPDATE, key: actuallyChangedKeys, value: finalUpdates, changedPaths: Array.from(changedPaths) }); for (const path of changedPaths){ try { const topLevelKey = path.split('.')[0]; if (topLevelKey in finalUpdates) { let value; if (path === topLevelKey) { value = finalUpdates[topLevelKey]; } else { const restPath = path.substring(topLevelKey.length + 1); value = getValueByPath(finalUpdates[topLevelKey], restPath); } if (value !== undefined) { this.notifySubscribers(path, value); } } } catch (error) { this.logger?.error('Error notifying path subscribers', { path, error }); } } await this.emitEvent({ type: StorageEvents.STORAGE_UPDATE, payload: { state: finalUpdates, key: actuallyChangedKeys, changedPaths: Array.from(changedPaths) } }); } catch (error) { this.logger?.error('Error updating state', { error }); throw error; } } async remove(key) { this.ensureReady(); try { const metadata = { operation: 'delete', timestamp: Date.now(), key }; const middlewareResult = await this.middlewareModule.dispatch({ type: 'delete', key, metadata }); if (middlewareResult === false) return; this._stateCache = await this.getRawState(); const keyStr = key.toString(); const changedPaths = [ keyStr ]; this.notifySubscribers(key, undefined); this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_UPDATE, key, value: undefined, result: middlewareResult, changedPaths }); // Ключ удалён — освобождаем слот версии, чтобы Map не рос на динамических ключах. this.keyVersions.delete(keyStr); await this.emitEvent({ type: StorageEvents.STORAGE_UPDATE, payload: { key, value: undefined, result: middlewareResult, changedPaths } }); } catch (error) { this.logger?.error('Error deleting value', { key, error }); throw error; } } async clear() { this.ensureReady(); try { await this.middlewareModule.dispatch({ type: 'clear' }); this._stateCache = {}; this.keyVersions.clear(); } catch (error) { this.logger?.error('Error clearing storage', { error }); throw error; } } async reset() { this.ensureReady(); try { const initialState = this.config.initialState; await this.middlewareModule.dispatch({ type: 'reset', value: initialState }); this._stateCache = initialState ? { ...initialState } : {}; this.keyVersions.clear(); const changedPaths = Object.keys(this._stateCache); this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_CLEAR, changedPaths }); this.emitEvent({ type: StorageEvents.STORAGE_CLEAR, payload: { changedPaths } }); } catch (error) { this.logger?.error('Error resetting storage', { error }); throw error; } } /** * SSR-гидрация: заменяет всё состояние переданным снапшотом. Намеренно НЕ требует * `ready()` — типичный сценарий вызвать её до `initialize()`, чтобы инициализация * не перезатёрла серверное состояние `initialState`-ом (см. `initializeWithMiddlewares`). */ async hydrate(state) { try { await this.doSet('', state); this._stateCache = await this.getRawState(); // Если включён persist-migration — фиксируем текущую версию: серверный снапшот // уже в актуальной схеме, миграцию на нём запускать не нужно. if (this.config.version !== undefined) { await this.writePersistedVersion(this.config.version); } const changedPaths = Object.keys(this._stateCache); for (const key of changedPaths){ this.notifySubscribers(key, this._stateCache[key]); } this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_UPDATE, key: changedPaths, value: this._stateCache, changedPaths }); } catch (error) { this.logger?.error('Error hydrating storage', { error }); throw error; } } async keys() { this.ensureReady(); try { return await this.middlewareModule.dispatch({ type: 'keys' }); } catch (error) { this.logger?.error('Error getting keys', { error }); throw error; } } async has(key) { this.ensureReady(); try { return await this.doHas(key); } catch (error) { this.logger?.error('Error checking value existence', { key, error }); throw error; } } async getState() { this.ensureReady(); return this.getRawState(); } // ─── Subscriptions (async — with race condition protection) ──────────────── subscribeByKey(key, callback) { if (!this.subscribers.has(key)) { this.subscribers.set(key, new Set()); } this.subscribers.get(key).add(callback); // Запоминаем версию ключа до асинхронного get(). // Если между вызовом get() и его разрешением произойдёт set(), // версия увеличится и мы не отправим устаревшее начальное значение. const versionAtSubscribe = this.keyVersions.get(key) ?? 0; this.get(key).then((value)=>{ try { const currentVersion = this.keyVersions.get(key) ?? 0; if (currentVersion === versionAtSubscribe) { callback(value); } } catch (error) { this.logger?.error('Error in initial callback', { key, error }); } }); return ()=>{ const subscribers = this.subscribers.get(key); if (subscribers) { subscribers.delete(callback); if (subscribers.size === 0) { this.subscribers.delete(key); } } }; } subscribeBySelector(pathSelector, callback) { const dummyState = createDummyState(); const fullPath = extractPath(pathSelector, dummyState, this.selectorPathCache); if (this.logger?.debug) { this.logger.debug('Subscribing to path:', { path: fullPath }); } const wrappedCallback = async (value)=>{ try { if (value === undefined || value === null) { const currentState = await this.getState(); const selectedValue = pathSelector(currentState); callback(selectedValue); return; } if (typeof value !== 'object' || value === null) { callback(value); return; } const currentState = await this.getState(); const selectedValue = pathSelector(currentState); callback(selectedValue); } catch (error) { this.logger?.error('Error in selector callback', { path: fullPath, error }); callback(value); } }; if (!fullPath) { return this.subscribeToAll(()=>{ this.getState().then((state)=>{ callback(pathSelector(state)); }); }); } return this.subscribeByKey(fullPath, wrappedCallback); } } export { AsyncBaseStorage }; //# sourceMappingURL=async-base-storage.service.js.map