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