74 KiB
План миграции с Isar на PostgreSQL + Drift
Содержание
- Обзор миграции
- Архитектурные решения
- Этап 1: Подготовка инфраструктуры
- Этап 2: Создание Drift схем
- Этап 3: Создание DAOs
- Этап 4: Рефакторинг кода
- Этап 5: Миграция данных
- Этап 6: Тестирование
- Этап 7: Deployment
- Чеклисты
Обзор миграции
Цель
Заменить embedded Isar БД на production-ready PostgreSQL с type-safe ORM Drift для:
- ✅ ACID транзакций (критично для платежей)
- ✅ Независимого деплоя backend и БД
- ✅ Горизонтального масштабирования
- ✅ Мощной аналитики
- ✅ Стандартных инструментов мониторинга
Оценка трудозатрат
- Подготовка: 15-20 часов
- Создание схем и DAOs: 40-50 часов
- Рефакторинг кода: 80-100 часов
- Миграция данных: 20-30 часов
- Тестирование: 30-40 часов
- Итого: ~165-240 часов
Архитектурные решения
✅ Решено: PostgreSQL
- Реляционная структура данных (User → Packs → Cards)
- ACID транзакции для платежей и подписок
- Мощная аналитика (window functions, CTEs)
- JSONB для гибких полей
- Проверенная надежность
✅ Решено: Drift ORM
- Type-safe queries (компиляция проверяет правильность запросов)
- Автогенерация кода
- Хорошая поддержка миграций
- Нативная интеграция с PostgreSQL
✅ Решено: Держать Drift внутри mnemo_cards_backend
- НЕ выносить в отдельный пакет
- Telegram bot использует HTTP API (не прямой доступ к БД)
- Backend - единственный владелец БД
Этап 1: Подготовка инфраструктуры
1.1. Настройка PostgreSQL для разработки
Вариант A: Docker Compose (рекомендуется)
Создать файл: mnemo_cards_backend/docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: mnemo_postgres
environment:
POSTGRES_DB: mnemo_cards_dev
POSTGRES_USER: mnemo_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-dev_password_change_me}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.UTF-8"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql # опционально
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mnemo_user -d mnemo_cards_dev"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mnemo_network
# Опционально: pgAdmin для визуального управления
pgadmin:
image: dpage/pgadmin4:latest
container_name: mnemo_pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@mnemo.local
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
networks:
- mnemo_network
depends_on:
- postgres
volumes:
postgres_data:
driver: local
pgadmin_data:
driver: local
networks:
mnemo_network:
driver: bridge
Команды:
# Запустить PostgreSQL
cd mnemo_cards_backend
docker-compose up -d postgres
# Проверить статус
docker-compose ps
# Логи
docker-compose logs -f postgres
# Подключиться к psql
docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev
# Остановить
docker-compose down
# Удалить с данными (осторожно!)
docker-compose down -v
Вариант B: Managed PostgreSQL (для продакшена)
Провайдеры:
-
Supabase (бесплатный tier)
- URL: https://supabase.com
- Создать проект → получить connection string
- Плюсы: бесплатно до 500MB, auth из коробки, realtime
-
Railway (~$5/месяц)
- URL: https://railway.app
- New Project → PostgreSQL
- Плюсы: простой deployment, auto-backups
-
Neon (serverless PostgreSQL)
- URL: https://neon.tech
- Бесплатный tier до 3GB
- Плюсы: serverless, бесплатно, быстрый
-
AWS RDS / Google Cloud SQL
- Для больших нагрузок
- Дороже, но более гибкие
1.2. Обновление зависимостей
Файл: mnemo_cards_backend/pubspec.yaml
name: mnemo_cards_backend
description: Mnemo backend
publish_to: 'none'
version: 1.0.0+5 # увеличить версию
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
mnemo_cards_common:
path: ../mnemo_cards_common
# УДАЛИТЬ Isar зависимости:
# isar: *isar_version
# ДОБАВИТЬ PostgreSQL + Drift:
drift: ^2.14.0
drift_postgres: ^1.1.0
postgres: ^3.0.4
# Остальные зависимости остаются:
image: ^4.1.7
http: ^1.1.0
async:
get_it: ^7.6.4
injectable: ^2.4.1
copy_with_extension_gen: ^5.0.4
shelf: ^1.4.1
shelf_enforces_ssl: ^1.2.1
shelf_router: ^1.1.4
shelf_open_api: ^1.1.0
shelf_swagger_ui: ^1.0.0+2
shelf_static: ^1.1.2
shelf_cors_headers: ^0.1.5
json_serializable:
json_annotation: ^4.8.1
dio: ^5.3.3
encrypt: ^5.0.3
basic_utils: ^5.7.0
crypto: ^3.0.3
googleapis: ^13.1.0
googleapis_auth:
uuid: ^3.0.7
yookassa_client: ^1.0.2
neat_periodic_task: ^2.0.1
jaguar_jwt: ^3.0.0
dev_dependencies:
build_runner: ^2.4.0
# УДАЛИТЬ:
# isar_generator: *isar_version
# ДОБАВИТЬ:
drift_dev: ^2.14.0
# Остальные остаются:
shelf_router_generator: ^1.1.0
shelf_open_api_generator:
injectable_generator:
archive: ^3.4.6
test: ^1.25.0
mockito: ^5.4.4
Команды:
cd mnemo_cards_backend
# Очистить старые зависимости
rm -rf .dart_tool/
rm pubspec.lock
# Установить новые
dart pub get
# Проверить, что всё установилось
dart pub deps
1.3. Создание .env файла
Создать файл: mnemo_cards_backend/.env.example
# PostgreSQL connection
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mnemo_cards_dev
DB_USER=mnemo_user
DB_PASSWORD=dev_password_change_me
DB_SSL_MODE=disable # для разработки, 'require' для продакшена
# Backend settings
PORT=3000
SERVER_ADDRESS=0.0.0.0
WORK_DIR=/root/mnemo_cards_backend
DEBUG=true
# Admin IDs (comma-separated)
ADMIN_IDS=1,2,3
# JWT secrets
JWT_SECRET=your-jwt-secret-key-here
JWT_REFRESH_SECRET=your-jwt-refresh-secret-key-here
# Backup (не нужно для PostgreSQL, но оставить для старых cron jobs)
BACKUP_DIR=/app/backups
Создать файл: mnemo_cards_backend/.env (добавить в .gitignore!)
# Скопировать из .env.example и заполнить реальными значениями
cp .env.example .env
# Отредактировать .env
Обновить: .gitignore
# ... существующие правила ...
# Environment variables
.env
.env.local
.env.production
# PostgreSQL data (если локально запускаете вне Docker)
postgres_data/
# Старые Isar файлы (можно удалить после миграции)
isar/
Этап 2: Создание Drift схем
2.1. Структура файлов
mnemo_cards_backend/lib/database/
├── database.dart # Главный класс AppDatabase
├── database.g.dart # Сгенерированный код (автогенерация)
├── connection/
│ └── connection.dart # Фабрика подключений
├── tables/
│ ├── users.dart # Users, UserDatas
│ ├── auth.dart # Tokens, RefreshTokens, TelegramAuthCodes
│ ├── packs.dart # CardPacks, GameCards, VoiceModels
│ ├── relations.dart # UserPacks (many-to-many)
│ ├── subscriptions.dart # SubscriptionPlans, UserSubscriptions
│ ├── payments.dart # Payments
│ ├── tests.dart # Tests, TestQuestions, TestStatistics
│ ├── tasks.dart # Tasks, UserTasks, UserTaskProgresses, UserTaskResults
│ ├── promo_codes.dart # PromoCodesCampaigns, PromoCodes
│ ├── discounts.dart # DiscountCampaigns, Discounts
│ ├── statistics.dart # StudySessions
│ └── telegram.dart # ShareRequests
└── daos/
├── user_dao.dart # User CRUD operations
├── pack_dao.dart # CardPack CRUD
├── test_dao.dart # Test CRUD
├── payment_dao.dart # Payment CRUD
├── subscription_dao.dart # Subscription CRUD
├── task_dao.dart # Task CRUD
├── promo_code_dao.dart # PromoCode CRUD
├── discount_dao.dart # Discount CRUD
└── statistics_dao.dart # Statistics CRUD
2.2. Пример таблиц: Users
Создать файл: lib/database/tables/users.dart
import 'package:drift/drift.dart';
import 'dart:convert';
/// Таблица Users - основная информация о пользователях
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get email => text().nullable()();
TextColumn get externalUserId => text().unique()();
BoolColumn get admin => boolean().withDefault(const Constant(false))();
// Audit fields
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
@override
List<String> get customConstraints => [
'CONSTRAINT valid_email CHECK (email IS NULL OR email LIKE \'%@%\')',
];
}
/// Таблица UserDatas - расширенная информация о пользователе
class UserDatas extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().unique().references(Users, #id, onDelete: KeyAction.cascade)();
// Баланс и статистика
IntColumn get balance => integer().withDefault(const Constant(0))();
IntColumn get totalCards => integer().withDefault(const Constant(0))();
IntColumn get totalTests => integer().withDefault(const Constant(0))();
// Временные метки
DateTimeColumn get lastTimeOnline => dateTime().nullable()();
DateTimeColumn get registrationDate => dateTime().withDefault(currentDateAndTime)();
// Настройки пользователя (JSON)
TextColumn get settings => text()
.nullable()
.map(const JsonMapConverter())();
// Теги пользователя (JSON array)
TextColumn get tags => text()
.nullable()
.map(const JsonListConverter())();
// Audit
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
// Конвертеры для JSON полей
class JsonMapConverter extends TypeConverter<Map<String, dynamic>?, String> {
const JsonMapConverter();
@override
Map<String, dynamic>? fromSql(String? fromDb) {
if (fromDb == null || fromDb.isEmpty) return null;
return json.decode(fromDb) as Map<String, dynamic>;
}
@override
String? toSql(Map<String, dynamic>? value) {
if (value == null || value.isEmpty) return null;
return json.encode(value);
}
}
class JsonListConverter extends TypeConverter<List<String>?, String> {
const JsonListConverter();
@override
List<String>? fromSql(String? fromDb) {
if (fromDb == null || fromDb.isEmpty) return null;
final decoded = json.decode(fromDb);
return (decoded as List).map((e) => e.toString()).toList();
}
@override
String? toSql(List<String>? value) {
if (value == null || value.isEmpty) return null;
return json.encode(value);
}
}
2.3. Пример таблиц: Auth
Создать файл: lib/database/tables/auth.dart
import 'package:drift/drift.dart';
import 'users.dart';
/// Таблица Tokens - токены авторизации пользователей
class Tokens extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
TextColumn get token => text().unique()();
TextColumn get externalUserId => text()();
DateTimeColumn get created => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get expires => dateTime()();
@override
List<String> get customConstraints => [
'CONSTRAINT valid_expiry CHECK (expires > created)',
];
}
/// Таблица RefreshTokens - refresh токены для JWT
class RefreshTokens extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
TextColumn get token => text().unique()();
TextColumn get jti => text().unique()(); // JWT ID
BoolColumn get revoked => boolean().withDefault(const Constant(false))();
DateTimeColumn get revokedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get expiresAt => dateTime()();
// IP и User-Agent для безопасности
TextColumn get ipAddress => text().nullable()();
TextColumn get userAgent => text().nullable()();
}
/// Таблица TelegramAuthCodes - коды для авторизации через Telegram
class TelegramAuthCodes extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get code => text().unique()();
TextColumn get telegramUserId => text()();
TextColumn get telegramUsername => text().nullable()();
BoolColumn get used => boolean().withDefault(const Constant(false))();
DateTimeColumn get usedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get expiresAt => dateTime()();
}
2.4. Пример таблиц: CardPacks
Создать файл: lib/database/tables/packs.dart
import 'package:drift/drift.dart';
import 'dart:convert';
/// Таблица CardPacks - наборы карточек
class CardPacks extends Table {
IntColumn get id => integer().autoIncrement()();
// Основная информация
TextColumn get title => text()();
TextColumn get subtitle => text()();
TextColumn get description => text().nullable()();
// Визуальное оформление
TextColumn get color => text().nullable()();
TextColumn get cover => text().nullable()(); // URL обложки
// Параметры
IntColumn get size => integer()(); // количество карточек
TextColumn get version => text().nullable()();
IntColumn get order => integer().withDefault(const Constant(0))();
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
// Порядок карточек (JSON array of IDs)
TextColumn get cardsOrder => text()
.withDefault(const Constant('[]'))
.map(const IntListConverter())();
// Store IDs для покупок
TextColumn get googlePlayId => text().nullable()();
TextColumn get rustoreId => text().nullable()();
TextColumn get appStoreId => text().nullable()();
// Цена
TextColumn get price => text().nullable()();
TextColumn get currency => text().withDefault(const Constant('RUB'))();
// Audit
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
}
/// Таблица GameCards - карточки для изучения
class GameCards extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
// Основной контент
TextColumn get original => text()(); // слово на иностранном языке
TextColumn get translation => text()(); // перевод
TextColumn get mnemo => text().nullable()(); // мнемоническая подсказка
// Изображения
TextColumn get image => text().nullable()(); // основное изображение
TextColumn get imageBack => text().nullable()(); // изображение на обратной стороне
// Произношение
TextColumn get transcription => text().nullable()();
TextColumn get transcriptionMnemo => text().nullable()();
// Дополнительная информация
TextColumn get back => text().nullable()(); // дополнительный текст
// Audit
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
/// Таблица VoiceModels - голосовые файлы для карточек
class VoiceModels extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
TextColumn get voiceUrl => text()(); // URL аудиофайла
TextColumn get language => text()(); // язык озвучки
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
// Конвертер для списка int (порядок карточек)
class IntListConverter extends TypeConverter<List<int>, String> {
const IntListConverter();
@override
List<int> fromSql(String fromDb) {
if (fromDb.isEmpty || fromDb == '[]') return [];
final decoded = json.decode(fromDb);
return (decoded as List).map((e) => e as int).toList();
}
@override
String toSql(List<int> value) {
return json.encode(value);
}
}
2.5. Таблица связей: UserPacks (Many-to-Many)
Создать файл: lib/database/tables/relations.dart
import 'package:drift/drift.dart';
import 'users.dart';
import 'packs.dart';
/// Junction table для связи Users ↔ CardPacks (многие ко многим)
/// Хранит информацию о том, какие паки куплены пользователем
class UserPacks extends Table {
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
// Когда пользователь получил доступ к паку
DateTimeColumn get grantedAt => dateTime().withDefault(currentDateAndTime)();
// Как пользователь получил пак (purchase, promo, free, admin)
TextColumn get grantType => text().withDefault(const Constant('purchase'))();
@override
Set<Column> get primaryKey => {userId, packId};
}
/// Таблица PreviewCards - связь CardPacks с preview карточками
/// Хранит какие карточки показывать в превью пака
class PreviewCards extends Table {
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
IntColumn get order => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {packId, cardId};
}
2.6. Остальные таблицы
Создать файлы:
lib/database/tables/subscriptions.dart- SubscriptionPlans, UserSubscriptionslib/database/tables/payments.dart- Paymentslib/database/tables/tests.dart- Tests, TestQuestions, TestStatisticslib/database/tables/tasks.dart- Tasks, UserTasks, UserTaskProgresses, UserTaskResultslib/database/tables/promo_codes.dart- PromoCodesCampaigns, PromoCodeslib/database/tables/discounts.dart- DiscountCampaigns, Discountslib/database/tables/statistics.dart- StudySessionslib/database/tables/telegram.dart- ShareRequests
Примеры в каждом файле аналогичны приведенным выше.
2.7. Главный файл базы данных
Создать файл: lib/database/database.dart
import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:postgres/postgres.dart' as pg;
import 'dart:io';
// Импорт всех таблиц
import 'tables/users.dart';
import 'tables/auth.dart';
import 'tables/packs.dart';
import 'tables/relations.dart';
import 'tables/subscriptions.dart';
import 'tables/payments.dart';
import 'tables/tests.dart';
import 'tables/tasks.dart';
import 'tables/promo_codes.dart';
import 'tables/discounts.dart';
import 'tables/statistics.dart';
import 'tables/telegram.dart';
// Импорт DAOs (будут созданы позже)
import 'daos/user_dao.dart';
import 'daos/pack_dao.dart';
import 'daos/test_dao.dart';
import 'daos/payment_dao.dart';
import 'daos/subscription_dao.dart';
import 'daos/task_dao.dart';
import 'daos/promo_code_dao.dart';
import 'daos/discount_dao.dart';
import 'daos/statistics_dao.dart';
// Сгенерированный код будет здесь
part 'database.g.dart';
@DriftDatabase(
tables: [
// User tables
Users,
UserDatas,
// Auth tables
Tokens,
RefreshTokens,
TelegramAuthCodes,
// Pack tables
CardPacks,
GameCards,
VoiceModels,
// Relations
UserPacks,
PreviewCards,
// Subscription tables
SubscriptionPlans,
UserSubscriptions,
// Payment tables
Payments,
// Test tables
Tests,
TestQuestions,
TestStatistics,
// Task tables
Tasks,
UserTasks,
UserTaskProgresses,
UserTaskResults,
// Promo code tables
PromoCodesCampaigns,
PromoCodes,
// Discount tables
DiscountCampaigns,
Discounts,
// Statistics tables
StudySessions,
// Telegram tables
ShareRequests,
],
daos: [
UserDao,
PackDao,
TestDao,
PaymentDao,
SubscriptionDao,
TaskDao,
PromoCodeDao,
DiscountDao,
StatisticsDao,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
/// Factory для подключения к PostgreSQL
static AppDatabase connect({
required String host,
required int port,
required String database,
required String username,
required String password,
bool useSsl = false,
}) {
final endpoint = pg.Endpoint(
host: host,
port: port,
database: database,
username: username,
password: password,
);
final connection = PgDatabase(
endpoint: endpoint,
settings: pg.ConnectionSettings(
sslMode: useSsl ? pg.SslMode.require : pg.SslMode.disable,
connectTimeout: const Duration(seconds: 10),
),
);
return AppDatabase(connection);
}
/// Factory для подключения из environment variables
static AppDatabase fromEnvironment() {
return connect(
host: Platform.environment['DB_HOST'] ?? 'localhost',
port: int.parse(Platform.environment['DB_PORT'] ?? '5432'),
database: Platform.environment['DB_NAME'] ?? 'mnemo_cards_dev',
username: Platform.environment['DB_USER'] ?? 'mnemo_user',
password: Platform.environment['DB_PASSWORD'] ?? '',
useSsl: Platform.environment['DB_SSL_MODE'] == 'require',
);
}
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
print('Creating database schema...');
await m.createAll();
print('Database schema created successfully');
// Создать индексы для оптимизации
await _createIndexes();
},
onUpgrade: (Migrator m, int from, int to) async {
print('Migrating database from version $from to $to');
// Миграции при обновлении схемы
// if (from < 2) {
// await m.addColumn(users, users.phoneNumber);
// }
},
beforeOpen: (details) async {
print('Opening database connection...');
// Проверка подключения
final result = await customSelect('SELECT 1 as test').getSingle();
print('Database connection successful: ${result.data}');
// Включить foreign key constraints
await customStatement('SET CONSTRAINTS ALL IMMEDIATE');
},
);
/// Создание индексов для оптимизации запросов
Future<void> _createIndexes() async {
print('Creating indexes...');
// Users indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_external_id ON users(external_user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_admin ON users(admin) WHERE admin = true');
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_not_deleted ON users(is_deleted) WHERE is_deleted = false');
// UserDatas indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_datas_last_online ON user_datas(last_time_online DESC)');
// Auth indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON tokens(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_tokens_expires ON tokens(expires) WHERE expires > NOW()');
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_jti ON refresh_tokens(jti)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_not_revoked ON refresh_tokens(revoked) WHERE revoked = false');
await customStatement('CREATE INDEX IF NOT EXISTS idx_telegram_codes_code ON telegram_auth_codes(code)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_telegram_codes_not_used ON telegram_auth_codes(used) WHERE used = false');
// CardPacks indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_packs_enabled ON card_packs(enabled) WHERE enabled = true');
await customStatement('CREATE INDEX IF NOT EXISTS idx_packs_order ON card_packs("order")');
// GameCards indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_cards_pack_id ON game_cards(pack_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cards_original ON game_cards(original)');
// UserPacks indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_packs_user_id ON user_packs(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_packs_pack_id ON user_packs(pack_id)');
// Payments indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_created ON payments(created_at DESC)');
// Subscriptions indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON user_subscriptions(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_subscriptions_active ON user_subscriptions(end_date) WHERE end_date > NOW()');
// Tests indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_tests_pack_id ON tests(pack_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_test_questions_test_id ON test_questions(test_id)');
// StudySessions indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON study_sessions(user_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_sessions_started ON study_sessions(started_at DESC)');
print('Indexes created successfully');
}
}
2.8. Генерация кода
После создания всех таблиц, нужно сгенерировать Drift код:
cd mnemo_cards_backend
# Сгенерировать код
dart run build_runner build --delete-conflicting-outputs
# Или в watch mode (автогенерация при изменениях)
dart run build_runner watch --delete-conflicting-outputs
Это создаст файл lib/database/database.g.dart с сгенерированным кодом.
Этап 3: Создание DAOs
3.1. UserDao - CRUD для Users
Создать файл: lib/database/daos/user_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
import '../tables/users.dart';
import '../tables/auth.dart';
import '../tables/packs.dart';
import '../tables/relations.dart';
part 'user_dao.g.dart';
@DriftAccessor(tables: [Users, UserDatas, Tokens, RefreshTokens, UserPacks])
class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
UserDao(super.db);
// ==================== Users ====================
/// Получить пользователя по ID
Future<User?> getUserById(int id) {
return (select(users)..where((u) => u.id.equals(id))).getSingleOrNull();
}
/// Получить пользователя с UserData
Future<UserWithData?> getUserWithDataById(int id) async {
final query = select(users).join([
leftOuterJoin(userDatas, userDatas.userId.equalsExp(users.id)),
])..where(users.id.equals(id));
final result = await query.getSingleOrNull();
if (result == null) return null;
return UserWithData(
user: result.readTable(users),
userData: result.readTableOrNull(userDatas),
);
}
/// Получить пользователя по email
Future<User?> getUserByEmail(String email) {
return (select(users)..where((u) => u.email.equals(email))).getSingleOrNull();
}
/// Получить пользователя по externalUserId
Future<User?> getUserByExternalId(String externalId) {
return (select(users)
..where((u) => u.externalUserId.equals(externalId))
).getSingleOrNull();
}
/// Создать пользователя
Future<int> createUser(UsersCompanion user) {
return into(users).insert(user);
}
/// Создать пользователя с UserData
Future<int> createUserWithData({
required UsersCompanion user,
required UserDatasCompanion userData,
}) async {
return await transaction(() async {
final userId = await into(users).insert(user);
await into(userDatas).insert(userData.copyWith(userId: Value(userId)));
return userId;
});
}
/// Обновить пользователя
Future<bool> updateUser(User user) {
return update(users).replace(user);
}
/// Удалить пользователя (soft delete)
Future<void> softDeleteUser(int userId) {
return (update(users)..where((u) => u.id.equals(userId)))
.write(const UsersCompanion(isDeleted: Value(true)));
}
/// Получить всех пользователей (для админки)
Future<List<User>> getAllUsers({
int? limit,
int? offset,
bool includeDeleted = false,
}) {
final query = select(users);
if (!includeDeleted) {
query.where((u) => u.isDeleted.equals(false));
}
query.orderBy([(u) => OrderingTerm.desc(u.createdAt)]);
if (limit != null) {
query.limit(limit, offset: offset);
}
return query.get();
}
/// Подсчитать пользователей
Future<int> countUsers({bool includeDeleted = false}) async {
final countExpr = users.id.count();
final query = selectOnly(users)..addColumns([countExpr]);
if (!includeDeleted) {
query.where(users.isDeleted.equals(false));
}
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Получить пользователей с подпиской
Stream<List<User>> watchUsersWithActiveSubscription() {
// TODO: implement with join to UserSubscriptions
return (select(users)
..where((u) => u.isDeleted.equals(false))
).watch();
}
// ==================== UserData ====================
/// Получить UserData пользователя
Future<UserData?> getUserData(int userId) {
return (select(userDatas)..where((ud) => ud.userId.equals(userId)))
.getSingleOrNull();
}
/// Создать UserData
Future<int> createUserData(UserDatasCompanion userData) {
return into(userDatas).insert(userData);
}
/// Обновить UserData
Future<bool> updateUserData(UserData userData) {
return update(userDatas).replace(userData);
}
/// Обновить время последнего визита
Future<void> updateLastOnline(int userId) async {
await (update(userDatas)..where((ud) => ud.userId.equals(userId)))
.write(UserDatasCompanion(
lastTimeOnline: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
));
}
/// Обновить баланс пользователя
Future<void> updateBalance(int userId, int newBalance) async {
await (update(userDatas)..where((ud) => ud.userId.equals(userId)))
.write(UserDatasCompanion(
balance: Value(newBalance),
updatedAt: Value(DateTime.now()),
));
}
// ==================== Tokens ====================
/// Получить токен по значению
Future<Token?> getTokenByValue(String tokenValue) {
return (select(tokens)..where((t) => t.token.equals(tokenValue)))
.getSingleOrNull();
}
/// Получить токен пользователя
Future<Token?> getTokenByUserId(int userId) {
return (select(tokens)
..where((t) => t.userId.equals(userId))
..where((t) => t.expires.isBiggerThanValue(DateTime.now()))
..orderBy([(t) => OrderingTerm.desc(t.created)])
).getSingleOrNull();
}
/// Создать токен
Future<int> createToken(TokensCompanion token) {
return into(tokens).insert(token);
}
/// Удалить токен
Future<int> deleteToken(int tokenId) {
return (delete(tokens)..where((t) => t.id.equals(tokenId))).go();
}
/// Удалить истекшие токены
Future<int> deleteExpiredTokens() {
return (delete(tokens)
..where((t) => t.expires.isSmallerThanValue(DateTime.now()))
).go();
}
// ==================== User Packs ====================
/// Получить паки пользователя
Future<List<CardPack>> getUserPacks(int userId) async {
final query = select(cardPacks).join([
innerJoin(
userPacks,
userPacks.packId.equalsExp(cardPacks.id) &
userPacks.userId.equals(userId),
),
]);
return query.map((row) => row.readTable(cardPacks)).get();
}
/// Проверить, есть ли у пользователя доступ к паку
Future<bool> hasPackAccess(int userId, int packId) async {
final query = select(userPacks)
..where((up) => up.userId.equals(userId) & up.packId.equals(packId));
final result = await query.getSingleOrNull();
return result != null;
}
/// Дать пользователю доступ к паку
Future<void> grantPackAccess({
required int userId,
required int packId,
String grantType = 'purchase',
}) async {
await into(userPacks).insert(
UserPacksCompanion.insert(
userId: userId,
packId: packId,
grantType: Value(grantType),
),
mode: InsertMode.insertOrIgnore, // игнорировать если уже есть
);
}
/// Отозвать доступ к паку
Future<void> revokePackAccess(int userId, int packId) async {
await (delete(userPacks)
..where((up) => up.userId.equals(userId) & up.packId.equals(packId))
).go();
}
}
/// Вспомогательный класс для User с UserData
class UserWithData {
final User user;
final UserData? userData;
UserWithData({required this.user, this.userData});
}
3.2. Остальные DAOs
Создать аналогичные DAO файлы:
- PackDao (
lib/database/daos/pack_dao.dart) - CRUD для CardPacks, GameCards - TestDao (
lib/database/daos/test_dao.dart) - CRUD для Tests, TestQuestions - PaymentDao (
lib/database/daos/payment_dao.dart) - CRUD для Payments - SubscriptionDao (
lib/database/daos/subscription_dao.dart) - CRUD для Subscriptions - TaskDao (
lib/database/daos/task_dao.dart) - CRUD для Tasks - PromoCodeDao (
lib/database/daos/promo_code_dao.dart) - CRUD для PromoCodes - DiscountDao (
lib/database/daos/discount_dao.dart) - CRUD для Discounts - StatisticsDao (
lib/database/daos/statistics_dao.dart) - CRUD для StudySessions
Каждый DAO должен содержать:
- Методы получения (get, getById, getAll)
- Методы создания (create, insert)
- Методы обновления (update)
- Методы удаления (delete, soft delete)
- Специфичные методы для сущности (например, getActiveSubscriptions)
3.3. Генерация кода для DAOs
# Сгенерировать код после создания DAOs
dart run build_runner build --delete-conflicting-outputs
Этап 4: Рефакторинг кода
4.1. Обновление main.dart
Файл: lib/main.dart
Было:
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
late Isar isar;
Future<Isar> _initIsar(String dir, bool debugMode) async {
var attempt = 0;
while (attempt < 5) {
print('Attempt $attempt to initialize Isar');
try {
return isar = await IsarConnector().connect(
dir: dir,
name: 'db',
inspector: debugMode,
);
} catch (e, s) {
print('Error initializing Isar: $e');
log('Error initializing Isar: $e', error: e, stackTrace: s);
}
await Future.delayed(Duration(seconds: attempt * 10));
attempt++;
}
throw Exception('Failed to initialize Isar');
}
void main() async {
final dir = Platform.environment['ISAR_DIR'] ?? 'isar';
final debugMode = Platform.environment['DEBUG'] == 'true' ||
Platform.environment['DEBUG'] == '1';
_initIsar(dir, debugMode).then((isar) async {
getIt.registerSingleton<Isar>(isar);
configureDependencies();
await getIt<MnemoShelf>().initV2();
CronManager([...]).init();
});
ProcessSignal.sigterm.watch().listen((signal) async {
try {
await isar.close();
} catch (e, s) {
log('Error closing Isar: $e', error: e, stackTrace: s);
}
exit(0);
});
}
Стало:
import 'dart:developer';
import 'dart:io';
import 'package:mnemo_cards_backend/api/mnemo_shelf.dart';
import 'package:mnemo_cards_backend/cron/add_free_packs.dart';
import 'package:mnemo_cards_backend/cron/backup.dart';
import 'package:mnemo_cards_backend/cron/cron_executor.dart';
import 'package:mnemo_cards_backend/cron/generate_promocodes.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
// Импорт Drift database
import 'package:mnemo_cards_backend/database/database.dart';
import 'api/di/injector.dart';
import 'cron/check_admins.dart';
import 'cron/check_payment.dart';
import 'cron/delete_old_archives.dart';
import 'cron/discount_campaign_task.dart';
import 'cron/tasks_seeder.dart';
import 'cron/test_generator.dart';
import 'cron/update_online_users.dart';
import 'packs/free_packs_distributor.dart';
late AppDatabase database;
late final String WORK_DIR;
/// Инициализация подключения к PostgreSQL
Future<AppDatabase> _initDatabase() async {
var attempt = 0;
const maxAttempts = 5;
while (attempt < maxAttempts) {
print('Attempt ${attempt + 1}/$maxAttempts to connect to PostgreSQL...');
try {
// Создать подключение из environment variables
final db = AppDatabase.fromEnvironment();
// Проверить подключение
await db.customSelect('SELECT 1').getSingle();
print('✅ Successfully connected to PostgreSQL');
return database = db;
} catch (e, s) {
print('❌ Error connecting to PostgreSQL: $e');
log('Error connecting to PostgreSQL: $e', error: e, stackTrace: s);
attempt++;
if (attempt < maxAttempts) {
final delay = Duration(seconds: attempt * 5);
print('Retrying in ${delay.inSeconds} seconds...');
await Future.delayed(delay);
}
}
}
throw Exception('Failed to connect to PostgreSQL after $maxAttempts attempts');
}
void main() async {
print('🚀 Starting Mnemo Cards Backend...');
// Читаем environment variables
WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend';
final backupDir = Platform.environment['BACKUP_DIR'] ?? '../backups/';
final debugMode = Platform.environment['DEBUG'] == 'true' ||
Platform.environment['DEBUG'] == '1';
print('📂 Working directory: $WORK_DIR');
print('🐛 Debug mode: $debugMode');
// Инициализация PostgreSQL
try {
await _initDatabase();
// Регистрация в DI
getIt.registerSingleton<AppDatabase>(database);
// Настройка остальных зависимостей
configureDependencies();
// Запуск API сервера
print('🌐 Starting API server...');
await getIt<MnemoShelf>().initV2();
// Запуск cron jobs
print('⏰ Starting cron jobs...');
// ignore: unawaited_futures
CronManager([
DeleteOldArchives(),
CheckAdminsTask(),
TestGeneratorTask(getIt.get<TestManager>()),
getIt.get<CheckPaymentTask>(),
AddFreePacks(getIt.get<FreePacksDistributor>()),
Backup(backupDir),
GeneratePromocodes(),
DiscountCampaignTask(getIt.get<DiscountsManager>()),
UpdateOnlineUsersTask(getIt.get<UserManager>()),
TasksSeederTask(),
]).init();
print('✅ Backend started successfully!');
} catch (e, s) {
print('❌ Fatal error starting backend: $e');
log('Fatal error starting backend: $e', error: e, stackTrace: s);
exit(1);
}
// Обработка SIGTERM для graceful shutdown
ProcessSignal.sigterm.watch().listen((signal) async {
print('🛑 Received SIGTERM, shutting down gracefully...');
try {
// Закрыть подключение к БД
await database.close();
print('✅ Database connection closed');
} catch (e, s) {
print('❌ Error closing database: $e');
log('Error closing database: $e', error: e, stackTrace: s);
}
exit(0);
});
}
4.2. Обновление UserManager
Файл: lib/user/user_manager.dart
Заменить все прямые обращения к isar на вызовы через UserDao:
Было:
Future<UserModel?> fetchUser(Id id) async {
final user = await isar.userModels.get(id);
return user;
}
Стало:
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
@lazySingleton
class UserManager {
final AppDatabase _db;
final FreePacksDistributor _freePacksDistributor;
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
final AchievementManager _achievementManager;
UserManager(
this._db,
this._freePacksDistributor,
this._sessionTracker,
this._statisticsCalculator,
this._achievementManager,
);
Future<User?> fetchUser(int id) async {
return await _db.userDao.getUserById(id);
}
// ... остальные методы обновить аналогично
}
4.3. Обновление PackManager
Файл: lib/packs/pack_manager.dart
Было:
Future<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel,
Map<String, String>? params,
) async {
final models = await isar.cardPackModels
.filter()
.optional(
userModel?.admin != true,
(q) => q.enabledEqualTo(true),
)
.findAll();
models.sort((p, n) => p.order.compareTo(n.order));
// ...
}
Стало:
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
@lazySingleton
class PackManager {
final AppDatabase _db;
final PackDtoConverter packDtoConverter;
PackManager(this._db, this.packDtoConverter);
Future<List<CardPackPreviewDto>> listPacksPreviews(
User? user,
Map<String, String>? params,
) async {
// Получить паки через DAO
final models = await _db.packDao.getAllPacks(
enabledOnly: user?.admin != true,
orderByField: 'order',
);
// ... остальная логика
}
}
4.4. Обновление API endpoints
Для каждого API файла обновить обращения к БД:
Пример: lib/api/v2/users_api_v2.dart
Было:
await backend_main.isar.writeTxn(() async {
final current = await backend_main.isar.userModels.get(user.id!);
if (current == null) {
throw StateError('User not found');
}
final updated = current.copyWith(
name: name ?? current.name,
email: email ?? current.email,
);
await backend_main.isar.userModels.put(updated);
});
Стало:
await _db.transaction(() async {
final current = await _db.userDao.getUserById(user.id!);
if (current == null) {
throw StateError('User not found');
}
await _db.userDao.updateUser(current.copyWith(
name: name ?? current.name,
email: email ?? current.email,
));
});
4.5. Приоритет рефакторинга
Высокий приоритет (критичные для работы):
- ✅
lib/main.dart- инициализация БД - ✅
lib/user/user_manager.dart- аутентификация - ✅
lib/api/v2/auth_api_v2.dart- логин/регистрация - ✅
lib/api/v2/jwt_service.dart- токены - ✅
lib/api/purchase/payment_manager.dart- платежи
Средний приоритет:
6. lib/packs/pack_manager.dart
7. lib/api/v2/packs_api_v2.dart
8. lib/api/v2/users_api_v2.dart
9. lib/tests/test_manager.dart
10. lib/api/subscription/subscription_manager.dart
Низкий приоритет: 11-34. Admin API, cron jobs, статистика
4.6. Миграция моделей и менеджеров
4.6.1. Маппинг Isar моделей → Drift таблицы
Принципы миграции:
-
Isar @Collection → Drift Table
@Collection()класс превращается в класс, наследующийTable- Поля модели становятся методами с типом
Column
-
Типы данных:
// Isar → Drift Id → IntColumn (autoIncrement) int → IntColumn String → TextColumn bool → BoolColumn DateTime → DateTimeColumn double → RealColumn List<String> → TextColumn + JsonListConverter Map<String, dynamic> → TextColumn + JsonMapConverter -
Отношения:
// Isar Links → Foreign Keys final pack = IsarLink<CardPackModel>() → IntColumn get packId => integer().references(CardPacks, #id)
Пример миграции модели:
Было (Isar): lib/models/user_model.dart
import 'package:isar/isar.dart';
@Collection()
class UserModel {
Id? id;
@Index(unique: true)
late String externalUserId;
late String name;
String? email;
@Index()
bool admin = false;
DateTime? createdAt;
bool isDeleted = false;
// Связь с UserData
final userData = IsarLink<UserDataModel>();
}
Стало (Drift): lib/database/tables/users.dart
import 'package:drift/drift.dart';
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get email => text().nullable()();
TextColumn get externalUserId => text().unique()();
BoolColumn get admin => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
// UserData в отдельной таблице с Foreign Key
class UserDatas extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().unique()
.references(Users, #id, onDelete: KeyAction.cascade)();
IntColumn get balance => integer().withDefault(const Constant(0))();
DateTimeColumn get lastTimeOnline => dateTime().nullable()();
// ... остальные поля
}
4.6.2. Рефакторинг менеджеров
Шаблон рефакторинга для всех Manager классов:
1. Заменить зависимость от Isar на AppDatabase
// Было
@lazySingleton
class UserManager {
final Isar _isar;
UserManager(this._isar);
}
// Стало
@lazySingleton
class UserManager {
final AppDatabase _db;
UserManager(this._db);
}
2. Заменить прямые обращения к коллекциям на DAO методы
// Было
Future<UserModel?> getUser(Id id) async {
return await _isar.userModels.get(id);
}
// Стало
Future<User?> getUser(int id) async {
return await _db.userDao.getUserById(id);
}
3. Заменить транзакции
// Было
await _isar.writeTxn(() async {
await _isar.userModels.put(user);
await _isar.userDataModels.put(userData);
});
// Стало
await _db.transaction(() async {
await _db.userDao.updateUser(user);
await _db.userDao.updateUserData(userData);
});
4. Заменить фильтры и запросы
// Было
final users = await _isar.userModels
.filter()
.adminEqualTo(true)
.isDeletedEqualTo(false)
.findAll();
// Стало
final users = await _db.userDao.getAllUsers(
includeDeleted: false,
adminOnly: true,
);
5. Заменить стримы
// Было
Stream<List<UserModel>> watchUsers() {
return _isar.userModels.watchLazy().map((_) =>
_isar.userModels.where().findAll()
);
}
// Стало
Stream<List<User>> watchUsers() {
return _db.userDao.watchAllUsers();
}
4.6.3. Полный список менеджеров для миграции
Таблица соответствия:
| Менеджер | Файл | Зависимые модели | DAO |
|---|---|---|---|
| UserManager | lib/user/user_manager.dart |
UserModel, UserDataModel | UserDao |
| PackManager | lib/packs/pack_manager.dart |
CardPackModel, GameCardModel | PackDao |
| TestManager | lib/tests/test_manager.dart |
TestModel, TestQuestionModel | TestDao |
| PaymentManager | lib/api/purchase/payment_manager.dart |
PaymentModel | PaymentDao |
| SubscriptionManager | lib/api/subscription/subscription_manager.dart |
SubscriptionPlanModel, UserSubscriptionModel | SubscriptionDao |
| TaskManager | lib/tasks/task_manager.dart |
TaskModel, UserTaskModel | TaskDao |
| PromoCodeManager | lib/promo_codes/promo_code_manager.dart |
PromoCodeModel | PromoCodeDao |
| DiscountsManager | lib/discounts/discounts_manager.dart |
DiscountModel | DiscountDao |
| StatisticsCalculator | lib/statistics/statistics_calculator.dart |
StudySessionModel | StatisticsDao |
| AuthManager | lib/api/v2/auth_manager.dart |
TokenModel, RefreshTokenModel | UserDao (включает Tokens) |
4.6.4. Пошаговая миграция менеджера
Пример: PackManager
Шаг 1. Обновить зависимости
// Было
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@lazySingleton
class PackManager {
final Isar _isar;
final PackDtoConverter _packDtoConverter;
PackManager(this._isar, this._packDtoConverter);
// Стало
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/api/dto/pack_dto.dart';
@lazySingleton
class PackManager {
final AppDatabase _db;
final PackDtoConverter _packDtoConverter;
PackManager(this._db, this._packDtoConverter);
Шаг 2. Обновить методы получения
// Было
Future<CardPackModel?> getPack(Id id) async {
return await _isar.cardPackModels.get(id);
}
Future<List<CardPackModel>> getAllPacks() async {
return await _isar.cardPackModels
.filter()
.enabledEqualTo(true)
.sortByOrder()
.findAll();
}
// Стало
Future<CardPack?> getPack(int id) async {
return await _db.packDao.getPackById(id);
}
Future<List<CardPack>> getAllPacks() async {
return await _db.packDao.getAllPacks(
enabledOnly: true,
orderByField: 'order',
);
}
Шаг 3. Обновить методы создания/обновления
// Было
Future<Id> createPack(CardPackModel pack) async {
return await _isar.writeTxn(() async {
return await _isar.cardPackModels.put(pack);
});
}
// Стало
Future<int> createPack(CardPacksCompanion pack) async {
return await _db.packDao.createPack(pack);
}
Шаг 4. Обновить сложные запросы
// Было
Future<List<CardPackModel>> getUserPacks(Id userId) async {
final user = await _isar.userModels.get(userId);
if (user == null) return [];
await user.packs.load();
return user.packs.toList();
}
// Стало
Future<List<CardPack>> getUserPacks(int userId) async {
return await _db.userDao.getUserPacks(userId);
}
Шаг 5. Обновить транзакции
// Было
Future<void> addCardsToPack(Id packId, List<GameCardModel> cards) async {
await _isar.writeTxn(() async {
final pack = await _isar.cardPackModels.get(packId);
if (pack == null) throw StateError('Pack not found');
await _isar.gameCardModels.putAll(cards);
pack.size = cards.length;
await _isar.cardPackModels.put(pack);
});
}
// Стало
Future<void> addCardsToPack(int packId, List<GameCardsCompanion> cards) async {
await _db.transaction(() async {
final pack = await _db.packDao.getPackById(packId);
if (pack == null) throw StateError('Pack not found');
for (final card in cards) {
await _db.packDao.createCard(card);
}
await _db.packDao.updatePack(pack.copyWith(size: cards.length));
});
}
4.6.5. Обновление DI (Dependency Injection)
Файл: lib/api/di/injector.dart
// Было
@module
abstract class AppModule {
@singleton
Isar get isar => backend_main.isar;
@lazySingleton
UserManager userManager(Isar isar) => UserManager(isar);
@lazySingleton
PackManager packManager(Isar isar, PackDtoConverter converter) =>
PackManager(isar, converter);
}
// Стало
@module
abstract class AppModule {
@singleton
AppDatabase get database => backend_main.database;
@lazySingleton
UserManager userManager(AppDatabase db) => UserManager(db);
@lazySingleton
PackManager packManager(AppDatabase db, PackDtoConverter converter) =>
PackManager(db, converter);
}
После изменений:
dart run build_runner build --delete-conflicting-outputs
4.6.6. Checklist миграции для каждого менеджера
Для каждого менеджера проверить:
- Заменена зависимость
IsarнаAppDatabase - Все методы
get()заменены наdao.getById() - Все методы
filter().findAll()заменены на DAO методы - Все
writeTxn()заменены наtransaction() - Все
IsarLinkзаменены на JOIN запросы через DAO - Все типы
Idзаменены наint - Все модели
*Modelзаменены на Drift data классы - Companion классы используются для insert/update
- Unit тесты обновлены
- DI обновлён в
injector.dart
4.6.7. Пример полной миграции менеджера
Файл: lib/api/purchase/payment_manager.dart
// ========== БЫЛО ==========
import 'package:isar/isar.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@lazySingleton
class PaymentManager {
final Isar _isar;
final YookassaClient _yookassa;
PaymentManager(this._isar, this._yookassa);
Future<PaymentModel> createPayment({
required Id userId,
required Id packId,
required double amount,
}) async {
final payment = PaymentModel()
..userId = userId
..packId = packId
..amount = amount
..status = 'pending'
..createdAt = DateTime.now();
await _isar.writeTxn(() async {
await _isar.paymentModels.put(payment);
});
return payment;
}
Future<void> confirmPayment(Id paymentId) async {
await _isar.writeTxn(() async {
final payment = await _isar.paymentModels.get(paymentId);
if (payment == null) throw StateError('Payment not found');
payment.status = 'confirmed';
payment.confirmedAt = DateTime.now();
await _isar.paymentModels.put(payment);
// Дать доступ к паку
final user = await _isar.userModels.get(payment.userId);
final pack = await _isar.cardPackModels.get(payment.packId);
if (user != null && pack != null) {
await user.packs.load();
user.packs.add(pack);
await user.packs.save();
}
});
}
Future<List<PaymentModel>> getUserPayments(Id userId) async {
return await _isar.paymentModels
.filter()
.userIdEqualTo(userId)
.sortByCreatedAtDesc()
.findAll();
}
}
// ========== СТАЛО ==========
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/api/yookassa/yookassa_client.dart';
import 'package:drift/drift.dart' as drift;
@lazySingleton
class PaymentManager {
final AppDatabase _db;
final YookassaClient _yookassa;
PaymentManager(this._db, this._yookassa);
Future<Payment> createPayment({
required int userId,
required int packId,
required double amount,
}) async {
final companion = PaymentsCompanion.insert(
userId: userId,
packId: packId,
amount: amount.toString(),
status: 'pending',
);
final paymentId = await _db.paymentDao.createPayment(companion);
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) {
throw StateError('Failed to create payment');
}
return payment;
}
Future<void> confirmPayment(int paymentId) async {
await _db.transaction(() async {
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) throw StateError('Payment not found');
// Обновить статус платежа
await _db.paymentDao.updatePayment(
payment.copyWith(
status: 'confirmed',
confirmedAt: drift.Value(DateTime.now()),
),
);
// Дать доступ к паку
await _db.userDao.grantPackAccess(
userId: payment.userId,
packId: payment.packId,
grantType: 'purchase',
);
});
}
Future<List<Payment>> getUserPayments(int userId) async {
return await _db.paymentDao.getPaymentsByUserId(
userId,
orderByCreatedAt: true,
descending: true,
);
}
}
4.6.8. Автоматизация проверки миграции
Создать скрипт: scripts/check_migration_progress.dart
import 'dart:io';
void main() {
final menedgersToMigrate = [
'lib/user/user_manager.dart',
'lib/packs/pack_manager.dart',
'lib/tests/test_manager.dart',
'lib/api/purchase/payment_manager.dart',
'lib/api/subscription/subscription_manager.dart',
'lib/tasks/task_manager.dart',
'lib/promo_codes/promo_code_manager.dart',
'lib/discounts/discounts_manager.dart',
'lib/statistics/statistics_calculator.dart',
];
print('🔍 Checking migration progress...\n');
int migrated = 0;
int notMigrated = 0;
for (final path in menedgersToMigrate) {
final file = File(path);
if (!file.existsSync()) {
print('❓ $path - file not found');
continue;
}
final content = file.readAsStringSync();
final hasIsar = content.contains('import \'package:isar/isar.dart\'');
final hasDrift = content.contains('AppDatabase');
if (hasDrift && !hasIsar) {
print('✅ $path - migrated');
migrated++;
} else if (hasIsar) {
print('❌ $path - NOT migrated (still uses Isar)');
notMigrated++;
} else {
print('⚠️ $path - unclear state');
}
}
print('\n📊 Summary:');
print('Migrated: $migrated / ${menedgersToMigrate.length}');
print('Not migrated: $notMigrated / ${menedgersToMigrate.length}');
print('Progress: ${(migrated / menedgersToMigrate.length * 100).toStringAsFixed(1)}%');
}
Запуск:
dart run scripts/check_migration_progress.dart
Этап 5: Тестирование
5.1. Обновление unit тестов
Пример: test/api/v2/users_api_v2_test.dart
Было:
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
testIsar = await Isar.open([...schemas], directory: testDir.path);
backend_main.isar = testIsar;
});
Стало:
late AppDatabase testDb;
setUpAll(() async {
// Создать тестовую БД (можно использовать in-memory или testcontainers)
testDb = AppDatabase.connect(
host: 'localhost',
port: 5433, // отдельный порт для тестов
database: 'mnemo_cards_test',
username: 'test_user',
password: 'test_pass',
);
// Создать схему
await testDb.migrator.createAll();
backend_main.database = testDb;
});
tearDown(() async {
// Очистить данные после каждого теста
await testDb.transaction(() async {
await testDb.delete(testDb.users).go();
await testDb.delete(testDb.cardPacks).go();
// ...
});
});
tearDownAll() async {
await testDb.close();
});
6.2. Mock DAOs для unit тестов
Создать файл: test/mocks/mock_user_dao.dart
import 'package:mockito/mockito.dart';
import 'package:mnemo_cards_backend/database/database.dart';
class MockUserDao extends Mock implements UserDao {}
class MockPackDao extends Mock implements PackDao {}
// ... остальные mock DAOs
6.3. Integration тесты
Создать файл: test/integration/database_test.dart
import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';
void main() {
late AppDatabase db;
setUpAll(() async {
db = AppDatabase.connect(
host: 'localhost',
port: 5433,
database: 'mnemo_cards_test',
username: 'test_user',
password: 'test_pass',
);
await db.migrator.createAll();
});
tearDownAll(() async {
await db.close();
});
group('UserDao', () {
test('создание и получение пользователя', () async {
// Arrange
final user = UsersCompanion.insert(
name: 'Test User',
externalUserId: 'test123',
);
// Act
final userId = await db.userDao.createUser(user);
final retrieved = await db.userDao.getUserById(userId);
// Assert
expect(retrieved, isNotNull);
expect(retrieved!.name, equals('Test User'));
expect(retrieved.externalUserId, equals('test123'));
});
// ... остальные тесты
});
}
Этап 6: Deployment
6.1. Обновление Dockerfile
Файл: mnemo_cards_backend/Dockerfile
# syntax=docker/dockerfile:1.7
# --- Build stage ------------------------------------------------------------
FROM dart:stable-sdk AS build
WORKDIR /app
# Copy sources
COPY mnemo_cards_common /app/mnemo_cards_common
COPY mnemo_cards_backend /app/mnemo_cards_backend
# Get dependencies
WORKDIR /app/mnemo_cards_common
RUN dart pub get
WORKDIR /app/mnemo_cards_backend
RUN dart pub get
# Generate Drift code
RUN dart run build_runner build --delete-conflicting-outputs
# Compile to native executable
RUN dart compile exe lib/main.dart -o /app/server
# --- Runtime stage ----------------------------------------------------------
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
openssl \
curl \
wget \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# App binary
COPY --from=build /app/server /app/server
# Static/assets
COPY --from=build /app/mnemo_cards_backend/public /app/public
COPY --from=build /app/mnemo_cards_backend/data /app/data
# Environment variables
ENV PORT=3000 \
SERVER_ADDRESS=0.0.0.0 \
WORK_DIR=/app \
DEBUG=false \
ADMIN_IDS= \
DB_HOST=postgres \
DB_PORT=5432 \
DB_NAME=mnemo_cards \
DB_USER=mnemo_user \
DB_PASSWORD= \
DB_SSL_MODE=disable
EXPOSE 3000
# Healthcheck
HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f -sS --max-time 8 --connect-timeout 3 http://127.0.0.1:${PORT:-3000}/health > /dev/null 2>&1 || exit 1
CMD ["/app/server"]
7.2. docker-compose для продакшена
Файл: docker-compose.prod.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: mnemo_postgres_prod
environment:
POSTGRES_DB: mnemo_cards
POSTGRES_USER: mnemo_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- postgres_prod_data:/var/lib/postgresql/data
networks:
- mnemo_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mnemo_user -d mnemo_cards"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
secrets:
- db_password
backend:
image: mnemo_backend:latest
container_name: mnemo_backend_prod
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: mnemo_cards
DB_USER: mnemo_user
DB_PASSWORD_FILE: /run/secrets/db_password
DB_SSL_MODE: require
PORT: 3000
DEBUG: false
ADMIN_IDS: ${ADMIN_IDS}
JWT_SECRET_FILE: /run/secrets/jwt_secret
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
networks:
- mnemo_network
restart: unless-stopped
secrets:
- db_password
- jwt_secret
# Опционально: PgBouncer для connection pooling
pgbouncer:
image: pgbouncer/pgbouncer:latest
environment:
DATABASES_HOST: postgres
DATABASES_PORT: 5432
DATABASES_DBNAME: mnemo_cards
DATABASES_USER: mnemo_user
PGBOUNCER_POOL_MODE: transaction
PGBOUNCER_MAX_CLIENT_CONN: 1000
PGBOUNCER_DEFAULT_POOL_SIZE: 25
ports:
- "6432:5432"
depends_on:
- postgres
networks:
- mnemo_network
volumes:
postgres_prod_data:
driver: local
networks:
mnemo_network:
driver: bridge
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
7.3. План production миграции
Zero-downtime migration plan:
-
Подготовка:
# Создать backup Isar БД cp -r isar/ isar_backup_$(date +%Y%m%d)/ # Развернуть PostgreSQL (managed или на сервере) # Настроить connection string -
Миграция данных:
# Включить maintenance mode (опционально) # Создать read-only режим на время миграции # Запустить миграцию dart run scripts/migrate_isar_to_postgres.dart # Верифицировать dart run scripts/verify_migration.dart -
Deployment новой версии:
# Билд нового образа docker build -t mnemo_backend:2.0 . # Обновить environment variables (убрать ISAR_DIR, добавить DB_*) # Развернуть docker service update --image mnemo_backend:2.0 mnemo_backend # Или через docker-compose docker-compose up -d backend -
Проверка:
# Проверить health endpoint curl http://localhost:3000/health # Проверить логи docker-compose logs -f backend # Smoke tests (создать пользователя, получить паки, и т.д.) -
Rollback план (если что-то пошло не так):
# Откатить Docker image docker service update --image mnemo_backend:1.9 mnemo_backend # Восстановить Isar БД из бэкапа rm -rf isar/ cp -r isar_backup_YYYYMMDD/ isar/ # Проверить работоспособность
Чеклисты
Pre-migration Checklist
- PostgreSQL развернут (Docker/managed service)
- Connection string настроен и проверен
- Drift зависимости установлены (
dart pub get) - Все таблицы созданы
- Все DAOs созданы
- Drift code сгенерирован (
dart run build_runner build) - Backup Isar БД создан
- Скрипт миграции протестирован на копии данных
Migration Checklist
- Maintenance mode включен (опционально)
- Скрипт миграции запущен
- Миграция завершилась без ошибок
- Verification script успешно прошел
- Количество записей в PostgreSQL совпадает с Isar
- Sample queries возвращают корректные данные
Post-migration Checklist
- Backend рефакторинг завершен (все файлы обновлены)
- Unit тесты обновлены и проходят
- Integration тесты написаны и проходят
- Docker image собран
- Environment variables обновлены
- Backend развернут в production
- Health check проходит
- API endpoints работают корректно
- Smoke tests пройдены
- Логи не содержат критических ошибок
- Мониторинг настроен (PostgreSQL + Backend)
- Backup настроен (automated PostgreSQL backups)
- Documentation обновлена
Rollback Checklist (если нужен откат)
- Docker image откачен на предыдущую версию
- Isar БД восстановлена из backup
- Environment variables восстановлены (ISAR_DIR и т.д.)
- Backend перезапущен
- Health check проходит
- API endpoints работают
- Логи проверены
Дополнительные ресурсы
Документация
Инструменты мониторинга
- pgAdmin - GUI для PostgreSQL
- DBeaver - универсальный клиент БД
- pg_stat_statements - статистика запросов
- Metabase - аналитика и dashboards
Managed PostgreSQL провайдеры
- Supabase - бесплатный tier
- Railway - ~$5/месяц
- Neon - serverless PostgreSQL
- AWS RDS
- Google Cloud SQL
Контакты и поддержка
При возникновении проблем:
- Проверить логи:
docker-compose logs -f backend postgres - Проверить connectivity:
docker-compose exec backend ping postgres - Проверить PostgreSQL:
docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev
Успешной миграции! 🚀