Паттерн пошаговой миграции

Пошаговая миграция данных в хранилище, построенном на localForage, опирается на идею контролируемого преобразования состояния между версиями схемы без потери совместимости и с минимальным временем простоя логики приложения. В условиях асинхронного API и возможного использования разных драйверов (IndexedDB, WebSQL, localStorage) особое значение приобретает предсказуемость переходов и детерминированность преобразований.

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

Типовая практика — выделение отдельного мета-ключа:

import localforage from "localforage";

const SCHEMA_VERSION_KEY = "__schema_version__";

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

  • 1 — начальная структура
  • 2 — добавлено новое поле
  • 3 — изменена форма данных
  • 4 — переработан ключевой индекс и т.д.

Получение версии:

async function getSchemaVersion() {
  const version = await localforage.getItem(SCHEMA_VERSION_KEY);
  return typeof version === "number" ? version : 0;
}

Установка версии:

async function setSchemaVersion(version) {
  await localforage.setItem(SCHEMA_VERSION_KEY, version);
}

Реестр миграций как детерминированная цепочка

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

const migrations = {
  1: async () => {
    // начальная структура, миграция не требуется
  },

  2: async () => {
    const keys = await localforage.keys();

    for (const key of keys) {
      if (key === SCHEMA_VERSION_KEY) continue;

      const value = await localforage.getItem(key);

      if (value && typeof value === "object") {
        value.createdAt = value.createdAt || Date.now();
        await localforage.setItem(key, value);
      }
    }
  },

  3: async () => {
    const keys = await localforage.keys();

    for (const key of keys) {
      if (key === SCHEMA_VERSION_KEY) continue;

      const value = await localforage.getItem(key);

      if (value?.type === "legacy") {
        value.type = "modern";
        value.flags = [];
        await localforage.setItem(key, value);
      }
    }
  }
};

Такой реестр обеспечивает линейное продвижение от одной версии к другой без ветвлений.

Пошаговое выполнение миграций

Основной алгоритм миграции строится как цикл от текущей версии к целевой:

async function migrateToLatest(latestVersion) {
  let currentVersion = await getSchemaVersion();

  while (currentVersion < latestVersion) {
    const nextVersion = currentVersion + 1;
    const migration = migrations[nextVersion];

    if (!migration) {
      throw new Error(`Migration for version ${nextVersion} not found`);
    }

    await migration();

    currentVersion = nextVersion;
    await setSchemaVersion(currentVersion);
  }
}

Ключевой принцип — фиксация версии только после успешного завершения шага. Это предотвращает частично применённые миграции.

Стратегии применения миграций

1. Жадная миграция при запуске

При инициализации приложения выполняется полный прогон миграций:

const LATEST_VERSION = 3;

export async function initStorage() {
  await migrateToLatest(LATEST_VERSION);
}

Характеристика подхода:

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

2. Ленивое преобразование при чтении

Альтернативная стратегия — миграция отдельных записей при обращении к ним.

async function getEntity(key) {
  const value = await localforage.getItem(key);

  if (!value) return null;

  if (value._version === 1) {
    value.createdAt = Date.now();
    value._version = 2;
    await localforage.setItem(key, value);
  }

  return value;
}

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

3. Гибридная модель

На практике часто используется комбинация:

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

Инкрементальные преобразования данных

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

async function migrateInBatches(batchSize = 50) {
  const keys = await localforage.keys();

  let batch = [];

  for (const key of keys) {
    if (key === SCHEMA_VERSION_KEY) continue;

    batch.push(key);

    if (batch.length >= batchSize) {
      await processBatch(batch);
      batch = [];
    }
  }

  if (batch.length > 0) {
    await processBatch(batch);
  }
}

async function processBatch(keys) {
  for (const key of keys) {
    const value = await localforage.getItem(key);

    if (!value) continue;

    if (value.needsNormalization) {
      value.needsNormalization = false;
      await localforage.setItem(key, value);
    }
  }
}

Пакетирование снижает риск блокировки event loop и уменьшает пик нагрузки на IndexedDB.

Обратная совместимость и двойная запись

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

async function setEntity(key, value) {
  const legacyValue = transformToLegacy(value);
  const modernValue = transformToModern(value);

  await Promise.all([
    localforage.setItem(`${key}:v1`, legacyValue),
    localforage.setItem(`${key}:v2`, modernValue)
  ]);
}

Позже старая форма постепенно выводится из эксплуатации.

Безопасность миграций и обработка ошибок

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

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

async function safeMigration(stepFn) {
  try {
    await stepFn();
  } catch (e) {
    console.error("Migration failed:", e);
    throw e;
  }
}

В более строгих сценариях применяется откат логической версии:

await setSchemaVersion(currentVersion);
throw new Error("Migration aborted");

Однако физический откат данных в localForage часто невозможен без резервных копий.

Миграционный контекст и флаги состояния

Для сложных приложений вводится контекст миграции, фиксирующий текущее состояние процесса:

const migrationState = {
  inProgress: false,
  step: 0,
  errors: []
};

Использование флагов позволяет:

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

Работа с частично повреждёнными данными

При эволюции схемы неизбежны случаи устаревших или некорректных значений. Стратегия обработки включает дефолтирование:

function normalize(value) {
  return {
    id: value.id || crypto.randomUUID(),
    createdAt: value.createdAt || Date.now(),
    type: value.type || "unknown",
    payload: value.payload || {}
  };
}

Нормализация часто становится частью каждой миграции, а не отдельным этапом.

Масштабирование миграций на большие хранилища

При значительном объёме данных применяется фоновая миграция с использованием отложенного выполнения:

function scheduleMigrationStep(fn) {
  return new Promise(resolve => {
    requestIdleCallback(async () => {
      await fn();
      resolve();
    });
  });
}

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

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

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

async function renameKeys(prefixOld, prefixNew) {
  const keys = await localforage.keys();

  for (const key of keys) {
    if (key.startsWith(prefixOld)) {
      const value = await localforage.getItem(key);
      const newKey = key.replace(prefixOld, prefixNew);

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

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

Итеративная стабилизация схемы

Пошаговая миграция не ограничивается одноразовым процессом. Она превращается в постоянный механизм сопровождения состояния, при котором каждая новая версия данных:

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

Это формирует устойчивую модель, в которой localForage выступает не просто хранилищем ключ-значение, а эволюционирующей системой данных с контролируемой историей изменений.