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

При миграциях схем данных в приложениях на JavaScript одной из наиболее сложных задач является обеспечение непрерывной работы системы в момент, когда старая и новая структура данных существуют одновременно. В контексте Iron-подхода (где логика работы с данными строится через строгие схемы, валидаторы и промежуточные слои трансформации) это решается через параллельное обслуживание двух схем в одном жизненном цикле приложения.

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

Ключевая идея заключается в том, что приложение должно уметь:

  • читать данные старой схемы без изменений;
  • читать данные новой схемы;
  • записывать данные в новую схему;
  • при необходимости преобразовывать данные «на лету».

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

Двойная модель представления данных

В Iron-подобных архитектурах обычно вводятся две независимые модели:

  • LegacySchema — описывает старую структуру данных
  • CurrentSchema — описывает новую структуру данных

Каждая модель имеет собственный валидатор и трансформер.

const LegacySchema = {
  validate(data) {
    return typeof data.name === 'string' && typeof data.age === 'number';
  }
};

const CurrentSchema = {
  validate(data) {
    return typeof data.fullName === 'string' && typeof data.birthYear === 'number';
  }
};

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

Унифицированный слой доступа

Чтобы бизнес-логика не зависела от конкретной версии схемы, вводится слой адаптера. Он определяет, как интерпретировать входящие данные.

function normalizeUser(data) {
  if (CurrentSchema.validate(data)) {
    return {
      fullName: data.fullName,
      birthYear: data.birthYear
    };
  }

  if (LegacySchema.validate(data)) {
    return {
      fullName: data.name,
      birthYear: new Date().getFullYear() - data.age
    };
  }

  throw new Error('Unknown schema');
}

Такой подход позволяет системе работать с обеими версиями без изменения бизнес-кода.

Запись данных в период миграции

На этапе миграции особенно важно контролировать формат записи. Типичная стратегия — запись только в новую схему при сохранении совместимости чтения.

function saveUser(db, user) {
  const normalized = {
    fullName: user.fullName,
    birthYear: user.birthYear
  };

  return db.users.insert(normalized);
}

Старая схема перестаёт использоваться для записи, но продолжает поддерживаться для чтения до завершения миграции.

Промежуточная трансформация данных

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

function getUser(db, id) {
  const raw = db.users.findById(id);

  return normalizeUser(raw);
}

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

Двойная валидация при обновлении

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

function validateIncoming(data) {
  const isNew = CurrentSchema.validate(data);
  const isOld = LegacySchema.validate(data);

  if (!isNew && !isOld) {
    return false;
  }

  return true;
}

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

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

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

function getAndMigrateUser(db, id) {
  const raw = db.users.findById(id);

  if (LegacySchema.validate(raw)) {
    const migrated = normalizeUser(raw);
    db.users.update(id, migrated);
    return migrated;
  }

  return raw;
}

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

Управление версионированием схем

Для контроля состояния системы вводится явное версионирование:

  • v1 — старая схема
  • v2 — новая схема

Каждая запись может содержать метаданные версии:

{
  version: 2,
  fullName: "Ivan Petrov",
  birthYear: 1990
}

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

Изоляция логики через фабрики схем

Чтобы избежать разрастания условий в коде, используется фабрика схем:

function createSchema(version) {
  if (version === 2) {
    return CurrentSchema;
  }

  return LegacySchema;
}

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

Консистентность данных в двух схемах

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

Для контроля этого состояния применяются:

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

Идемпотентность особенно важна:

function migrate(data) {
  if (data.version === 2) return data;

  return {
    version: 2,
    fullName: data.name,
    birthYear: new Date().getFullYear() - data.age
  };
}

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

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

Параллельная работа схем невозможна без строгого соблюдения обратной совместимости. Это означает, что:

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

В противном случае система теряет предсказуемость поведения.

Завершение переходного периода

Когда доля данных старой схемы становится минимальной, вводится финальный этап:

  • отключение LegacySchema;
  • удаление адаптеров;
  • фиксация CurrentSchema как единственного источника истины.

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