Управление singleton-экземпляром базы данных

Базовая проблема множественных экземпляров IndexedDB

IndexedDB в браузере допускает одновременное открытие одной и той же базы данных из разных контекстов выполнения. Однако при работе с Dexie.js множественные экземпляры Dexie-базы, созданные в рамках одного приложения, приводят к ряду архитектурных и эксплуатационных проблем:

  • дублирование подключения к одной физической базе IndexedDB
  • конфликты миграций (version().upgrade)
  • непредсказуемый порядок выполнения хуков (on('ready'), populate)
  • избыточные операции открытия соединения
  • сложность синхронизации состояния кэша и транзакций

Особенно критично это проявляется в SPA-приложениях, где модули могут импортироваться повторно в разных частях дерева зависимостей или при горячей перезагрузке (HMR).

Природа singleton в контексте Dexie.js

Singleton-экземпляр базы данных в Dexie.js — это не просто глобальная переменная, а гарантированная точка доступа к единственному экземпляру Dexie, связанного с конкретной физической IndexedDB-базой.

Ключевой смысл:

  • один экземпляр Dexie
  • один lifecycle открытия/закрытия соединения
  • единая схема версий
  • централизованное управление транзакциями

В отличие от обычных singleton-паттернов, здесь важно учитывать асинхронную природу db.open() и состояние подключения.


Базовая реализация singleton через модуль

Наиболее распространённый подход основан на кэшировании экземпляра в области модуля ES Modules.

import Dexie from 'dexie';

class AppDatabase extends Dexie {
  constructor() {
    super('app_database');

    this.version(1).stores({
      users: '++id, email, name',
      posts: '++id, userId, title'
    });
  }
}

let dbInstance = null;

export function getDB() {
  if (!dbInstance) {
    dbInstance = new AppDatabase();
  }
  return dbInstance;
}

Ключевая особенность:

  • dbInstance существует в замыкании модуля
  • повторные импорты не создают новый объект
  • гарантируется единая точка доступа

Учет асинхронного открытия базы

Dexie требует явного открытия соединения через db.open(), которое возвращает Promise. Это создает проблему гонки инициализации при параллельных вызовах.

Проблема конкурентного открытия

При таком коде:

const db1 = getDB();
const db2 = getDB();

db1.open();
db2.open();

возможны:

  • двойной вызов открытия IndexedDB
  • конфликт состояния open/closed
  • лишние события ready

Решение: promise-based singleton initialization

Корректный подход заключается в кэшировании Promise открытия:

import Dexie from 'dexie';

class AppDatabase extends Dexie {
  constructor() {
    super('app_database');

    this.version(1).stores({
      users: '++id, email, name'
    });
  }
}

let dbInstance = null;
let dbOpenPromise = null;

export function getDB() {
  if (!dbInstance) {
    dbInstance = new AppDatabase();
  }
  return dbInstance;
}

export function openDB() {
  if (!dbOpenPromise) {
    dbOpenPromise = getDB().open();
  }
  return dbOpenPromise;
}

Особенности:

  • dbOpenPromise гарантирует единственное открытие
  • повторные вызовы используют один и тот же Promise
  • устраняется race condition

Закрытие и повторное открытие singleton

Dexie поддерживает db.close(), что требует аккуратного обращения с singleton-моделью.

Проблема повторного открытия

После закрытия:

db.close();
await db.open();

экземпляр остается валидным, но его состояние становится сложно контролируемым в глобальном singleton.


Управляемый reset singleton

Используется явное сбрасывание кэша:

let dbInstance = null;
let dbOpenPromise = null;

export function getDB() {
  if (!dbInstance) {
    dbInstance = new AppDatabase();
  }
  return dbInstance;
}

export async function resetDB() {
  if (dbInstance) {
    dbInstance.close();
  }

  dbInstance = null;
  dbOpenPromise = null;
}

Такой подход позволяет:

  • переинициализировать схему
  • безопасно применять миграции в тестах
  • очищать состояние при logout

Singleton в условиях hot module replacement

В dev-средах с HMR (Webpack, Vite) модуль может пересоздаваться без перезагрузки страницы, что ломает классический singleton через let dbInstance.

Проблема дублирования экземпляров

Каждый HMR reload:

  • пересоздает модуль
  • сбрасывает переменные
  • создает новый Dexie instance
  • приводит к множественным подключениям

Глобальный singleton через globalThis

Решение — хранение экземпляра в глобальном объекте:

class AppDatabase extends Dexie {
  constructor() {
    super('app_database');

    this.version(1).stores({
      users: '++id, email'
    });
  }
}

const globalKey = '__APP_DB__';

