"""Database engine, sessions and schema lifecycle helpers.""" from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from sqlalchemy import create_engine, text from sqlalchemy.orm import Session, sessionmaker from cayceops.models import Base _engine = None _factory: sessionmaker | None = None def engine(url: str | None = None): """Return (and cache) the process SQLAlchemy engine.""" global _engine if _engine is None: from cayceops.config import load _engine = create_engine(url or load().database_url, future=True) return _engine def reset_engine() -> None: """Drop the cached engine (used by tests and CLI re-entry).""" global _engine, _factory if _engine is not None: _engine.dispose() _engine = None _factory = None def session_factory(): global _factory if _factory is None: _factory = sessionmaker(bind=engine(), expire_on_commit=False, future=True) return _factory @contextmanager def session_scope() -> Iterator[Session]: """Transactional scope: commit on success, rollback on error.""" s = session_factory()() try: yield s s.commit() except Exception: s.rollback() raise finally: s.close() def init_db() -> None: """Create all tables from metadata (dev/test convenience). Production uses Alembic migrations (``cayceops db upgrade``). """ Base.metadata.create_all(engine()) def upgrade_db() -> None: """Run Alembic migrations to head.""" from alembic.config import Config as AlembicConfig from alembic import command from cayceops.config import load cfg = AlembicConfig("alembic.ini") cfg.set_main_option("script_location", "alembic") cfg.set_main_option("sqlalchemy.url", load().database_url) command.upgrade(cfg, "head") def current_revision() -> str | None: with engine().connect() as conn: row = conn.execute(text("SELECT version_num FROM alembic_version")).first() return row[0] if row else None