Хранение версии схемы данных

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

Понятие версии схемы

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

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

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

const SCHEMA_VERSION = 3;

Ключевая особенность — версия должна храниться отдельно от данных, чтобы её можно было прочитать до загрузки бизнес-логики.


Хранение версии схемы

Наиболее устойчивый подход — хранение версии в отдельном ключе внутри localForage.

import localforage from "localforage";

const VERSION_KEY = "__schema_version__";

async function getSchemaVersion() {
  const version = await localforage.getItem(VERSION_KEY);
  return version ?? 0;
}

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

Такой подход позволяет:

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

Альтернативный вариант — хранение версии внутри каждого объекта:

{
  id: "user_1",
  name: "Alice",
  schemaVersion: 2
}

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


Инициализация и проверка версии при запуске

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

const CURRENT_VERSION = 3;

async function initSchema() {
  const storedVersion = await getSchemaVersion();

  if (storedVersion === CURRENT_VERSION) {
    return;
  }

  await migrateSchema(storedVersion, CURRENT_VERSION);
  await setSchemaVersion(CURRENT_VERSION);
}

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


Миграции схемы данных

Миграции представляют собой цепочку преобразований данных от одной версии к другой.

const migrations = {
  1: async () => {
    const legacy = await localforage.getItem("userData");

    if (legacy) {
      await localforage.setItem("users", [legacy]);
      await localforage.removeItem("userData");
    }
  },

  2: async () => {
    const users = await localforage.getItem("users") || [];

    const updated = users.map(u => ({
      ...u,
      createdAt: Date.now()
    }));

    await localforage.setItem("users", updated);
  }
};

Общий механизм применения миграций:

async function migrateSchema(fromVersion, toVersion) {
  for (let v = fromVersion + 1; v <= toVersion; v++) {
    const migration = migrations[v];
    if (migration) {
      await migration();
    }
  }
}

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


Стратегии версионирования

Существуют различные стратегии управления версией схемы в localForage.

Линейная версия

Самая распространённая модель:

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

Недостаток — рост количества миграций при долгоживущих проектах.


Семантическое разделение

Версия разделяется на компоненты:

{
  major: 2,
  minor: 5,
  patch: 1
}

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


Версионирование namespace

Данные разделяются по пространствам ключей:

const NAMESPACE = "app_v3_";

Пример:

localforage.setItem(`${NAMESPACE}users`, data);

Преимущества:

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

Недостатки:

  • накопление устаревших данных;
  • необходимость очистки старых namespace.

Хранение метаданных схемы

Помимо версии, часто требуется хранить дополнительную информацию:

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

Пример структуры метаданных:

const schemaMeta = {
  version: 3,
  migratedAt: 1710000000000,
  previousVersion: 2,
  checksum: "a91f3c"
};

Хранение:

await localforage.setItem("__schema_meta__", schemaMeta);

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

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

Защита от частичного обновления

Проблема: процесс миграции прерван, данные находятся в промежуточном состоянии.

Решение — использование временных ключей:

await localforage.setItem("users_tmp", transformed);
await localforage.removeItem("users");
await localforage.setItem("users", transformed);
await localforage.removeItem("users_tmp");

Двойная запись (shadow copy)

Более надёжный подход:

await localforage.setItem("users_v2", newData);
await localforage.setItem("users", newData);

Позволяет откатиться к предыдущей версии при ошибке.


Проверка целостности

Дополнительно используется контрольный механизм:

function computeChecksum(data) {
  return JSON.stringify(data).length.toString(16);
}

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


Ленивые миграции

Вместо миграции всех данных при запуске применяется подход «on read»:

async function getUser(id) {
  const user = await localforage.getItem(`user_${id}`);

  if (user.schemaVersion < CURRENT_VERSION) {
    return migrateUser(user);
  }

  return user;
}

Преимущества:

  • снижение времени запуска;
  • распределение нагрузки.

Недостатки:

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

Параллельное существование схем

В некоторых случаях несколько версий схемы могут существовать одновременно:

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

Пример:

await localforage.setItem("users_v2", usersV2);
await localforage.setItem("users_v3", usersV3);

Выбор актуальной версии осуществляется на уровне бизнес-логики.


Тестирование миграций

Проверка корректности миграций требует симуляции различных состояний хранилища.

async function testMigration() {
  await localforage.clear();

  await localforage.setItem("__schema_version__", 1);
  await localforage.setItem("users", legacyData);

  await migrateSchema(1, 3);

  const result = await localforage.getItem("users");
  console.assert(result.length > 0);
}

Особое внимание уделяется:

  • обратной совместимости;
  • корректной обработке отсутствующих полей;
  • устойчивости к повреждённым данным.

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

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

async function safeMigration(step) {
  try {
    await step();
  } catch (e) {
    await rollbackMigration();
    throw e;
  }
}

Rollback-стратегия может включать:

  • восстановление из backup-ключей;
  • откат к предыдущей версии namespace;
  • очистку повреждённых данных.

Масштабирование схемы хранения

При росте приложения структура localForage-хранилища часто усложняется:

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

Пример registry:

const schemaRegistry = {
  users: 3,
  settings: 2,
  cache: 5
};

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