mnemo_cards/mnemo_cards_backend/DB_PLAN.md
2026-01-03 16:14:27 +03:00

74 KiB
Raw Permalink Blame History

План миграции с Isar на PostgreSQL + Drift

Содержание

  1. Обзор миграции
  2. Архитектурные решения
  3. Этап 1: Подготовка инфраструктуры
  4. Этап 2: Создание Drift схем
  5. Этап 3: Создание DAOs
  6. Этап 4: Рефакторинг кода
  7. Этап 5: Миграция данных
  8. Этап 6: Тестирование
  9. Этап 7: Deployment
  10. Чеклисты

Обзор миграции

Цель

Заменить 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 (для продакшена)

Провайдеры:

  1. Supabase (бесплатный tier)

    • URL: https://supabase.com
    • Создать проект → получить connection string
    • Плюсы: бесплатно до 500MB, auth из коробки, realtime
  2. Railway (~$5/месяц)

    • URL: https://railway.app
    • New Project → PostgreSQL
    • Плюсы: простой deployment, auto-backups
  3. Neon (serverless PostgreSQL)

    • URL: https://neon.tech
    • Бесплатный tier до 3GB
    • Плюсы: serverless, бесплатно, быстрый
  4. 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. Остальные таблицы

Создать файлы:

  1. lib/database/tables/subscriptions.dart - SubscriptionPlans, UserSubscriptions
  2. lib/database/tables/payments.dart - Payments
  3. lib/database/tables/tests.dart - Tests, TestQuestions, TestStatistics
  4. lib/database/tables/tasks.dart - Tasks, UserTasks, UserTaskProgresses, UserTaskResults
  5. lib/database/tables/promo_codes.dart - PromoCodesCampaigns, PromoCodes
  6. lib/database/tables/discounts.dart - DiscountCampaigns, Discounts
  7. lib/database/tables/statistics.dart - StudySessions
  8. lib/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 файлы:

  1. PackDao (lib/database/daos/pack_dao.dart) - CRUD для CardPacks, GameCards
  2. TestDao (lib/database/daos/test_dao.dart) - CRUD для Tests, TestQuestions
  3. PaymentDao (lib/database/daos/payment_dao.dart) - CRUD для Payments
  4. SubscriptionDao (lib/database/daos/subscription_dao.dart) - CRUD для Subscriptions
  5. TaskDao (lib/database/daos/task_dao.dart) - CRUD для Tasks
  6. PromoCodeDao (lib/database/daos/promo_code_dao.dart) - CRUD для PromoCodes
  7. DiscountDao (lib/database/daos/discount_dao.dart) - CRUD для Discounts
  8. 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. Приоритет рефакторинга

Высокий приоритет (критичные для работы):

  1. lib/main.dart - инициализация БД
  2. lib/user/user_manager.dart - аутентификация
  3. lib/api/v2/auth_api_v2.dart - логин/регистрация
  4. lib/api/v2/jwt_service.dart - токены
  5. 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 таблицы

Принципы миграции:

  1. Isar @CollectionDrift Table

    • @Collection() класс превращается в класс, наследующий Table
    • Поля модели становятся методами с типом Column
  2. Типы данных:

    // Isar → Drift
    Id  IntColumn (autoIncrement)
    int  IntColumn
    String  TextColumn
    bool  BoolColumn
    DateTime  DateTimeColumn
    double  RealColumn
    List<String>  TextColumn + JsonListConverter
    Map<String, dynamic>  TextColumn + JsonMapConverter
    
  3. Отношения:

    // 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:

  1. Подготовка:

    # Создать backup Isar БД
    cp -r isar/ isar_backup_$(date +%Y%m%d)/
    
    # Развернуть PostgreSQL (managed или на сервере)
    # Настроить connection string
    
  2. Миграция данных:

    # Включить maintenance mode (опционально)
    # Создать read-only режим на время миграции
    
    # Запустить миграцию
    dart run scripts/migrate_isar_to_postgres.dart
    
    # Верифицировать
    dart run scripts/verify_migration.dart
    
  3. 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
    
  4. Проверка:

    # Проверить health endpoint
    curl http://localhost:3000/health
    
    # Проверить логи
    docker-compose logs -f backend
    
    # Smoke tests (создать пользователя, получить паки, и т.д.)
    
  5. 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 провайдеры


Контакты и поддержка

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

  1. Проверить логи: docker-compose logs -f backend postgres
  2. Проверить connectivity: docker-compose exec backend ping postgres
  3. Проверить PostgreSQL: docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev

Успешной миграции! 🚀