Версионирование схем

При разработке схем в Superstruct ключевая сложность возникает не в их первоначальном описании, а в последующем изменении структуры данных. Любая реальная система со временем требует эволюции: добавляются новые поля, меняются типы, часть атрибутов становится устаревшей. Без продуманного подхода к версионированию схем это приводит к разрыву совместимости и росту количества условной логики валидации.

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

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

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

Версионирование схем — это не отдельная функция библиотеки, а архитектурный слой поверх неё, позволяющий безопасно управлять изменениями.

Базовый принцип версионирования

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

  • числовой (1, 2, 3);
  • семантической (1.0, 1.1);
  • строковой (v1, v2);
  • или даже структурной ({ version: 1 } внутри объекта).

В Superstruct это обычно реализуется через обёртку над базовой схемой, которая выполняет маршрутизацию входных данных к нужному валидатору.

Разделение схем по версиям

Наиболее прямолинейный подход — хранение отдельных схем для каждой версии:

import { object, string, number, optional } from 'superstruct';

export const UserV1 = object({
  id: number(),
  name: string(),
});

export const UserV2 = object({
  id: number(),
  fullName: string(),
  email: optional(string()),
});

Такой подход прост, но приводит к дублированию логики. Его основное преимущество — полная изоляция версий. Изменение одной схемы не влияет на другую.

Однако при росте числа версий появляется проблема: поддержка становится линейно сложнее.

Унификация через слой адаптации

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

import { assert } from 'superstruct';

function validateUserV1(data) {
  return assert(data, UserV1);
}

function validateUserV2(data) {
  return assert(data, UserV2);
}

Далее вводится единая внутренняя модель:

function toInternalUser(data, version) {
  if (version === 1) {
    return {
      id: data.id,
      name: data.name,
      email: null,
    };
  }

  if (version === 2) {
    return {
      id: data.id,
      name: data.fullName,
      email: data.email ?? null,
    };
  }
}

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

Версионирование через дискриминатор

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

import { object, string, number, literal, union } from 'superstruct';

const UserV1 = object({
  version: literal(1),
  id: number(),
  name: string(),
});

const UserV2 = object({
  version: literal(2),
  id: number(),
  fullName: string(),
});

export const User = union([UserV1, UserV2]);

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

Ключевое преимущество — расширяемость без изменения существующих схем. Каждая новая версия добавляется как новый вариант union.

Инкрементальное расширение схем

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

import { assign, object, string, number, optional } from 'superstruct';

const BaseUser = object({
  id: number(),
  name: string(),
});

const UserV2 = assign(BaseUser, object({
  email: optional(string()),
}));

Функция assign позволяет наследовать структуру и добавлять новые поля без дублирования.

Такой подход особенно эффективен при эволюции API, где изменения носят расширяющий, а не ломающий характер.

Проблема ломающих изменений

Наиболее сложный случай — изменение типа данных:

  • строка → объект;
  • число → строка;
  • массив → объект с метаданными.

Superstruct не выполняет автоматическую миграцию типов, поэтому необходимо явно описывать переходные структуры.

import { union, string, object } from 'superstruct';

const LegacyTag = string();

const ModernTag = object({
  id: string(),
  label: string(),
});

const Tag = union([LegacyTag, ModernTag]);

После валидации требуется нормализация:

function normalizeTag(tag) {
  if (typeof tag === 'string') {
    return { id: tag, label: tag };
  }
  return tag;
}

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

Версионирование через фабрики схем

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

function createUserSchema(version) {
  if (version === 1) {
    return object({
      id: number(),
      name: string(),
    });
  }

  return object({
    id: number(),
    fullName: string(),
    email: optional(string()),
  });
}

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

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

Совместимость как основная цель

Версионирование схем в контексте Superstruct всегда связано с двумя принципами:

  1. обратная совместимость — старые данные должны валидироваться;
  2. предсказуемая миграция — данные должны приводиться к единому формату.

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

Частичное версионирование полей

Иногда версия затрагивает не всю структуру, а только отдельные поля. В этом случае полезно разделять схемы на компоненты:

const NameV1 = string();

const NameV2 = object({
  first: string(),
  last: string(),
});

И использовать их внутри общей структуры:

const UserV1 = object({
  id: number(),
  name: NameV1,
});

const UserV2 = object({
  id: number(),
  name: NameV2,
});

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

Слои совместимости и деградации

При работе с множеством версий данных полезно вводить понятие деградации схемы — преобразования новых структур в старые формы для внешних систем.

function toLegacyUser(user) {
  return {
    id: user.id,
    name: typeof user.name === 'string'
      ? user.name
      : `${user.name.first} ${user.name.last}`,
  };
}

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

Управление ростом количества версий

С увеличением числа версий возникает риск комбинаторного усложнения. Для контроля используют стратегии:

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

Superstruct в этом случае остаётся лишь инструментом проверки входных контрактов, не беря на себя управление жизненным циклом данных.

Комбинирование union и transform-логики

Хотя Superstruct не навязывает трансформации, его можно использовать как слой перед нормализацией:

import { union, object, number, string } from 'superstruct';

const RawUser = union([
  object({ version: number(), id: number(), name: string() }),
  object({ version: number(), id: number(), fullName: string() }),
]);

После валидации выполняется единая функция приведения:

function normalize(user) {
  return {
    id: user.id,
    name: user.name ?? user.fullName,
  };
}

Архитектурная роль схем

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

Их задачи:

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

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

Итоговая модель эволюции данных

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

  • слой схем (валидация через struct/union/assign);
  • слой определения версии (дискриминатор или внешняя метка);
  • слой нормализации (преобразование в внутренний формат);
  • слой деградации (обратное преобразование для старых систем).

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