76 lines
3 KiB
Python
76 lines
3 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
|
||
|
|
|
||
|
|
DB_PATH = 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)
|