UNPKG

synapse-storage

Version:

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

567 lines (544 loc) 22.1 kB
import { syncBatchingMiddleware } from "../middlewares/sync-storage-batching.middleware.js"; import { syncLoggerMiddleware } from "../middlewares/sync-storage-logger.middleware.js"; import { syncShallowCompareMiddleware } from "../middlewares/sync-storage-shallow-compare.middleware.js"; import { StorageEvents } from "../storage.interface.js"; import { SyncMiddlewareModule, 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"; /** * Базовый класс для синхронных хранилищ (Memory, LocalStorage). * * Все CRUD-операции выполняются синхронно. * Lifecycle (initialize, destroy) остаётся async. * subscribeByKey упрощён — нет race condition без async get. */ class SyncBaseStorage extends StorageCore { config; /** Sync-хранилище: доступно синхронное чтение кэша (getStateSync). */ isSync = true; middlewareModule; initializedMiddlewares = null; selectorPathCache = new WeakMap(); constructor(config, eventEmitter, logger){ super(config, eventEmitter, logger), this.config = config; this.middlewareModule = new SyncMiddlewareModule({ getState: ()=>this.getRawState(), doGet: this.doGet.bind(this), doSet: this.doSet.bind(this), doUpdate: this.doUpdate.bind(this), doRemove: this.doRemove.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 = this.getRawState(); } /** * Синхронная инициализация sync-хранилища. Вся работа Memory/LocalStorage * (`initializeMiddlewares` + `initializeWithMiddlewares`) синхронна — здесь она * выполняется без `await`, чтобы {@link StorageCore.initializeSync} мог довести * хранилище до `READY` в пределах одного тика (SSR sync-shell). */ performInitializeSync() { this.initializeMiddlewares(); this.initializeWithMiddlewares(); this._stateCache = this.getRawState(); } /** * Дефолт для `config.clearOnDestroy`, если он не задан. * Memory: `true` (эфемерное). LocalStorage переопределяет на `false` (персистентное). */ get clearOnDestroyDefault() { return true; } async performCleanup() { // Обходим публичный clear() (избегая ensureReady после _isDestroyed = true) if (this.config.clearOnDestroy ?? this.clearOnDestroyDefault) { this.doClear(); this.clearPersistedVersion(); } await this.doDestroy(); if (this.initializedMiddlewares) { this.initializedMiddlewares.forEach((m)=>m.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 = {})=>syncBatchingMiddleware(options), shallowCompare: (options = {})=>syncShallowCompareMiddleware(options), logger: (options = {})=>syncLoggerMiddleware(options) }; } initializeWithMiddlewares() { try { const state = this.getRawState(); const hasExistingState = Object.keys(state).length > 0; // Миграция выключена (version не задан) — прежнее поведение: засеять initialState на пустом. if (this.config.version === undefined) { if (!hasExistingState && this.config.initialState) { this.middlewareModule.dispatch({ type: 'init', value: this.config.initialState }); } return; } const decision = decideMigration({ hasExisting: hasExistingState, existingState: state, persistedVersion: this.readPersistedVersion(), targetVersion: this.config.version, migrate: this.config.migrate }); switch(decision.kind){ case 'seed': { if (this.config.initialState) { this.middlewareModule.dispatch({ type: 'init', value: this.config.initialState }); } this.writePersistedVersion(this.config.version); break; } case 'migrate': { this.middlewareModule.dispatch({ type: 'reset', value: decision.state }); this.writePersistedVersion(this.config.version); break; } case 'bump': { this.writePersistedVersion(this.config.version); break; } case 'none': break; } } catch (error) { this.logger?.error('Ошибка инициализации хранилища', { error }); throw error; } } // ─── Persisted schema version (persist-migration) ─────────────────────────── /** Читает сохранённую версию схемы. По умолчанию `undefined` (эфемерные хранилища). */ readPersistedVersion() { return undefined; } /** Сохраняет версию схемы рядом с данными. По умолчанию no-op (эфемерные хранилища). */ writePersistedVersion(_version) {} /** Удаляет сохранённую версию схемы (вызывается при destroy с `clearOnDestroy`). */ clearPersistedVersion() {} // ─── Internal state access ────────────────────────────────────────────────── getRawState() { try { const value = this.doGet(''); return value || {}; } catch (error) { this.logger?.error('Error getting state', { error }); throw error; } } // ─── Public sync API ──────────────────────────────────────────────────────── get(key) { this.ensureReady(); try { const metadata = { operation: 'get', timestamp: Date.now(), key }; const finalResult = this.middlewareModule.dispatch({ type: 'get', key, metadata }); // Чтения не эмитят событий — это горячий путь, а STORAGE_SELECT нигде не потребляется. return finalResult; } catch (error) { this.logger?.error('Error getting value', { key, error }); throw error; } } set(key, value) { this.ensureReady(); try { const metadata = { operation: 'set', timestamp: Date.now(), key }; const finalResult = this.middlewareModule.dispatch({ type: 'set', key, value, metadata }); if (finalResult === VALUE_NOT_CHANGED) return; this._stateCache = 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 }); this.emitEvent({ type: StorageEvents.STORAGE_UPDATE, payload: { key, value: finalResult, changedPaths } }); } catch (error) { this.logger?.error('Error setting value', { key, error }); throw error; } } update(updater) { this.ensureReady(); try { const metadata = { operation: 'update', timestamp: Date.now() }; const currentState = this.getState(); const newState = createLazyClone(currentState); updater(newState); const changedPaths = findChangedPaths(currentState, newState); if (changedPaths.size === 0) { this.logger?.debug?.('No changes detected in update'); return; } 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 = 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) { this.logger?.debug?.('No actual changes after middleware processing'); return; } const finalUpdates = {}; actuallyChangedKeys.forEach((key)=>{ finalUpdates[key] = updatedValues[key]; }); this.logger?.debug?.('Notifying subscribers about changes:', { keys: actuallyChangedKeys }); // Читаем кэш из стора (как в set()), а не пересобираем из currentState+finalUpdates: // иначе ключи, дописанные middleware во время dispatch (напр. `errors`), терялись бы // в getStateSync() / useStorageSubscribe. this._stateCache = this.getRawState(); 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 }); } } 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; } } remove(key) { this.ensureReady(); try { const metadata = { operation: 'delete', timestamp: Date.now(), key }; const middlewareResult = this.middlewareModule.dispatch({ type: 'delete', key, metadata }); if (middlewareResult === false) return; this._stateCache = 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 }); 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; } } clear() { this.ensureReady(); try { this.middlewareModule.dispatch({ type: 'clear' }); this._stateCache = {}; } catch (error) { this.logger?.error('Error clearing storage', { error }); throw error; } } reset() { this.ensureReady(); try { const initialState = this.config.initialState; this.middlewareModule.dispatch({ type: 'reset', value: initialState }); this._stateCache = initialState ? { ...initialState } : {}; 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`). */ hydrate(state) { try { this.doSet('', state); this._stateCache = this.getRawState(); // Если включён persist-migration — фиксируем текущую версию: серверный снапшот // уже в актуальной схеме, миграцию на нём запускать не нужно. if (this.config.version !== undefined) { this.writePersistedVersion(this.config.version); } this.notifyHydration(this._stateCache); } catch (error) { this.logger?.error('Error hydrating storage', { error }); throw error; } } /** Уведомляет подписчиков о замене состояния гидрацией (no-op до initialize/без подписок). */ notifyHydration(state) { const changedPaths = Object.keys(state); for (const key of changedPaths){ this.notifySubscribers(key, state[key]); } this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_UPDATE, key: changedPaths, value: state, changedPaths }); } keys() { this.ensureReady(); try { return this.middlewareModule.dispatch({ type: 'keys' }); } catch (error) { this.logger?.error('Error getting keys', { error }); throw error; } } has(key) { this.ensureReady(); try { return this.doHas(key); } catch (error) { this.logger?.error('Error checking value existence', { key, error }); throw error; } } getState() { this.ensureReady(); return this.getRawState(); } // ─── Subscriptions (4.4 — simplified for sync) ───────────────────────────── /** * Sync-версия subscribeByKey. * Нет race condition (get() синхронный) → не нужен keyVersions tracking. */ subscribeByKey(key, callback) { if (!this.subscribers.has(key)) { this.subscribers.set(key, new Set()); } this.subscribers.get(key).add(callback); // Синхронно получаем начальное значение и сразу вызываем callback try { const value = this.get(key); 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); this.logger?.debug?.('Subscribing to path:', { path: fullPath }); // Sync-обёртка: получаем текущее состояние синхронно и применяем селектор const wrappedCallback = (value)=>{ try { if (value === undefined || value === null || typeof value === 'object') { const currentState = this.getState(); const selectedValue = pathSelector(currentState); callback(selectedValue); return; } callback(value); } catch (error) { this.logger?.error('Error in selector callback', { path: fullPath, error }); callback(value); } }; if (!fullPath) { return this.subscribeToAll(()=>{ callback(pathSelector(this.getState())); }); } return this.subscribeByKey(fullPath, wrappedCallback); } } export { SyncBaseStorage }; //# sourceMappingURL=sync-base-storage.service.js.map