Миграции в Dexie строятся вокруг версионирования схемы IndexedDB и последовательного преобразования структуры данных при изменении модели. В TypeScript-среде ключевая сложность заключается в синхронизации трёх слоёв: фактической схемы IndexedDB, описания таблиц Dexie, и типов приложения, которые используют эти данные. Ошибка в любом из слоёв приводит либо к runtime-несоответствиям, либо к потере типовой безопасности.
Dexie использует инкрементальную систему версий:
db.version(1).stores({
users: '++id, name, age'
});
Каждая новая версия базы фактически фиксирует момент времени, в котором:
version(n).При изменении схемы возникает ключевая проблема: типизация должна отражать не только текущую версию, но и историю изменений.
Рассмотрим эволюцию модели:
interface UserV1 {
id?: number;
name: string;
}
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;age: number;undefined.Наиболее устойчивый подход — явное разделение типов по версиям.
type DBVersion = 1 | 2;
interface SchemaV1 {
users: UserV1;
}
interface SchemaV2 {
users: UserV2;
}
Dexie обычно типизируется через наследование:
class AppDB extends Dexie {
users!: Table<UserV2, number>;
constructor() {
super('app-db');
}
}
Здесь возникает ключевое допущение:
Dexie всегда типизируется по последней версии схемы, а миграции отвечают за приведение старых данных к актуальному виду.
Dexie поддерживает миграции через upgrade:
db.version(2).upgrade(async tx => {
await tx.table('users').toCollection().modify(user => {
user.age = 0;
});
});
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>;
Чистая архитектура миграций:
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));
Одной из самых частых ошибок является некорректное расширение типов:
age: number
в реальности:
age?: number
Типобезопасная стратегия:
type WithOptional<T, K extends keyof T> =
Omit<T, K> & Partial<Pick<T, K>>;
Использование:
type UserV2Safe = WithOptional<UserV2, 'age'>;
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';
}
Стабильная архитектура строится на трёх уровнях:
class AppDB extends Dexie {
users!: Table<UserV3, number>;
}
Миграции при этом остаются единственным местом, где допускается работа с устаревшими типами, а вся остальная часть приложения оперирует только актуальной моделью данных.