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