thermograph/accounts/db.py
Emi Griffith d17ac794fd Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module

Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.

Groundwork for moving modules into packages without re-pointing paths.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62

* Split the backend into domain packages

Group the flat backend modules into packages that mirror their concerns:
  data/          climate, grading, scoring, grid, places, cities,
                 city_events, store
  web/           app, views, homepage, content, schemas
  notifications/ notify, digest, push, mailer, discord,
                 discord_interactions, discord_link
  accounts/      models, users, api_accounts, db
  core/          metrics, singleton, audit

Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.

Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00

77 lines
3.2 KiB
Python

"""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
import paths
# 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.join(paths.DATA_DIR, "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.
"""
from accounts import models # noqa: F401 (registers tables on Base.metadata)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)