Инициализация базы данных как singleton

Базовая концепция singleton-экземпляра

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

Если приложение многократно создаёт new Dexie('dbName'), возникают следующие эффекты:

  • повторная регистрация схемы и версий;
  • конкуренция за открытие IndexedDB-соединения;
  • дублирование подписок и live-запросов;
  • потенциальные конфликты миграций;
  • увеличение накладных расходов на инициализацию.

Singleton-подход решает задачу централизованного управления одним экземпляром базы данных в пределах процесса исполнения.


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

Наиболее простой и распространённый вариант — использование механизма модулей ES:

// db.js
import Dexie from 'dexie';

const db = new Dexie('appDatabase');

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

db.open();

export default db;

Особенность модульной системы JavaScript заключается в том, что импортируемый модуль кэшируется. Это означает:

  • db создаётся ровно один раз на уровне модуля;
  • все импорты получают ссылку на один и тот же объект;
  • повторная инициализация не происходит.

Такой подход уже является singleton по своей природе.


Ленивая инициализация (lazy singleton)

В некоторых сценариях требуется отложенное создание базы — например, когда имя базы зависит от конфигурации пользователя или окружения.

import Dexie from 'dexie';

let dbInstance = null;

export function getDB() {
  if (dbInstance) return dbInstance;

  dbInstance = new Dexie('appDatabase');

  dbInstance.version(1).stores({
    cache: '++id, key, value, updatedAt'
  });

  return dbInstance;
}

Особенности подхода:

  • база создаётся только при первом обращении;
  • можно контролировать момент инициализации;
  • удобно для тестов и динамических окружений.

Однако важно учитывать, что open() в Dexie — асинхронная операция, и lazy-инициализация должна учитывать состояние открытия.


Singleton с учётом асинхронного открытия базы

Более строгая модель учитывает состояние открытия:

import Dexie from 'dexie';

let dbPromise = null;

export function getDB() {
  if (!dbPromise) {
    const db = new Dexie('appDatabase');

    db.version(1).stores({
      logs: '++id, level, message, createdAt'
    });

    dbPromise = db.open().then(() => db);
  }

  return dbPromise;
}

Здесь важно:

  • возвращается Promise<Dexie>;
  • исключается повторный open();
  • гарантируется, что все потребители получают один и тот же открытый экземпляр.

Проблема множественных импортов и HMR

В средах разработки (Vite, Webpack, Next.js) hot module replacement может приводить к повторной загрузке модуля, где создаётся Dexie-инстанс.

Типичная проблема:

  • при каждом HMR создаётся новый Dexie('appDatabase');
  • старые подписки liveQuery продолжают существовать;
  • появляются дублирующиеся слушатели изменений.

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

import Dexie from 'dexie';

const globalScope = typeof globalThis !== 'undefined' ? globalThis : window;

const db = globalScope.__dbInstance || new Dexie('appDatabase');

if (!globalScope.__dbInstance) {
  db.version(1).stores({
    messages: '++id, text, sentAt'
  });

  db.open();

  globalScope.__dbInstance = db;
}

export default db;

Такой подход:

  • защищает от повторной инициализации при HMR;
  • сохраняет состояние базы между пересборками;
  • предотвращает утечки подписок.

Singleton в SSR (Server-Side Rendering)

В SSR-окружениях (например, Next.js) важно различать сервер и клиент.

IndexedDB существует только в браузере, поэтому прямое создание Dexie на сервере недопустимо.

Корректная структура:

let db;

export function getDB() {
  if (typeof window === 'undefined') {
    return null;
  }

  if (!db) {
    const Dexie = require('dexie');

    db = new Dexie('appDatabase');

    db.version(1).stores({
      sessions: '++id, token, expiresAt'
    });

    db.open();
  }

  return db;
}

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

  • сервер не создаёт базу;
  • клиент инициализирует singleton один раз;
  • предотвращается ошибка IndexedDB is not defined.

Singleton с версионированием схемы

Dexie поддерживает миграции через version(). Singleton должен учитывать, что схема может эволюционировать.

import Dexie from 'dexie';

let db;

export function getDB() {
  if (db) return db;

  db = new Dexie('appDatabase');

  db.version(1).stores({
    notes: '++id, title'
  });

  db.version(2).stores({
    notes: '++id, title, updatedAt'
  }).upgrade(tx => {
    return tx.table('notes').toCollection().modify(note => {
      note.updatedAt = Date.now();
    });
  });

  db.open();

  return db;
}

Важно:

  • singleton не мешает добавлению новых версий;
  • Dexie сам применяет миграции при первом open();
  • экземпляр остаётся единым для всех версий.

Интеграция singleton с реактивными слоями

В связке с UI-фреймворками singleton становится фундаментом реактивности.

Пример с React и liveQuery:

import { liveQuery } from 'dexie';
import { getDB } from './db';

const db = await getDB();

export const usersQuery = liveQuery(() => db.users.toArray());

Ключевой момент:

  • если db не singleton, создаются дублирующиеся подписки;
  • liveQuery начинает слушать несколько IndexedDB-контекстов;
  • возможны неконсистентные обновления UI.

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

  • единый поток изменений;
  • предсказуемое поведение реактивных запросов;
  • стабильное управление транзакциями.

Тестирование singleton-архитектуры

В тестовой среде singleton часто мешает изоляции. Решение — сброс модуля или явный reset:

export function resetDB() {
  if (db) {
    db.close();
  }
  db = null;
}

Или вариант с фабрикой:

export function createDB(name) {
  const db = new Dexie(name);

  db.version(1).stores({
    items: '++id, value'
  });

  return db;
}

В тестах:

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

Глобальные принципы корректного singleton в Dexie

При проектировании архитектуры важно соблюдать несколько устойчивых правил:

  • экземпляр Dexie должен быть единственным в пределах runtime;
  • open() вызывается один раз на жизненный цикл приложения;
  • схема фиксируется централизованно;
  • любые модули приложения используют один источник базы;
  • lazy-init допустим, но должен контролировать состояние открытия;
  • SSR требует разделения серверной и клиентской логики;
  • HMR требует защиты через globalThis.

Эти принципы формируют устойчивую модель работы с IndexedDB через Dexie в приложениях любой сложности.