export function getDB() {
  if (!globalThis[globalKey]) {
    globalThis[globalKey] = new AppDatabase();
  }
  return globalThis[globalKey];
}

Преимущества:

  • устойчивость к HMR
  • единый экземпляр даже при пересборке модуля
  • отсутствие утечек подключения

Singleton и миграции схемы

Dexie строго привязан к версии схемы. Singleton-архитектура напрямую влияет на миграции.

Риск неконсистентных версий

При неправильной организации возможно:

  • создание экземпляров с разными версиями
  • попытка открыть базу с устаревшей схемой
  • конфликт upgrade-хуков

Централизованная версия базы

Версия должна быть фиксированной внутри singleton-класса:

class AppDatabase extends Dexie {
  constructor() {
    super('app_database');

    this.version(1).stores({
      users: '++id'
    });

    this.version(2).stores({
      users: '++id, email',
      posts: '++id, userId'
    });
  }
}

Singleton обеспечивает:

  • единый путь миграции
  • отсутствие параллельных schema definitions
  • контроль upgrade pipeline

Singleton и dependency injection

В крупных архитектурах прямой singleton может ограничивать тестируемость. Альтернативой становится DI-слой поверх Dexie.

Абстракция доступа

export class DatabaseService {
  constructor(db) {
    this.db = db;
  }

  getUsers() {
    return this.db.users.toArray();
  }
}

Singleton используется только на уровне создания:

import { getDB } from './db';

const db = getDB();
export const databaseService = new DatabaseService(db);

Преимущества:

  • изоляция бизнес-логики
  • возможность подмены Dexie в тестах
  • отсутствие жесткой зависимости от глобального состояния

Singleton в тестовой среде

Dexie часто тестируется с fake-indexeddb, где singleton может мешать изоляции тестов.

Проблема утечки состояния

Если singleton не сбрасывается:

  • данные сохраняются между тестами
  • миграции выполняются только один раз
  • состояние IndexedDB становится неочищаемым

Полная изоляция через фабрику

export function createTestDB() {
  const db = new Dexie('test_db');

  db.version(1).stores({
    users: '++id'
  });

  return db;
}

И отказ от singleton в тестах:

let db;

beforeEach(async () => {
  db = createTestDB();
  await db.open();
});

afterEach(async () => {
  db.close();
  await Dexie.delete('test_db');
});

Такой подход гарантирует:

  • чистую базу на каждый тест
  • отсутствие shared state
  • предсказуемость миграций

Ленивая инициализация singleton

Часто база не должна открываться сразу при импорте модуля.

Lazy pattern

let dbInstance;
let dbOpenPromise;

export function getDB() {
  if (!dbInstance) {
    dbInstance = new AppDatabase();
  }
  return dbInstance;
}

export function ensureDB() {
  if (!dbOpenPromise) {
    dbOpenPromise = getDB().open();
  }
  return dbOpenPromise;
}

Особенности:

  • создание объекта отделено от открытия
  • управление ресурсами становится явным
  • предотвращается лишний IO на старте приложения

Singleton и многомодульная архитектура

В крупных приложениях Dexie часто используется в нескольких слоях:

  • repository layer
  • service layer
  • cache layer

Без строгого singleton каждый слой может создать собственный экземпляр, что приводит к:

  • множественным соединениям IndexedDB
  • несогласованности транзакций
  • конфликтам обновлений данных

Централизация через singleton обеспечивает:

  • единый транзакционный контекст
  • согласованное кэширование
  • предсказуемое поведение наблюдателей (liveQuery, hooks)

Ошибки проектирования singleton-оберток

1. Singleton внутри класса без внешнего кэша

class AppDatabase extends Dexie {
  static instance;

  static getInstance() {
    if (!this.instance) {
      this.instance = new AppDatabase();
    }
    return this.instance;
  }
}

Проблема:

  • сложность управления lifecycle
  • трудности в HMR
  • неявное состояние внутри класса

2. Смешивание открытия и создания

const db = new Dexie('db');
db.open();

Проблема:

  • невозможно контролировать момент открытия
  • race condition при параллельных вызовах

3. Отсутствие reset механизма

Без resetDB() невозможно:

  • протестировать миграции
  • сбросить состояние приложения
  • переинициализировать схему

Итоговая архитектурная модель singleton Dexie

Корректная модель управления включает:

  • кэш экземпляра Dexie в глобальном или модульном scope
  • отдельное кэширование Promise открытия
  • явный reset механизма
  • разделение creation/open lifecycle
  • опциональную DI-обертку для бизнес-логики
  • отдельные фабрики для тестов

Такая структура обеспечивает стабильную работу IndexedDB в условиях SPA, SSR-клиентов, HMR и тестовых сред, устраняя основные источники гонок и дублирования состояния.