mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-30 01:07:17 +00:00
Migration 0001 uses Base.metadata.create_all() which creates ALL tables from current models.py, causing subsequent migrations (0015+) to fail with "already exists" errors when they try to re-create constraints, indexes, columns, and tables. Three-layer fix: 1. migrations.py: detect fresh DB (no tables) and bootstrap via create_all() + stamp head, bypassing all migrations entirely. 2. models.py: add EmailTemplate model, CheckConstraints to LandingPage, and indexes to GuestPurchase so create_all() produces a complete schema identical to running all 30 migrations sequentially. 3. Idempotency guards in migrations 0015-0030: _has_unique_constraint, _has_table, _has_index, _has_column, _has_check_constraint checks before DDL operations, protecting against re-runs via make migrate.
104 lines
3.7 KiB
Python
104 lines
3.7 KiB
Python
"""Programmatic Alembic migration runner for bot startup."""
|
||
|
||
from pathlib import Path
|
||
|
||
import structlog
|
||
from alembic import command
|
||
from alembic.config import Config
|
||
from sqlalchemy import inspect
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||
_ALEMBIC_INI = _PROJECT_ROOT / 'alembic.ini'
|
||
|
||
|
||
def _get_alembic_config() -> Config:
|
||
"""Build Alembic Config pointing at the project root."""
|
||
from app.config import settings
|
||
|
||
cfg = Config(str(_ALEMBIC_INI))
|
||
cfg.set_main_option('sqlalchemy.url', settings.get_database_url())
|
||
return cfg
|
||
|
||
|
||
async def _detect_db_state() -> str:
|
||
"""Detect database state: 'fresh', 'legacy', or 'managed'.
|
||
|
||
- fresh: no tables at all — brand new database
|
||
- legacy: has tables but no alembic_version (transition from universal_migration)
|
||
- managed: has alembic_version — already managed by Alembic
|
||
"""
|
||
from app.database.database import engine
|
||
|
||
async with engine.connect() as conn:
|
||
has_alembic = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('alembic_version'))
|
||
if has_alembic:
|
||
return 'managed'
|
||
has_users = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('users'))
|
||
return 'legacy' if has_users else 'fresh'
|
||
|
||
|
||
_INITIAL_REVISION = '0001'
|
||
|
||
|
||
async def _bootstrap_fresh_db() -> None:
|
||
"""Bootstrap a fresh database: create all tables from models and stamp at head.
|
||
|
||
On a fresh DB, running all migrations sequentially would fail because
|
||
migration 0001 uses Base.metadata.create_all() which creates ALL tables
|
||
from the current models.py (including columns/constraints/indexes added
|
||
by later migrations), and then those later migrations try to re-create
|
||
the same objects. Instead, we create the full schema directly and stamp
|
||
the migration history at HEAD so Alembic considers all migrations applied.
|
||
"""
|
||
from app.database.database import engine
|
||
from app.database.models import Base
|
||
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
|
||
logger.info('Свежая БД: все таблицы созданы из моделей')
|
||
|
||
|
||
async def run_alembic_upgrade() -> None:
|
||
"""Run ``alembic upgrade head``, handling fresh and legacy databases."""
|
||
import asyncio
|
||
|
||
db_state = await _detect_db_state()
|
||
|
||
if db_state == 'fresh':
|
||
logger.warning('Обнаружена пустая БД — создание схемы из моделей + stamp head')
|
||
await _bootstrap_fresh_db()
|
||
await _stamp_alembic_revision('head')
|
||
return
|
||
|
||
if db_state == 'legacy':
|
||
logger.warning(
|
||
'Обнаружена существующая БД без alembic_version — автоматический stamp 0001 (переход с universal_migration)'
|
||
)
|
||
await _stamp_alembic_revision(_INITIAL_REVISION)
|
||
|
||
cfg = _get_alembic_config()
|
||
loop = asyncio.get_running_loop()
|
||
# run_in_executor offloads to a thread where env.py can safely
|
||
# call asyncio.run() to create its own event loop.
|
||
await loop.run_in_executor(None, command.upgrade, cfg, 'head')
|
||
logger.info('Alembic миграции применены')
|
||
|
||
|
||
async def stamp_alembic_head() -> None:
|
||
"""Stamp the DB as being at head without running migrations (for existing DBs)."""
|
||
await _stamp_alembic_revision('head')
|
||
|
||
|
||
async def _stamp_alembic_revision(revision: str) -> None:
|
||
"""Stamp the DB at a specific revision without running migrations."""
|
||
import asyncio
|
||
|
||
cfg = _get_alembic_config()
|
||
loop = asyncio.get_running_loop()
|
||
await loop.run_in_executor(None, command.stamp, cfg, revision)
|
||
logger.info('Alembic: база отмечена как актуальная', revision=revision)
|