Source code for trader.persistence.db

"""Engine, session factory, and schema creation for the PostgreSQL database
(issue #26 — migrated off SQLite; see docs/invariants.md for why)."""

from sqlalchemy import Engine, create_engine

# Re-exported so callers outside this package can catch "the database itself
# refused" — a missing table on an un-migrated database being the common case —
# without importing SQLAlchemy themselves. Narrower than `Exception`, which
# would also swallow the programming errors that must stay loud, and wider
# than `OperationalError`, which misses e.g. `IntegrityError`.
# `__all__` below is what makes this a re-export, so the `as DatabaseError`
# alias the PEP 484 convention would otherwise need is redundant here.
from sqlalchemy.exc import DatabaseError
from sqlalchemy.orm import Session, sessionmaker

from trader.persistence.models import Base

__all__ = [
    "DatabaseError",
    "create_db_engine",
    "create_session_factory",
    "init_db",
]


[docs] def create_db_engine(database_url: str) -> Engine: """Build an engine for the Postgres database at `database_url`. `pool_pre_ping=True`: unlike a SQLite file, Postgres is a real network/ socket service that can go away mid-process (a `brew services restart`, a dropped Unix socket). Without this, a stale pooled connection surfaces as a mystifying error mid-cycle instead of being detected and replaced before use. No PRAGMA setup here (the pre-migration SQLite version of this function had one): `journal_mode`/`busy_timeout` have no Postgres equivalent — MVCC and its own connection-level lock manager already give concurrent readers/writers what WAL mode existed to provide — and `foreign_keys=ON` is unconditional on Postgres, never opt-in. """ return create_engine(database_url, pool_pre_ping=True)
[docs] def create_session_factory(engine: Engine) -> sessionmaker[Session]: """Build a session factory bound to `engine`.""" return sessionmaker(bind=engine, expire_on_commit=False)
[docs] def init_db(database_url: str, stamp: bool = True) -> Engine: """Create every table in the (already-existing) Postgres database at `database_url`. Safe to run repeatedly. Uses `create_all` rather than replaying migrations because the test suite builds around a hundred-plus throwaway databases and replaying history for each costs far more than it proves. The drift that would otherwise allow is caught by `tests/persistence/test_migrations.py`. The new database is then stamped at head, so Alembic knows its tables already exist and a later `upgrade` applies only genuinely new revisions. Pass `stamp=False` to skip that — worth it only for a throwaway database in a tight loop, since stamping opens a second connection. Unlike the pre-migration SQLite version, this does **not** create the database itself — `CREATE DATABASE` is a privileged, out-of-band operation (see `tests/persistence/pg_testing.py` for the test-suite fixture that does it), never something an app connection triggers implicitly by connecting. """ engine = create_db_engine(database_url) Base.metadata.create_all(engine) if stamp: # Imported here, not at module scope: `migrations.py` imports Alembic, # and `create_db_engine` is on the hot path for every repository. from trader.persistence.migrations import stamp_head stamp_head(database_url) return engine