UNPKG

synapse-storage

Version:

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

281 lines (262 loc) 13.4 kB
import { SingletonMixin } from "../modules/singleton/mixin.util.js"; import { StorageEvents } from "../storage.interface.js"; import { findChangedPaths } from "../utils/state-diff.util.js"; import { StorageKey } from "../utils/storage-key.js"; import { AsyncBaseStorage } from "./async-base-storage.service.js"; import { getValueByPath, parsePath, removeValueByPath, setValueByPath } from "./path.utils.js"; import { GLOBAL_SUBSCRIPTION_KEY } from "./storage-core.js"; import { SharedWorkerBackend } from "./worker-backends/shared-worker-backend.js"; /** * Асинхронное хранилище поверх общего key-value стора внутри SharedWorker. * * Это тот же принцип, что у {@link MemoryStorage} (данные в `Map`), но `Map` живёт * ВНУТРИ SharedWorker и потому разделяется между вкладками. Если SharedWorker * недоступен (тесты/SSR/CSP), {@link WorkerChannel} прозрачно откатывается на * in-process `Map` — round-trip идентичен MemoryStorage, но БЕЗ межвкладочного * шеринга (это даёт только настоящий SharedWorker). * * Все `do*` реализованы через RPC к стору ({@link WorkerChannel.request}) и повторяют * маппинг ключей/путей MemoryStorage (те же `getValueByPath`/`setValueByPath`/ * `removeValueByPath`), поэтому поведение совпадает с MemoryStorage «ключ-в-ключ». * * Записи атомарны на уровне ключа (per-key `set`/`update`/`delete`), поэтому * конкурентные вкладки не затирают чужие ключи. После мутации из другой вкладки * стор рассылает уведомление — адаптер обновляет свой кэш и будит подписчиков. */ class WorkerCacheStorage extends AsyncBaseStorage { static STORAGE_TYPE = 'worker'; type = 'worker'; /** Живой кэш; `shared` честно отражает, реально ли работает кросс-табный SharedWorker. */ capabilities; channelName; backend; unsubscribeMutation; // Коалесцирование refresh'ей: если во время await getAll() приходит ещё одна мутация, // не запускаем перекрывающийся refresh (он диффил бы против того же устаревшего `prev` // и повторно уведомил бы подписчиков) — помечаем dirty и прогоняем ровно один раз после. refreshInFlight = false; refreshDirty = false; constructor(config, eventEmitter, logger){ super(config, eventEmitter, logger); this.channelName = config.options?.channelName ?? config.name; this.backend = new SharedWorkerBackend(this.channelName, { workerUrl: config.options?.workerUrl, requestTimeoutMs: config.options?.requestTimeoutMs }); // Честный capabilities: shared только если реально поднялся SharedWorker // (иначе — in-process фолбэк без кросс-табного шеринга). this.capabilities = { shared: this.backend.shared, sync: false }; if (!this.backend.shared) { this.logger?.debug(`WorkerCacheStorage "${this.name}" работает в in-process режиме (SharedWorker недоступен) — без кросс-табного шеринга`); } } static create(config, eventEmitter, logger) { return SingletonMixin.handleSingletonCreation(config, this.STORAGE_TYPE, (finalConfig)=>new WorkerCacheStorage(finalConfig, eventEmitter, logger), logger); } async doInitialize() { try { this.logger?.debug(`Initializing WorkerCacheStorage "${this.name}"`); await this.backend.connect(); this.subscribeToStoreMutations(); this.initializeMiddlewares(); await this.initializeWithMiddlewares(); this.logger?.debug(`WorkerCacheStorage "${this.name}" initialized successfully`); return this; } catch (error) { this.logger?.error('Error initializing WorkerCacheStorage', { error }); throw error; } } // ─── Cross-tab subscription hook (R3) ──────────────────────────────────────── /** * Подписка на мутации из ДРУГИХ вкладок/экземпляров. Воркер рассылает STORE_MUTATION * после каждой мутирующей операции; payload несёт только `{op}`, поэтому мы просто * перечитываем полное состояние, обновляем `_stateCache` и будим подписчиков (как * при локальной записи). Без этого хука `subscribe()` и tagIndex в QueryStorage * устаревали бы для записей, сделанных другими вкладками. */ subscribeToStoreMutations() { this.unsubscribeMutation = this.backend.onMutation(()=>{ this.scheduleRefresh(); }); } /** * Сериализует refresh'и: пока один в полёте, следующие мутации лишь взводят `dirty`, * и после завершения текущего прогоняется ровно один догоняющий refresh. Так каждый * refresh диффит против актуального `_stateCache`, а не против устаревшего снапшота — * нет дублей уведомлений подписчиков одними и теми же значениями. */ scheduleRefresh() { if (this.refreshInFlight) { this.refreshDirty = true; return; } this.refreshInFlight = true; void this.runRefreshLoop(); } async runRefreshLoop() { try { do { this.refreshDirty = false; await this.refreshFromRemoteMutation(); }while (this.refreshDirty); } catch (error) { this.logger?.error('Error refreshing state after remote mutation', { error }); } finally{ this.refreshInFlight = false; } } async refreshFromRemoteMutation() { const prev = this._stateCache; const state = await this.getRawState(); this._stateCache = state; const changed = findChangedPaths(prev ?? {}, state); if (changed.size === 0) return; const changedPaths = Array.from(changed); // Пер-ключевые подписчики: top-level ключи + полные пути (как в update()). const topLevelKeys = new Set(); for (const path of changedPaths){ topLevelKeys.add(path.split('.')[0]); } for (const key of topLevelKeys){ this.notifySubscribers(key, state[key]); } for (const path of changedPaths){ if (!topLevelKeys.has(path)) { this.notifySubscribers(path, getValueByPath(state, path)); } } this.notifySubscribers(GLOBAL_SUBSCRIPTION_KEY, { type: StorageEvents.STORAGE_UPDATE, changedPaths, source: 'worker' }); } // ─── do* (зеркалит MemoryStorage, но асинхронно через RPC) ─────────────────── async doGet(key) { if (key === '') return this.backend.getAll(); if (key instanceof StorageKey && key.isUnparseable()) { return this.backend.get(key.valueOf()); } const parts = parsePath(key); if (parts.length === 1) { return this.backend.get(parts[0]); } // Вложенный путь: читаем только нужный top-level ключ, затем спускаемся по пути. const rootValue = await this.backend.get(parts[0]); if (rootValue === undefined) return undefined; return getValueByPath(rootValue, parts.slice(1).join('.')); } async doSet(key, value) { // Whole-state запись остаётся на setAll (гидрация/reset/init). if (key === '') { await this.backend.setAll(value); return; } // Атомарный per-key set — конкурентные вкладки не затирают чужие ключи (R2). if (key instanceof StorageKey && key.isUnparseable()) { await this.backend.set(key.valueOf(), value); return; } const parts = parsePath(key); if (parts.length === 1) { await this.backend.set(parts[0], value); return; } // Вложенный путь: read-modify-write ТОЛЬКО корневого ключа (не всего состояния). const rootKey = parts[0]; const rootValue = await this.backend.get(rootKey); const newRoot = setValueByPath(rootValue, parts.slice(1).join('.'), value); await this.backend.set(rootKey, newRoot); } async doUpdate(updates) { const entries = []; // Вложенные пути группируем по корню — читаем каждый корень один раз. const nestedByRoot = new Map(); for (const { key, value } of updates){ if (key instanceof StorageKey && key.isUnparseable()) { entries.push({ key: key.valueOf(), value }); continue; } const parts = parsePath(key); if (parts.length === 1) { entries.push({ key: parts[0], value }); } else { const rootKey = parts[0]; if (!nestedByRoot.has(rootKey)) nestedByRoot.set(rootKey, []); nestedByRoot.get(rootKey).push({ path: parts.slice(1).join('.'), value }); } } for (const [rootKey, patches] of nestedByRoot){ let rootValue = await this.backend.get(rootKey); for (const { path, value } of patches){ rootValue = setValueByPath(rootValue, path, value); } entries.push({ key: rootKey, value: rootValue }); } if (entries.length > 0) { // Атомарный per-key батч (set по каждому ключу), а не clear+setAll (R2). await this.backend.update(entries); } } async doDelete(key) { if (key instanceof StorageKey && key.isUnparseable()) { return this.backend.delete(key.valueOf()); } const parts = parsePath(key); if (parts.length === 1) { // Атомарное per-key удаление (R2). return this.backend.delete(parts[0]); } // Вложенный путь: read-modify-write ТОЛЬКО корневого ключа. const rootKey = parts[0]; const rootValue = await this.backend.get(rootKey); if (rootValue === undefined) return false; const { state: newRoot, removed } = removeValueByPath(rootValue, parts.slice(1).join('.')); if (!removed) return false; await this.backend.set(rootKey, newRoot); return true; } async doClear() { await this.backend.clear(); } async doKeys() { return this.backend.keys(); } async doHas(key) { const value = await this.doGet(key); return value !== undefined; } /** * Живой кэш НЕ очищается при destroy: данные живут в воркере и разделяются между * вкладками — уничтожение адаптера одной вкладкой не должно стирать общий кэш. * Поэтому переопределяем performCleanup (как IndexedDBStorage), пропуская doClear(), * но ОБЯЗАТЕЛЬНО прогоняя cleanup мидлвар (иначе их каналы/таймеры текут — R8). */ async performCleanup() { await this.doDestroy(); await this.cleanupMiddlewares(); } async doDestroy() { // Отписываемся от мутаций и закрываем бэкенд; данные в сторе остаются (live-cache). this.unsubscribeMutation?.(); this.unsubscribeMutation = undefined; this.backend.close(); } } export { WorkerCacheStorage }; //# sourceMappingURL=worker-storage.service.js.map