""" Lightweight, idempotent DB migrations. This project historically used `Base.metadata.create_all()` for bootstrapping new installs. That does NOT handle schema evolution on existing databases. For performance-related changes (indexes, new optional columns), we apply additive migrations on startup. Important: - Only additive changes (ADD COLUMN / CREATE INDEX) should live here. - Operations must be idempotent (safe to run on every startup). """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncConnection logger = logging.getLogger(__name__) async def _sqlite_table_exists(conn: AsyncConnection, table: str) -> bool: res = await conn.execute( text("SELECT 1 FROM sqlite_master WHERE type='table' AND name=:name LIMIT 1"), {"name": table}, ) return res.scalar() is not None async def _sqlite_has_column(conn: AsyncConnection, table: str, column: str) -> bool: res = await conn.execute(text(f"PRAGMA table_info({table})")) rows = res.fetchall() # PRAGMA table_info: (cid, name, type, notnull, dflt_value, pk) return any(r[1] == column for r in rows) async def _postgres_table_exists(conn: AsyncConnection, table: str) -> bool: # to_regclass returns NULL if the relation does not exist res = await conn.execute(text("SELECT to_regclass(:name)"), {"name": table}) return res.scalar() is not None async def _postgres_has_column(conn: AsyncConnection, table: str, column: str) -> bool: res = await conn.execute( text( """ SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = :table AND column_name = :column LIMIT 1 """ ), {"table": table, "column": column}, ) return res.scalar() is not None async def _table_exists(conn: AsyncConnection, table: str) -> bool: dialect = conn.engine.dialect.name if dialect == "sqlite": return await _sqlite_table_exists(conn, table) return await _postgres_table_exists(conn, table) async def _has_column(conn: AsyncConnection, table: str, column: str) -> bool: dialect = conn.engine.dialect.name if dialect == "sqlite": return await _sqlite_has_column(conn, table, column) return await _postgres_has_column(conn, table, column) async def apply_migrations(conn: AsyncConnection) -> None: """ Apply idempotent migrations. Called on startup after `create_all()` to keep existing DBs up-to-date. """ dialect = conn.engine.dialect.name logger.info("DB migrations: starting (dialect=%s)", dialect) # ------------------------------------------------------------------ # 1) domain_auctions.pounce_score (enables DB-level sorting/pagination) # ------------------------------------------------------------------ if await _table_exists(conn, "domain_auctions"): if not await _has_column(conn, "domain_auctions", "pounce_score"): logger.info("DB migrations: adding column domain_auctions.pounce_score") await conn.execute(text("ALTER TABLE domain_auctions ADD COLUMN pounce_score INTEGER")) # Index for feed ordering await conn.execute( text("CREATE INDEX IF NOT EXISTS ix_domain_auctions_pounce_score ON domain_auctions(pounce_score)") ) # --------------------------------------------------------- # 2) domain_checks index for history queries (watchlist UI) # --------------------------------------------------------- if await _table_exists(conn, "domain_checks"): await conn.execute( text( "CREATE INDEX IF NOT EXISTS ix_domain_checks_domain_id_checked_at " "ON domain_checks(domain_id, checked_at)" ) ) # --------------------------------------------------- # 3) tld_prices composite index for trend computations # --------------------------------------------------- if await _table_exists(conn, "tld_prices"): await conn.execute( text( "CREATE INDEX IF NOT EXISTS ix_tld_prices_tld_registrar_recorded_at " "ON tld_prices(tld, registrar, recorded_at)" ) ) # ---------------------------------------------------- # 4) domain_listings pounce_score index (market sorting) # ---------------------------------------------------- if await _table_exists(conn, "domain_listings"): await conn.execute( text( "CREATE INDEX IF NOT EXISTS ix_domain_listings_pounce_score " "ON domain_listings(pounce_score)" ) ) logger.info("DB migrations: done")