Типизация миграций

Миграции в Dexie строятся вокруг версионирования схемы IndexedDB и последовательного преобразования структуры данных при изменении модели. В TypeScript-среде ключевая сложность заключается в синхронизации трёх слоёв: фактической схемы IndexedDB, описания таблиц Dexie, и типов приложения, которые используют эти данные. Ошибка в любом из слоёв приводит либо к runtime-несоответствиям, либо к потере типовой безопасности.


Базовая модель версионирования и её влияние на типы

Dexie использует инкрементальную систему версий:

db.version(1).stores({
  users: '++id, name, age'
});

Каждая новая версия базы фактически фиксирует момент времени, в котором:

  • структура таблиц считается неизменной;
  • все последующие изменения происходят через version(n).

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


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

Рассмотрим эволюцию модели:

Версия 1

interface UserV1 {
  id?: number;
  name: string;
}

Версия 2

interface UserV2 {
  id?: number;
  name: string;
  age: number;
}

Схема Dexie:

db.version(1).stores({
  users: '++id,name'
});

db.version(2).stores({
  users: '++id,name,age'
});

Проблема возникает в момент:

  • старые данные могут не содержать age;
  • TypeScript ожидает age: number;
  • runtime-значение может быть undefined.

Стратегия типизации через версии моделей

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

Определение версионной модели

type DBVersion = 1 | 2;
interface SchemaV1 {
  users: UserV1;
}

interface SchemaV2 {
  users: UserV2;
}

Привязка Dexie к актуальной модели

Dexie обычно типизируется через наследование:

class AppDB extends Dexie {
  users!: Table<UserV2, number>;

  constructor() {
    super('app-db');
  }
}

Здесь возникает ключевое допущение:

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


Типизация миграций через upgrade-хуки

Dexie поддерживает миграции через upgrade:

db.version(2).upgrade(async tx => {
  await tx.table('users').toCollection().modify(user => {
    user.age = 0;
  });
});

Проблема типизации tx

tx.table() возвращает таблицу без строгой привязки к новой структуре. Это создаёт типовой разрыв.

Решение — явное приведение:

db.version(2).upgrade(async tx => {
  const users = tx.table<UserV1, number>('users');

  await users.toCollection().modify(user => {
    const u = user as UserV2;
    u.age = u.age ?? 0;
  });
});

Унификация типов через промежуточные состояния

Более строгая модель вводит тип состояния до миграции:

type PreMigrationUser = UserV1 & Partial<Pick<UserV2, 'age'>>;

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

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

Миграции как чистые функции преобразования

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

function migrateUserV1ToV2(user: UserV1): UserV2 {
  return {
    ...user,
    age: 0
  };
}

Использование в Dexie:

db.version(2).upgrade(async tx => {
  const users = tx.table<UserV1, number>('users');

  await users.toCollection().modify(user => {
    return migrateUserV1ToV2(user);
  });
});

Но Dexie modify не всегда принимает return-значение как замену, поэтому корректнее:

await users.toCollection().modify(user => {
  const updated = migrateUserV1ToV2(user);
  Object.assign(user, updated);
});

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

Когда количество версий растёт, вводится цепочка преобразований:

type UserAnyVersion = UserV1 | UserV2;

И набор функций:

function migrateV1toV2(u: UserV1): UserV2;
function normalizeUser(u: UserAnyVersion): UserV2;
function normalizeUser(u: UserAnyVersion): UserV2 {
  if (!('age' in u)) {
    return migrateV1toV2(u);
  }
  return u;
}

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


Типизация транзакций в миграциях

Dexie предоставляет транзакцию UpgradeTransaction, которая часто теряет типовую информацию.

Усиление типизации:

type UpgradeTxV2 = Transaction & {
  table: <T, K>(name: string) => Table<T, K>;
};

Использование:

db.version(2).upgrade(async (tx: UpgradeTxV2) => {
  const users = tx.table<UserV1, number>('users');
});

Это неофициальное усиление, но оно повышает контроль над схемой.


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

Dexie schema string:

'++id,name,age'

TypeScript не анализирует эту строку, поэтому возникает необходимость дублирования схемы:

interface DexieSchema {
  users: {
    key: number;
    value: UserV2;
    indexes: 'name' | 'age';
  };
}

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


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

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

db.version(1)
db.version(2)
db.version(3)

И соответствующие типы:

type V1User = { id?: number; name: string };
type V2User = V1User & { age: number };
type V3User = V2User & { isActive: boolean };

Финальный тип:

type CurrentUser = V3User;

Dexie-таблица:

users!: Table<CurrentUser, number>;

Типобезопасные миграции через mapping-слои

Чистая архитектура миграций:

type Migration<TFrom, TTo> = (item: TFrom) => TTo;
const v1toV2: Migration<UserV1, UserV2> = u => ({
  ...u,
  age: 0
});

Цепочка:

const v2toV3: Migration<UserV2, UserV3> = u => ({
  ...u,
  isActive: true
});

Композиция:

const migrate = (u: UserV1): UserV3 =>
  v2toV3(v1toV2(u));

Контроль nullable-полей при миграции

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

age: number

в реальности:

age?: number

Типобезопасная стратегия:

type WithOptional<T, K extends keyof T> =
  Omit<T, K> & Partial<Pick<T, K>>;

Использование:

type UserV2Safe = WithOptional<UserV2, 'age'>;

Миграции и bulk-операции с типами

Dexie активно использует bulk-операции:

await users.bulkPut(data);

Типизация требует согласованности:

const data: UserV2[] = oldData.map(migrateUserV1ToV2);

Ошибка здесь приводит к массовому загрязнению базы, поэтому миграция всегда должна происходить до bulk-операций, а не внутри них.


Проблема частично обновлённых данных

Во время миграции база может содержать смешанные версии.

Типизация решается через union:

type MixedUser = UserV1 | UserV2;

И обязательную нормализацию:

function isV2(u: MixedUser): u is UserV2 {
  return typeof (u as UserV2).age === 'number';
}

Итоговая модель типизированных миграций

Стабильная архитектура строится на трёх уровнях:

  • Версионные типы данных
  • Чистые функции миграции
  • Единый финальный тип для Dexie-таблиц
class AppDB extends Dexie {
  users!: Table<UserV3, number>;
}

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