Обработка ошибок при миграции

Миграции схемы в Dexie.js выполняются через механизм версий базы данных, где каждое изменение структуры описывается отдельным блоком version().stores() и сопровождается опциональной функцией upgrade(). При этом процесс обновления схемы всегда асинхронный и потенциально прерываемый, поэтому обработка ошибок становится критическим элементом архитектуры хранилища.

В основе Dexie.js лежит IndexedDB, и большинство ошибок миграции связаны не с самой библиотекой, а с ограничениями движка:

  • ConstraintError — нарушение уникальности или индексов при изменении структуры
  • AbortError — прерывание транзакции из-за внешних факторов
  • QuotaExceededError — превышение лимита хранилища
  • DataError — некорректные данные при трансформации
  • VersionError — попытка отката или некорректного перехода версии
  • UnknownError — непредсказуемые ошибки браузерного движка

Миграции выполняются внутри транзакции версии базы, и любая ошибка приводит к откату текущего шага upgrade. Однако откат не всегда восстанавливает предыдущую структуру в привычном смысле — изменения схемы уже частично применены на уровне IndexedDB.

Базовая модель обработки ошибок в upgrade()

Каждая миграция в Dexie.js задаётся функцией:

db.version(2).stores({
  users: "++id,email,name"
}).upgrade(tx => {
  return tx.users.toCollection().modify(user => {
    user.name = user.name || "unknown";
  });
});

Ошибка внутри modify прерывает всю транзакцию версии 2. Поэтому критически важно учитывать атомарность: любая ошибка = откат всей миграции.

Защита через try/catch

db.version(2).stores({
  users: "++id,email,name"
}).upgrade(async tx => {
  try {
    await tx.users.toCollection().modify(user => {
      if (!user.email) {
        throw new Error("Invalid user: missing email");
      }
    });
  } catch (err) {
    console.error("Migration failed:", err);
    throw err;
  }
});

Повторное пробрасывание ошибки обязательно: подавление приведёт к неконсистентной схеме.

Разделение миграций на безопасные этапы

Практика миграций в Dexie.js предполагает минимизацию риска за счёт дробления изменений:

  1. Добавление новых полей без удаления старых
  2. Заполнение новых полей
  3. Удаление устаревших полей отдельной версией

Пример безопасного подхода:

db.version(2).stores({
  users: "++id,email,name_v2"
}).upgrade(async tx => {
  await tx.users.toCollection().modify(user => {
    user.name_v2 = user.name || null;
  });
});
db.version(3).stores({
  users: "++id,email,name_v2"
}).upgrade(async tx => {
  await tx.users.toCollection().modify(user => {
    delete user.name;
  });
});

Разделение снижает вероятность частичного повреждения данных.

Обработка ошибок на уровне открытия базы

Ошибки миграции часто проявляются не внутри upgrade, а на этапе открытия:

try {
  await db.open();
} catch (err) {
  console.error("DB open failed:", err);
}

Типичные причины:

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

Dexie предоставляет событие блокировки:

db.on("blocked", () => {
  console.warn("Upgrade blocked by another open connection");
});

И событие изменения версии:

db.on("versionchange", () => {
  db.close();
});

Эти события критичны для предотвращения гонок при миграциях.

Стратегия устойчивых миграций

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

Идемпотентность миграций

Миграция должна быть безопасной при повторном запуске:

db.version(2).stores({
  users: "++id,email,name"
}).upgrade(async tx => {
  await tx.users.toCollection().modify(user => {
    if (!("migrated" in user)) {
      user.name = user.name || "unknown";
      user.migrated = true;
    }
  });
});

Флаг migrated защищает от повторной трансформации.

Обработка ошибок данных при трансформации

Ошибки часто возникают при работе с реальными данными:

  • отсутствующие поля
  • неожиданные типы
  • повреждённые записи

Защита через дефолтные значения:

await tx.users.toCollection().modify(user => {
  user.age = Number.isFinite(user.age) ? user.age : 0;
  user.tags = Array.isArray(user.tags) ? user.tags : [];
});

При необходимости можно изолировать проблемные записи:

await tx.users.toCollection().modify(user => {
  try {
    user.profile = JSON.parse(user.profileRaw);
  } catch {
    user.profile = null;
  }
});

Ошибки при изменении индексов и схемы

Изменение stores() в Dexie.js может приводить к критическим сбоям:

  • удаление индекса → потеря возможности запросов
  • изменение уникального индекса → конфликт ConstraintError
  • переименование полей → рассинхронизация данных

Типовой подход:

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

Если поле переименовывается, безопаснее сохранить старое поле временно:

db.version(3).stores({
  users: "++id,email,username,new_username"
}).upgrade(async tx => {
  await tx.users.toCollection().modify(user => {
    user.new_username = user.username;
  });
});

Работа с большими миграциями и таймаутами

При больших объёмах данных ошибки часто связаны с:

  • долгими транзакциями
  • блокировками браузера
  • memory pressure

Рекомендуется пакетная обработка:

const batchSize = 500;
let lastId = 0;

while (true) {
  const batch = await tx.users
    .where("id")
    .above(lastId)
    .limit(batchSize)
    .toArray();

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

  for (const user of batch) {
    user.flag = true;
    await tx.users.put(user);
    lastId = user.id;
  }
}

Такой подход снижает вероятность AbortError.

Логирование и диагностика миграций

Для устойчивости миграций в Dexie.js важно фиксировать состояние:

db.on("error", (err) => {
  console.error("Global DB error:", err);
});

Дополнительно полезно логировать версии:

console.log("DB version:", db.verno);

И текущую схему:

console.log(db.tables.map(t => t.name));

Поведение при частично применённых миграциях

IndexedDB не поддерживает полноценные rollback-транзакции схемы. Это означает:

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

Поэтому каждая миграция должна быть:

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

Защита от параллельных миграций

При открытии нескольких вкладок возможна гонка версий:

db.on("blocked", () => {
  db.close();
});

Дополнительно:

window.addEventListener("beforeunload", () => {
  db.close();
});

Это снижает вероятность состояния, когда одна вкладка держит старую версию, а другая пытается выполнить upgrade.

Обработка критических ошибок открытия базы

Некоторые ошибки невозможно обработать внутри миграции:

try {
  await db.open();
} catch (err) {
  if (err.name === "VersionError") {
    console.error("Incompatible DB version");
  } else {
    console.error("Unexpected DB failure", err);
  }
}

При повреждённой базе часто требуется пересоздание:

await db.delete();
await db.open();

Этот сценарий применяется как крайняя мера восстановления.

Контроль целостности после миграции

После успешного upgrade полезно выполнять проверку:

await db.transaction("r", db.users, async () => {
  const count = await db.users.count();
  if (count < 0) throw new Error("Corrupted state");
});

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

Итоговая модель устойчивой обработки ошибок

Миграции в Dexie.js строятся вокруг нескольких принципов:

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

Каждая ошибка в процессе миграции рассматривается как сигнал к либо повторному выполнению безопасной операции, либо к остановке с контролируемым переходом в состояние восстановления данных.