"""Authoritative account database — SQLAlchemy over data/accounts.sqlite (WAL). This is deliberately a *separate* database from data/thermograph.sqlite (store.py). That file is a disposable accelerator — "deleting it is a safe reset" — because everything in it recomputes from the parquet source of truth. Accounts, subscriptions and notifications have no source to recompute from, so they live in their own file with their own (stricter) rules: foreign keys on, errors surface rather than get swallowed, and it should be backed up (it is not regenerable). Two engines share the one file: * an **async** engine (``sqlite+aiosqlite``) drives the request/auth path, because fastapi-users is async; and * a **sync** engine drives the background notifier thread (notify.py), which is a plain daemon thread with no event loop. WAL mode lets the async readers/writers and the sync notifier coexist on the same file without blocking each other. """ import os from sqlalchemy import create_engine, event from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker # Default to data/accounts.sqlite; THERMOGRAPH_ACCOUNTS_DB overrides it (tests point # this at a throwaway temp file to stay hermetic). DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite") ) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) class Base(DeclarativeBase): """Declarative base shared by every account-domain table (models.py).""" def _apply_pragmas(dbapi_conn, _rec): # Per-connection SQLite setup: WAL for concurrent async/sync access, NORMAL # sync for a good durability/speed tradeoff, and foreign_keys ON so the # ON DELETE CASCADE relationships (user -> subscriptions -> notifications) # are actually enforced (SQLite defaults them off). cur = dbapi_conn.cursor() cur.execute("PRAGMA journal_mode=WAL") cur.execute("PRAGMA synchronous=NORMAL") cur.execute("PRAGMA foreign_keys=ON") cur.close() # --- async engine (web / auth path) ----------------------------------------- async_engine = create_async_engine(f"sqlite+aiosqlite:///{DB_PATH}", future=True) event.listen(async_engine.sync_engine, "connect", _apply_pragmas) async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False) async def get_async_session() -> AsyncSession: """FastAPI dependency: one AsyncSession per request.""" async with async_session_maker() as session: yield session # --- sync engine (background notifier thread) ------------------------------- sync_engine = create_engine(f"sqlite:///{DB_PATH}", future=True) event.listen(sync_engine, "connect", _apply_pragmas) sync_session_maker = sessionmaker(sync_engine, expire_on_commit=False) async def create_db_and_tables() -> None: """Create any missing tables. Called once at startup (app lifespan). Imported for its side effect of registering the mapped classes on Base.metadata before create_all runs. """ import models # noqa: F401 (registers tables on Base.metadata) async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all)