UNPKG

synapse-storage

Version:

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

657 lines (646 loc) 30 kB
import { SingletonMixin } from "../modules/singleton/mixin.util.js"; import { StorageKey } from "../utils/storage-key.js"; import { AsyncBaseStorage } from "./async-base-storage.service.js"; import { getValueByPath, parsePath, setValueByPath } from "./path.utils.js"; /** * Reserved-ключ записи с версией схемы (persist-migration). Хранится в том же сторе, что и * данные, но исключается из `getState()`/`keys()` и переживает `clear()`/`set('')`, когда * задана `config.version` (иначе — обычный быстрый `store.clear()`). */ const VERSION_META_KEY = '__synapse_version__'; // Управляет соединением с базой данных class IndexedDBManager { dbName; logger; static instances = new Map(); db = null; initPromise = null; storeNames = new Set(); dbVersion; // Последовательная очередь операций изменения схемы (создание сторов). // На одну БД (singleton по dbName) обычно инициализируется сразу несколько // ApiClient-ов (comments, posts, reactions, ...). Без сериализации их // ensureStoreExists перекрывались: один обнулял this.db между close() и // переоткрытием, второй в этот момент читал this.db.objectStoreNames → null. opQueue = Promise.resolve(); constructor(dbName, dbVersion, logger){ this.dbName = dbName; this.logger = logger; this.dbVersion = dbVersion; } static getInstance(dbName, dbVersion = 1, logger) { if (!IndexedDBManager.instances.has(dbName)) { IndexedDBManager.instances.set(dbName, new IndexedDBManager(dbName, dbVersion, logger)); } const instance = IndexedDBManager.instances.get(dbName); // Update version if higher version is requested if (dbVersion > instance.dbVersion) { instance.dbVersion = dbVersion; } return instance; } async initialize() { if (this.db) { return this.db; } if (!this.initPromise) { this.initPromise = this.autoDetectAndOpen(); } return this.initPromise; } async autoDetectAndOpen() { // Auto-detect the current DB version to avoid VersionError const currentVersion = await this.detectCurrentVersion(); if (currentVersion > this.dbVersion) { this.logger?.debug(`Auto-detected higher DB version: ${currentVersion} (requested: ${this.dbVersion})`); this.dbVersion = currentVersion; } return this.openDatabase(); } detectCurrentVersion() { return new Promise((resolve)=>{ try { const request = indexedDB.open(this.dbName); request.onsuccess = ()=>{ const version = request.result.version; request.result.close(); resolve(version); }; request.onerror = ()=>resolve(0); } catch { resolve(0); } }); } async ensureStoreExists(storeName) { return this.enqueue(()=>this.ensureStoresInternal([ storeName ])); } /** * Ставит операцию изменения схемы в последовательную очередь, чтобы создания * сторов на одной БД не перекрывались между await-точками. Очередь не должна * "застревать" из-за упавшей операции — ошибку прокидываем вызывающему, но * следующий элемент очереди стартует независимо от результата предыдущего. */ enqueue(operation) { const result = this.opQueue.then(operation, operation); this.opQueue = result.then(()=>undefined, ()=>undefined); return result; } /** * Идемпотентно гарантирует наличие переданных сторов. Никогда не обращается к * this.db после await — работает с локальной ссылкой, возвращённой initialize() * / openDatabase(), поэтому параллельное обнуление this.db (другим клиентом * или onversionchange) не приводит к чтению свойств у null. */ async ensureStoresInternal(storeNames) { let db = await this.initialize(); const missingStores = storeNames.filter((name)=>!db.objectStoreNames.contains(name)); if (missingStores.length === 0) { for (const name of storeNames)this.storeNames.add(name); return db; } this.logger?.debug(`Создание недостающих хранилищ: ${missingStores.join(', ')}`, { dbName: this.dbName, currentStores: Array.from(db.objectStoreNames) }); db.close(); this.db = null; this.dbVersion++; this.initPromise = this.openDatabase(missingStores); db = await this.initPromise; for (const name of storeNames)this.storeNames.add(name); return db; } async openDatabase(newStores = []) { return new Promise((resolve, reject)=>{ this.logger?.debug(`Opening database "${this.dbName}" with version ${this.dbVersion}`); const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = ()=>{ this.logger?.error(`Failed to open database "${this.dbName}"`, { error: request.error }); reject(request.error); }; request.onblocked = ()=>{ this.logger?.warn(`Database "${this.dbName}" upgrade blocked by another connection. Close other tabs or connections.`); reject(new Error(`Database "${this.dbName}" upgrade blocked — close other tabs using this database`)); }; request.onsuccess = ()=>{ this.db = request.result; // Auto-close when another connection requests a version upgrade this.db.onversionchange = ()=>{ this.db?.close(); this.db = null; this.initPromise = null; }; // Add existing stores to our set for(let i = 0; i < this.db.objectStoreNames.length; i++){ this.storeNames.add(this.db.objectStoreNames[i]); } this.logger?.debug(`Database "${this.dbName}" opened successfully`, { version: this.db.version, stores: Array.from(this.db.objectStoreNames) }); resolve(this.db); }; request.onupgradeneeded = (event)=>{ const db = event.target.result; this.logger?.debug(`Upgrading database "${this.dbName}" to version ${this.dbVersion}`); // Create new stores that don't exist yet for (const storeName of newStores){ if (!db.objectStoreNames.contains(storeName)) { this.logger?.debug(`Creating store "${storeName}"`); db.createObjectStore(storeName); } } }; }); } closeDatabase() { if (this.db) { this.db.close(); this.db = null; this.initPromise = null; } } async deleteDatabase() { this.closeDatabase(); return new Promise((resolve, reject)=>{ const request = indexedDB.deleteDatabase(this.dbName); request.onsuccess = ()=>{ this.logger?.debug(`Database "${this.dbName}" deleted successfully`); IndexedDBManager.instances.delete(this.dbName); this.storeNames.clear(); resolve(); }; request.onerror = ()=>{ this.logger?.error(`Failed to delete database "${this.dbName}"`, { error: request.error }); reject(request.error); }; }); } async ensureStoresExist(storeNames) { return this.enqueue(()=>this.ensureStoresInternal(storeNames)); } // Метод для получения текущей версии getCurrentVersion() { return this.dbVersion; } } class IndexedDBStorage extends AsyncBaseStorage { static STORAGE_TYPE = 'indexedDB'; type = 'indexedDB'; DB_NAME; STORE_NAME; dbManager; constructor(config, eventEmitter, logger){ super(config, eventEmitter, logger); this.DB_NAME = config.options?.dbName || 'app_storage'; this.STORE_NAME = config.name; // Get database manager instance (version is auto-detected internally) this.dbManager = IndexedDBManager.getInstance(this.DB_NAME, 1, logger); } static create(config, eventEmitter, logger) { return SingletonMixin.handleSingletonCreation(config, this.STORAGE_TYPE, (finalConfig)=>new IndexedDBStorage(finalConfig, eventEmitter, logger), logger); } async doInitialize() { try { this.logger?.debug(`Initializing IndexedDB storage "${this.STORE_NAME}"`); // Создаем store в базе данных await this.dbManager.ensureStoreExists(this.STORE_NAME); // Проверяем, что хранилище доступно const db = await this.dbManager.initialize(); if (!db.objectStoreNames.contains(this.STORE_NAME)) { throw new Error(`Store "${this.STORE_NAME}" not found after initialization`); } // Инициализируем middleware this.initializeMiddlewares(); // Инициализируем с middleware await this.initializeWithMiddlewares(); this.logger?.debug(`IndexedDB storage "${this.STORE_NAME}" initialized successfully`); return this; } catch (error) { this.logger?.error(`Ошибка инициализации IndexedDB "${this.name}"`, { error }); throw error; } } static async createStorages(dbName, configs, logger) { // Используем единый IndexedDBManager (версия определяется автоматически) const dbManager = IndexedDBManager.getInstance(dbName, 1, logger); // Получаем имена всех хранилищ, которые нужно создать const storeNames = Object.values(configs).map((config)=>config.name); // Предварительно создаем все хранилища в рамках одной операции await dbManager.ensureStoresExist(storeNames); // Создаем и инициализируем все хранилища const result = {}; for (const [key, config] of Object.entries(configs)){ const storage = new IndexedDBStorage({ ...config, options: { dbName } }, config.eventEmitter, logger); // Инициализируем хранилище result[key] = await storage.initialize(); } return result; } // ─── IndexedDB-specific API ──────────────────────────────────────────────── /** * Выполняет операцию в рамках IDB-транзакции. * Обёртка для низкоуровневого IDB transaction API. * * @param mode - Режим транзакции ('readonly' | 'readwrite') * @param fn - Callback, получающий IDBObjectStore. Возвращает результат операции. * @returns Promise с результатом callback-а */ async transaction(mode, fn) { const store = await this.getObjectStore(mode); return new Promise((resolve, reject)=>{ const tx = store.transaction; tx.onerror = ()=>reject(tx.error); tx.onabort = ()=>reject(tx.error || new Error('Transaction aborted')); try { const result = fn(store); if (result instanceof IDBRequest) { result.onsuccess = ()=>resolve(result.result); result.onerror = ()=>reject(result.error); } else { // Sync result — resolve when transaction completes tx.oncomplete = ()=>resolve(result); } } catch (error) { reject(error); } }); } /** * Текущая версия базы данных. */ get dbVersion() { return this.dbManager.getCurrentVersion(); } /** * Имя базы данных. */ get dbName() { return this.DB_NAME; } /** * Имя object store в IndexedDB. */ get storeName() { return this.STORE_NAME; } // ─── Private helpers ─────────────────────────────────────────────────────── async getTransaction(mode = 'readonly') { try { // Ensure database is open and our store exists const db = await this.dbManager.ensureStoreExists(this.STORE_NAME); // Проверяем существование хранилища перед созданием транзакции if (!db.objectStoreNames.contains(this.STORE_NAME)) { // Попытка исправить проблему - закрываем и снова открываем this.logger?.warn(`Object store "${this.STORE_NAME}" not found, attempting to repair`); db.close(); this.dbManager.closeDatabase(); // Пробуем заново создать хранилище с инкрементом версии const newDb = await this.dbManager.ensureStoreExists(this.STORE_NAME); if (!newDb.objectStoreNames.contains(this.STORE_NAME)) { throw new Error(`Object store "${this.STORE_NAME}" still doesn't exist after repair attempt`); } return newDb.transaction(this.STORE_NAME, mode); } return db.transaction(this.STORE_NAME, mode); } catch (error) { this.logger?.error(`Failed to create transaction for store "${this.STORE_NAME}"`, { error }); throw error; } } async getObjectStore(mode = 'readonly') { const transaction = await this.getTransaction(mode); return transaction.objectStore(this.STORE_NAME); } async doGet(key) { const store = await this.getObjectStore(); // Для пустого ключа возвращаем все состояние if (key === '') { return new Promise((resolve, reject)=>{ const request = store.getAll(); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>{ const allValues = request.result; const allKeys = store.getAllKeys(); allKeys.onsuccess = ()=>{ const state = allKeys.result.reduce((acc, k, index)=>{ if (k !== 'root' && k !== VERSION_META_KEY) { acc[k] = allValues[index]; } return acc; }, {}); resolve(state); }; allKeys.onerror = ()=>reject(allKeys.error); }; }); } // Проверяем, является ли ключ "сырым" if (key instanceof StorageKey && key.isUnparseable()) { return new Promise((resolve, reject)=>{ const request = store.get(key.valueOf()); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>resolve(request.result); }); } // Для вложенного пути const parts = parsePath(key); if (parts.length > 1) { const rootKey = parts[0]; return new Promise((resolve, reject)=>{ const request = store.get(rootKey); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>{ const rootValue = request.result; if (!rootValue) { resolve(undefined); return; } const value = getValueByPath(rootValue, parts.slice(1).join('.')); resolve(value); }; }); } // Для простого ключа return new Promise((resolve, reject)=>{ const request = store.get(parts[0]); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>resolve(request.result); }); } async doSet(key, value) { // Для пустого ключа устанавливаем все состояние if (key === '') { const store = await this.getObjectStore('readwrite'); const preserveVersion = this.config.version !== undefined; return new Promise((resolve, reject)=>{ const tx = store.transaction; tx.oncomplete = ()=>{ resolve(); }; tx.onerror = ()=>{ reject(tx.error); }; const writeEntries = ()=>{ const entries = Object.entries(value); for (const [entryKey, entryValue] of entries){ if (entryKey === VERSION_META_KEY) continue; store.put(entryValue, entryKey); } }; // С persist-migration сохраняем reserved version-запись при перезаписи всего состояния. if (preserveVersion) { const keysReq = store.getAllKeys(); keysReq.onerror = ()=>reject(keysReq.error); keysReq.onsuccess = ()=>{ for (const k of keysReq.result){ if (k !== VERSION_META_KEY) store.delete(k); } writeEntries(); }; return; } const clearRequest = store.clear(); clearRequest.onsuccess = ()=>writeEntries(); clearRequest.onerror = ()=>reject(clearRequest.error); }); } const store = await this.getObjectStore('readwrite'); // Для "сырого" ключа if (key instanceof StorageKey && key.isUnparseable()) { await this.putValueInStore(store, key.valueOf(), value); return; } // Для вложенного пути const parts = parsePath(key); if (parts.length > 1) { const rootKey = parts[0]; return new Promise((resolve, reject)=>{ const request = store.get(rootKey); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>{ const rootValue = request.result || {}; const updatedValue = setValueByPath(rootValue, parts.slice(1).join('.'), value); const putRequest = store.put(updatedValue, rootKey); putRequest.onerror = ()=>reject(putRequest.error); putRequest.onsuccess = ()=>resolve(); }; }); } // Для простого ключа await this.putValueInStore(store, parts[0], value); } async putValueInStore(store, key, value) { return new Promise((resolve, reject)=>{ const request = store.put(value, key.valueOf()); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>resolve(); }); } async doUpdate(updates) { // Группируем обновления const updatesByRoot = new Map(); const rawUpdates = []; // Разделяем обновления на "сырые" и обычные for (const { key, value } of updates){ if (key instanceof StorageKey && key.isUnparseable()) { rawUpdates.push({ key: key.valueOf(), value }); continue; } const parts = parsePath(key); const rootKey = parts[0]; const path = parts.slice(1); if (!updatesByRoot.has(rootKey)) { updatesByRoot.set(rootKey, []); } updatesByRoot.get(rootKey).push({ path, value }); } // Одна транзакция на весь doUpdate — атомарность const transaction = await this.getTransaction('readwrite'); const store = transaction.objectStore(this.STORE_NAME); return new Promise((resolve, reject)=>{ transaction.oncomplete = ()=>resolve(); transaction.onerror = ()=>{ this.logger?.error('Error during update:', { error: transaction.error }); reject(transaction.error); }; transaction.onabort = ()=>{ this.logger?.error('Update transaction aborted:', { error: transaction.error }); reject(transaction.error || new Error('Transaction aborted')); }; // Обрабатываем "сырые" обновления for (const { key, value } of rawUpdates){ store.put(value, key); } // Обрабатываем сгруппированные обновления // Для каждого rootKey: читаем текущее значение, применяем все path-обновления, записываем обратно const rootKeys = Array.from(updatesByRoot.keys()); if (rootKeys.length === 0) { // Нет сгруппированных обновлений — транзакция завершится сама return; } for (const rootKey of rootKeys){ const getRequest = store.get(rootKey); getRequest.onsuccess = ()=>{ const rootValue = getRequest.result || {}; let updatedValue = { ...rootValue }; const pathUpdates = updatesByRoot.get(rootKey); for (const { path, value } of pathUpdates){ if (path.length === 0) { updatedValue = value; } else { updatedValue = setValueByPath(updatedValue, path.join('.'), value); } } store.put(updatedValue, rootKey); }; } }); } async doDelete(key) { const store = await this.getObjectStore('readwrite'); // Для "сырого" ключа if (key instanceof StorageKey && key.isUnparseable()) { return new Promise((resolve, reject)=>{ const request = store.delete(key.valueOf()); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>resolve(true); }); } const parts = parsePath(key); // Для простого ключа if (parts.length === 1) { return new Promise((resolve, reject)=>{ const request = store.delete(parts[0]); request.onerror = ()=>reject(request.error); request.onsuccess = ()=>resolve(true); }); } // Для вложенного пути const rootKey = parts[0]; return new Promise((resolve, reject)=>{ const getRequest = store.get(rootKey); getRequest.onerror = ()=>reject(getRequest.error); getRequest.onsuccess = ()=>{ const rootValue = getRequest.result; if (!rootValue) { resolve(false); return; } // rootValue уже соответствует parts[0] (каждый top-level ключ — отдельная запись стора), // поэтому путь к родителю отсчитывается ВНУТРИ rootValue: parts.slice(1, -1) (ср. doGet). const innerParentPath = parts.slice(1, -1).join('.'); const parent = innerParentPath ? getValueByPath(rootValue, innerParentPath) : rootValue; const lastKey = parts[parts.length - 1]; if (!parent || !(lastKey in parent)) { resolve(false); return; } if (Array.isArray(parent)) { const index = parseInt(lastKey, 10); if (!isNaN(index)) { parent.splice(index, 1); } else { // @ts-ignore delete parent[lastKey]; } } else { delete parent[lastKey]; } const putRequest = store.put(rootValue, rootKey); putRequest.onerror = ()=>reject(putRequest.error); putRequest.onsuccess = ()=>resolve(true); }; }); } async doClear() { const store = await this.getObjectStore('readwrite'); // С persist-migration сохраняем reserved version-запись (она описывает схему, не данные). if (this.config.version !== undefined) { return new Promise((resolve, reject)=>{ const tx = store.transaction; tx.oncomplete = ()=>resolve(); tx.onerror = ()=>reject(tx.error); const keysReq = store.getAllKeys(); keysReq.onerror = ()=>reject(keysReq.error); keysReq.onsuccess = ()=>{ for (const k of keysReq.result){ if (k !== VERSION_META_KEY) store.delete(k); } }; }); } return new Promise((resolve, reject)=>{ const request = store.clear(); request.onsuccess = ()=>resolve(); request.onerror = ()=>reject(request.error); }); } async doKeys() { const store = await this.getObjectStore(); const request = store.getAllKeys(); return new Promise((resolve, reject)=>{ request.onsuccess = ()=>{ resolve(request.result.filter((k)=>k !== VERSION_META_KEY)); }; request.onerror = ()=>reject(request.error); }); } // ─── Persisted schema version (persist-migration) ─────────────────────────── async readPersistedVersion() { const store = await this.getObjectStore(); return new Promise((resolve)=>{ const request = store.get(VERSION_META_KEY); request.onsuccess = ()=>resolve(typeof request.result === 'number' ? request.result : undefined); request.onerror = ()=>resolve(undefined); }); } async writePersistedVersion(version) { const store = await this.getObjectStore('readwrite'); return new Promise((resolve, reject)=>{ const request = store.put(version, VERSION_META_KEY); request.onsuccess = ()=>resolve(); request.onerror = ()=>reject(request.error); }); } async doHas(key) { const value = await this.doGet(key); return value !== undefined; } /** * Override performCleanup: persistent storage should NOT clear data on destroy. * Only clean up middleware and runtime resources, not persisted data. * Прогоняем cleanup мидлвар (иначе их каналы/таймеры текут после destroy — R8), * но сознательно пропускаем doClear() — данные переживают lifecycle компонента. */ async performCleanup() { await this.doDestroy(); await this.cleanupMiddlewares(); } async doDestroy() { // Persistent storage: do NOT clear data on destroy. // Only release runtime resources. Data persists across component lifecycles. } } export { IndexedDBManager, IndexedDBStorage }; //# sourceMappingURL=indexed-DB.service.js.map