Files
wiggleverse-ecomm/backend/app/platform/db.py
T
ben.stull 9462423642 feat(slice-1): platform/db migration runner + migration 0001 (INV-7, §6.3 schema)
Forward-only, fail-stop, advisory-lock-guarded psycopg runner; migration 0001 lays
the account/auth_code/storefront/membership schema. Tests prove migrate-from-empty
and idempotent re-migrate (INV-1 partial) and the INV-4 many-capable shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:37:09 -07:00

88 lines
3.4 KiB
Python

"""PostgreSQL access (psycopg 3) + a forward-only migration runner.
Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and
numbered forward-only `.sql` migrations applied in filename order, recorded in
`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and
records nothing, so it retries next boot) and guarded by a session advisory lock so
two booting processes never double-apply (INV-7).
"""
from __future__ import annotations
from pathlib import Path
import psycopg
from psycopg_pool import ConnectionPool
from . import config
# app/platform/db.py -> parents[2] is backend/, where migrations/ lives.
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard).
_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31 # "ecomm1"
def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool:
"""Open a small connection pool against the configured database."""
pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False)
pool.open()
pool.wait()
return pool
def _migration_files(migrations_dir: Path) -> list[Path]:
return sorted(migrations_dir.glob("*.sql"))
def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
"""Apply unapplied migrations in filename order; return the names applied.
Forward-only and fail-stop: each migration runs with its `schema_migrations`
insert in one transaction, so a failure records nothing and is retried next time.
A session advisory lock serializes concurrent migrators (INV-7).
"""
conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,))
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations ("
" filename TEXT PRIMARY KEY,"
" applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
)
conn.commit()
applied = {
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
}
ran: list[str] = []
for path in _migration_files(migrations_dir):
if path.name in applied:
continue
try:
conn.execute(path.read_text()) # whole file; no params -> multi-statement
conn.execute(
"INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,)
)
conn.commit()
except Exception:
conn.rollback()
raise
ran.append(path.name)
return ran
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,))
conn.commit()
def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
"""Migration filenames present on disk but not yet recorded as applied.
Used by /healthz to report migration currency (§6.4). Returns all on-disk
migrations if the tracking table does not exist yet.
"""
exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0]
if exists is None:
return [p.name for p in _migration_files(migrations_dir)]
applied = {
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
}
return [p.name for p in _migration_files(migrations_dir) if p.name not in applied]