Последовательные миграции между версиями

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

Принцип линейного роста версий

Версия базы данных в Dexie представляет собой монотонно возрастающее целое число. Любое обновление схемы требует увеличения версии:

  • версия 1 → начальная схема
  • версия 2 → первое изменение структуры
  • версия 3 → следующее изменение и т.д.

Ключевая особенность заключается в том, что Dexie всегда выполняет миграции последовательно, начиная с текущей версии пользователя и заканчивая последней объявленной версией.

Если пользователь пропустил несколько обновлений (например, с версии 1 сразу переходит на 4), движок не выполняет только финальную миграцию. Вместо этого последовательно выполняются все промежуточные шаги:

1 → 2 → 3 → 4

Это гарантирует целостность данных при накопительных изменениях схемы.


Объявление цепочки версий

Каждая версия задаётся через цепочку db.version(n):

const db = new Dexie("appDatabase");

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

db.version(2).stores({
  users: "++id, name, email, createdAt"
});

При таком определении Dexie фиксирует две версии схемы. При открытии базы:

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

Последовательные миграции через upgrade()

Добавление .stores() определяет только структуру. Однако реальные преобразования данных выполняются через upgrade():

db.version(3).stores({
  users: "++id, name, email, createdAt, isActive"
}).upgrade(tx => {
  return tx.table("users").toCollection().modify(user => {
    user.isActive = true;
  });
});

Здесь происходит разделение:

  • .stores() — описание схемы
  • .upgrade() — трансформация существующих данных

Dexie гарантирует выполнение upgrade() внутри транзакции соответствующей версии.


Важность строгой последовательности версий

Dexie требует, чтобы версии объявлялись строго по возрастанию. Нарушение порядка приводит к ошибке и блокирует открытие базы.

Корректный порядок:

db.version(1) ...
db.version(2) ...
db.version(3) ...

Некорректные варианты:

db.version(3) ...
db.version(2) ... // ошибка

или

db.version(1) ...
db.version(3) ...
db.version(2) ... // ошибка

Природа последовательного выполнения миграций

Когда Dexie обнаруживает, что текущая версия базы меньше последней объявленной, он формирует цепочку транзакций:

  • открывается транзакция версии 2
  • выполняется upgrade 1 → 2
  • затем открывается транзакция версии 3
  • выполняется upgrade 2 → 3
  • и так далее

Каждая версия получает собственную транзакцию IndexedDB. Это означает:

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

Работа с данными между версиями

Миграции часто требуют трансформации данных, особенно при изменении структуры индексов или ключей.

Пример добавления нового индекса с пересчётом данных:

db.version(4).stores({
  users: "++id, name, email, createdAt, isActive, role"
}).upgrade(async tx => {
  const users = await tx.table("users").toArray();

  for (const user of users) {
    if (!user.role) {
      user.role = "guest";
      await tx.table("users").put(user);
    }
  }
});

Особенность Dexie заключается в том, что upgrade-транзакция остаётся активной до завершения всех асинхронных операций. Это позволяет безопасно выполнять пакетные преобразования.


Изменение индексов и влияние на миграции

Изменение схемы индексов является одной из самых чувствительных операций. Dexie рассматривает такие изменения как структурные:

  • добавление индекса требует новой версии
  • удаление индекса требует новой версии
  • изменение составного индекса требует полной пересборки таблицы

Пример:

db.version(5).stores({
  users: "++id, name, email, createdAt, role, country"
});

Если ранее индекс email был уникальным, а теперь стал обычным, IndexedDB пересоздаёт внутреннюю структуру хранилища, что автоматически запускает upgrade-цепочку.


Разделение схемы и миграционной логики

Dexie позволяет разделять объявление структуры и обработку данных по разным версиям:

db.version(6).stores({
  users: "++id, name, email, createdAt"
});

db.version(7).stores({
  users: "++id, name, email, createdAt, lastLogin"
}).upgrade(async tx => {
  await tx.table("users").toCollection().modify(user => {
    user.lastLogin = null;
  });
});

Такой подход делает миграции читаемыми и предсказуемыми: каждая версия отвечает за одно атомарное изменение.


Поведение при пропуске версий

Если пользователь открывает приложение, где текущая база версии 2, а доступна версия 6, Dexie выполняет:

2 → 3 → 4 → 5 → 6

При этом:

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

Транзакционная модель upgrade()

Каждый upgrade() выполняется внутри транзакции, которая:

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

Пример поведения при ошибке:

db.version(8).stores({
  users: "++id, name, email"
}).upgrade(async tx => {
  throw new Error("migration failed");
});

В этом случае:

  • версия 8 не применяется
  • база остаётся на предыдущей стабильной версии
  • последующие операции блокируются до исправления миграции

Работа с асинхронностью в цепочке миграций

Dexie поддерживает асинхронные операции внутри upgrade, но важно учитывать:

  • транзакция активна, пока не завершён Promise
  • любые обращения к таблицам должны использовать tx.table()
  • внешние соединения и side effects недопустимы

Пример корректного паттерна:

db.version(9).upgrade(async tx => {
  const table = tx.table("users");

  await table.toCollection().modify(user => {
    user.normalized = user.name.toLowerCase();
  });
});

Частичные миграции и оптимизация

При больших объёмах данных миграции могут быть оптимизированы через пакетную обработку:

db.version(10).upgrade(async tx => {
  const chunkSize = 500;
  let collection = tx.table("users").toCollection();

  let lastId = null;

  while (true) {
    const batch = await collection
      .filter(u => !lastId || u.id > lastId)
      .limit(chunkSize)
      .toArray();

    if (batch.length === 0) break;

    for (const user of batch) {
      user.flagged = false;
      await tx.table("users").put(user);
      lastId = user.id;
    }
  }
});

Такой подход снижает нагрузку на память и уменьшает риск блокировки UI.


Совместимость старых версий схемы

Dexie сохраняет возможность чтения старых схем до момента завершения миграции. Это означает:

  • старые поля доступны в upgrade
  • отсутствующие индексы ещё не применены
  • структура таблицы отражает версию, с которой выполняется миграция

Это поведение позволяет безопасно трансформировать данные без потери информации.


Типичные ошибки при построении цепочек миграций

Наиболее распространённые проблемы:

  • пропуск версии в цепочке объявлений
  • изменение структуры без увеличения версии
  • выполнение внешних API-запросов внутри upgrade
  • попытка использовать разные схемы одной таблицы в одной версии
  • несоответствие индексов между версиями

Стратегии проектирования долгих цепочек версий

В реальных приложениях цепочка миграций может достигать десятков версий. Для поддержания устойчивости применяются подходы:

  • каждая версия содержит одно изменение
  • сложные преобразования разбиваются на несколько шагов
  • миграции пишутся как идемпотентные операции
  • избегается повторное изменение одной и той же таблицы в одной версии
  • используется последовательное наращивание схемы без «перепрыгивания»

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