Необходимость миграций при изменении структуры данных

Любое долговременное использование клиентского хранилища неизбежно приводит к ситуации, когда первоначальная структура данных перестаёт соответствовать новым требованиям приложения. В случае с localForage, который абстрагирует работу над IndexedDB, WebSQL и localStorage, эта проблема проявляется особенно явно: данные могут храниться годами, а код приложения обновляется значительно чаще.

Основная сложность заключается в том, что localForage не предоставляет встроенной системы миграций схемы. Это означает, что ответственность за согласованность структуры данных полностью лежит на приложении.

Типичные изменения, требующие миграции:

  • переименование ключей объектов;
  • изменение вложенности структуры;
  • добавление новых обязательных полей;
  • изменение формата значений (например, строка → объект);
  • разделение одного хранилища на несколько логических доменов.

Игнорирование этих изменений приводит к накоплению «устаревших» данных, которые начинают ломать бизнес-логику.


Особенности хранения данных в localForage

Перед рассмотрением миграций важно понимать, как именно localForage хранит данные.

В зависимости от драйвера:

  • IndexedDB — хранение в виде object store;
  • WebSQL — таблицы с ключ-значение;
  • localStorage — простые пары ключ/значение.

Ключевой момент: независимо от драйвера, localForage оперирует логической моделью key-value storage, где значение может быть сериализованным объектом.

Это создаёт иллюзию отсутствия схемы, но фактически схема существует на уровне приложения.

Пример:

localforage.setItem('user', {
  id: 1,
  name: 'Alex',
  theme: 'dark'
});

Любое изменение структуры этого объекта требует контроля версий.


Почему миграции становятся обязательными

Отсутствие миграций приводит к накоплению несовместимых данных.

Рассмотрим сценарий:

Версия 1 структуры

{
  id: 1,
  name: "Alex"
}

Версия 2 структуры

{
  id: 1,
  fullName: "Alex",
  settings: {
    theme: "dark"
  }
}

Старые записи:

  • не содержат settings;
  • используют name вместо fullName.

Если приложение начинает ожидать новую структуру, без миграции возникают:

  • undefined ошибки;
  • некорректный UI;
  • сбои бизнес-логики.

Базовый принцип миграций: версионирование схемы

Ключевой подход — введение версии схемы данных.

Обычно используется отдельный служебный ключ:

localforage.setItem('schemaVersion', 2);

При запуске приложения выполняется проверка:

const version = await localforage.getItem('schemaVersion');

Если версия отсутствует, считается, что данные относятся к первому релизу.


Стратегии миграции данных

Полная миграция при старте

Данные загружаются, преобразуются и сохраняются заново.

async function migrateV1toV2() {
  const user = await localforage.getItem('user');

  if (!user) return;

  const migrated = {
    id: user.id,
    fullName: user.name,
    settings: {
      theme: 'light'
    }
  };

  await localforage.setItem('user', migrated);
}

Подходит для небольших объёмов данных.


Ленивые миграции (lazy migration)

Миграция выполняется при чтении данных.

async function getUser() {
  const user = await localforage.getItem('user');

  if (user && user.name && !user.fullName) {
    const migrated = {
      id: user.id,
      fullName: user.name,
      settings: { theme: 'light' }
    };

    await localforage.setItem('user', migrated);
    return migrated;
  }

  return user;
}

Преимущество — распределение нагрузки по времени.

Недостаток — смешение старых и новых форматов в хранилище.


Инкрементальные миграции

Используется цепочка версий:

const migrations = {
  1: async () => {
    const user = await localforage.getItem('user');
    if (user) {
      await localforage.setItem('user', {
        id: user.id,
        fullName: user.name
      });
    }
    await localforage.setItem('schemaVersion', 2);
  },

  2: async () => {
    const user = await localforage.getItem('user');
    if (user) {
      user.settings = { theme: 'dark' };
      await localforage.setItem('user', user);
    }
    await localforage.setItem('schemaVersion', 3);
  }
};

Инициализация:

async function runMigrations() {
  let version = await localforage.getItem('schemaVersion');

  if (!version) version = 1;

  while (migrations[version]) {
    await migrations[version]();
    version++;
  }
}

Миграции при множественных ключах

В реальных приложениях редко используется один ключ. Обычно структура включает множество сущностей:

  • users
  • settings
  • cache
  • session

Пример миграции набора данных:

async function migrateAllUsers() {
  const keys = await localforage.keys();

  for (const key of keys) {
    if (!key.startsWith('user_')) continue;

    const user = await localforage.getItem(key);

    if (user && user.name) {
      user.fullName = user.name;
      delete user.name;

      await localforage.setItem(key, user);
    }
  }
}

Здесь важно учитывать производительность, особенно при больших наборах данных в IndexedDB.


Конфликты миграций и гонки данных

Асинхронная природа localForage приводит к потенциальным проблемам:

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

Типичный сценарий гонки:

  1. Запущена миграция V1 → V2.
  2. Пользовательское действие вызывает setItem.
  3. Старый код записывает структуру V1 поверх V2.

Защита через блокировку миграций

let migrationLock = false;

async function safeMigrate() {
  if (migrationLock) return;

  migrationLock = true;

  try {
    await runMigrations();
  } finally {
    migrationLock = false;
  }
}

В более сложных системах используется persisted-lock через storage.


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

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

Типичные причины ошибок:

  • повреждённые данные;
  • неожиданные типы (строка вместо объекта);
  • частично записанные значения.

Пример безопасной миграции:

async function safeMigrationStep() {
  const user = await localforage.getItem('user');

  if (!user || typeof user !== 'object') {
    return;
  }

  if (!('fullName' in user)) {
    user.fullName = user.name || 'unknown';
  }

  await localforage.setItem('user', user);
}

Оптимизация миграций в IndexedDB

При больших объёмах данных важно учитывать особенности IndexedDB:

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

Практика оптимизации:

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

Пример чанковой миграции:

async function migrateInChunks(keys, chunkSize = 50) {
  for (let i = 0; i < keys.length; i += chunkSize) {
    const chunk = keys.slice(i, i + chunkSize);

    await Promise.all(chunk.map(async (key) => {
      const value = await localforage.getItem(key);

      if (value && value.oldField) {
        value.newField = value.oldField;
        delete value.oldField;

        await localforage.setItem(key, value);
      }
    }));
  }
}

Согласование миграций между версиями приложения

При обновлении клиентского приложения важно синхронизировать:

  • версию кода;
  • версию структуры данных;
  • логику fallback.

Распространённый подход — хранение метаданных:

{
  schemaVersion: 3,
  appVersion: "2.1.0"
}

Это позволяет:

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

Деградация данных при отсутствии миграций

Если миграции не реализованы, со временем возникает деградация:

  • рост количества «битых» записей;
  • усложнение бизнес-логики через проверки if (oldField || newField);
  • накопление legacy-кода;
  • невозможность безопасного удаления старых форматов.

В долгосрочной перспективе это приводит к тому, что локальное хранилище становится неконтролируемым источником ошибок, несмотря на внешнюю простоту localForage.