Database migrations

This commit is contained in:
2026-06-12 07:14:50 -07:00
parent 0535c68647
commit 96eeb442fd
9 changed files with 337 additions and 20 deletions

View File

@@ -1 +1,14 @@
Use the version of Python/Pytest in the .venv directory when attempting to run commands, as that's where the pip dependencies (and pytest) are installed. Use the version of Python/Pytest in the .venv directory when attempting to run commands, as that's where the pip dependencies (and pytest) are installed.
## Database migrations
Schema changes use Alembic (migrations live in `src/pirats/migrations/`, shipped inside the package). The app applies them automatically at startup via `pirats.database.run_migrations()`; databases predating Alembic are normalized and stamped at the `0001` baseline first.
After editing `src/pirats/models.py`, generate a migration and sanity-check it:
```
.venv/bin/alembic revision --autogenerate -m "describe the change"
.venv/bin/alembic upgrade head # or just start the app
```
Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list exists only to upgrade pre-Alembic databases to the baseline and must not grow.

47
alembic.ini Normal file
View File

@@ -0,0 +1,47 @@
# Dev-only config for the alembic CLI (the app runs migrations itself at
# startup via pirats.database.run_migrations).
#
# New migration: .venv/bin/alembic revision --autogenerate -m "add foo column"
# Apply: .venv/bin/alembic upgrade head
#
# No sqlalchemy.url here: env.py uses the app's engine (DATABASE_URL or
# ./rats_with_gats.db). Override with: alembic -x url=sqlite:///other.db ...
[alembic]
script_location = src/pirats/migrations
file_template = %%(rev)s_%%(slug)s
prepend_sys_path = src
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -45,6 +45,7 @@
]; ];
propagatedBuildInputs = with python.pkgs; [ propagatedBuildInputs = with python.pkgs; [
alembic
fastapi fastapi
sqlmodel sqlmodel
uvicorn uvicorn

View File

@@ -9,6 +9,7 @@ description = "Rats with Gats Remote Play web application"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"alembic",
"fastapi", "fastapi",
"sqlmodel", "sqlmodel",
"uvicorn", "uvicorn",
@@ -28,7 +29,7 @@ pirats = "pirats.main:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
pirats = ["static/**/*", "rules.html"] pirats = ["static/**/*", "rules.html", "migrations/**/*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]

View File

@@ -1,7 +1,11 @@
from sqlmodel import SQLModel, create_engine, Session
import os import os
from pathlib import Path from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect, text
from sqlmodel import Session, create_engine
# Allow configuring via environment variable # Allow configuring via environment variable
sqlite_url = os.environ.get("DATABASE_URL") sqlite_url = os.environ.get("DATABASE_URL")
if not sqlite_url: if not sqlite_url:
@@ -11,17 +15,29 @@ if not sqlite_url:
# Use connect_args={"check_same_thread": False} for SQLite in FastAPI # Use connect_args={"check_same_thread": False} for SQLite in FastAPI
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False}) engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
def create_db_and_tables(): MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SQLModel.metadata.create_all(engine) # The revision that matches the schema produced by the pre-Alembic
# Ensure crew_name column exists (migration for existing DBs) # create_all + ad-hoc ALTER TABLE era.
from sqlalchemy import text BASELINE_REVISION = "0001"
with Session(engine) as session:
try:
session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR"))
except Exception:
pass # Column already exists or table doesn't exist yet
def _alembic_config() -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
cfg.attributes["engine"] = engine
return cfg
def _upgrade_legacy_db() -> None:
"""Bring a pre-Alembic database up to the baseline (0001) schema.
This is the old ad-hoc migration list, kept only so existing databases
can be stamped at the baseline. New columns go in Alembic revisions,
not here.
"""
with Session(engine) as session:
columns = [ columns = [
("game", "crew_name", "VARCHAR"),
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
@@ -34,21 +50,34 @@ def create_db_and_tables():
("player", "player_name", "VARCHAR DEFAULT ''"), ("player", "player_name", "VARCHAR DEFAULT ''"),
("gameevent", "kind", "VARCHAR DEFAULT 'info'"), ("gameevent", "kind", "VARCHAR DEFAULT 'info'"),
] ]
for table, col, def_type in columns: for table, col, def_type in columns:
try: try:
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}")) session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
except Exception: except Exception:
pass pass # Column already exists
# Backfill: pre-existing players used a single name for both player and Pi-Rat # Backfill: pre-existing players used a single name for both player and Pi-Rat
try: session.execute(
session.execute(text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")) text("UPDATE player SET player_name = name WHERE player_name = '' OR player_name IS NULL")
except Exception: )
pass
session.commit() session.commit()
def run_migrations() -> None:
"""Migrate the database to the latest schema.
Fresh databases are built entirely by Alembic. Databases that predate
Alembic (tables exist but no alembic_version) are first normalized to
the baseline schema, stamped, and then upgraded normally.
"""
cfg = _alembic_config()
inspector = inspect(engine)
if inspector.has_table("game") and not inspector.has_table("alembic_version"):
_upgrade_legacy_db()
command.stamp(cfg, BASELINE_REVISION)
command.upgrade(cfg, "head")
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:
yield session yield session

View File

@@ -10,7 +10,7 @@ import os
from pathlib import Path from pathlib import Path
from . import crud from . import crud
from .database import create_db_and_tables, get_session from .database import run_migrations, get_session
from .ws import manager from .ws import manager
EVENT_PAGE_SIZE = 50 EVENT_PAGE_SIZE = 50
@@ -19,7 +19,7 @@ BASE_DIR = Path(__file__).parent
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
create_db_and_tables() run_migrations()
yield yield
app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan) app = FastAPI(title="Rats with Gats Remote Play API", lifespan=lifespan)

View File

@@ -0,0 +1,65 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import create_engine
from sqlmodel import SQLModel
# Importing the models registers every table on SQLModel.metadata,
# which is what `alembic revision --autogenerate` diffs against.
from pirats import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
target_metadata = SQLModel.metadata
def _get_engine():
# The app (pirats.database.run_migrations) passes its engine in via
# attributes; the alembic CLI falls back to the same engine the app
# would build (DATABASE_URL or ./rats_with_gats.db), unless an URL is
# set explicitly with `alembic -x url=...` or in alembic.ini.
engine = config.attributes.get("engine")
if engine is not None:
return engine
url = context.get_x_argument(as_dictionary=True).get("url") or config.get_main_option(
"sqlalchemy.url"
)
if url:
return create_engine(url)
from pirats.database import engine as app_engine
return app_engine
def run_migrations_offline() -> None:
context.configure(
url=str(_get_engine().url),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
with _get_engine().connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
# SQLite can't ALTER most things in place; batch mode rebuilds
# tables so autogenerated migrations actually run.
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,137 @@
"""Initial schema: the six tables as of 2026-06-12.
Pre-Alembic databases are brought to this exact shape by
pirats.database._upgrade_legacy_db() and then stamped at this revision,
so this migration only ever runs against an empty database.
Revision ID: 0001
Revises:
Create Date: 2026-06-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"game",
sa.Column("id", sa.String(), nullable=False),
sa.Column("admin_key", sa.String(), nullable=False),
sa.Column("crew_name", sa.String(), nullable=True),
sa.Column("phase", sa.String(), nullable=False),
sa.Column("current_scene_number", sa.Integer(), nullable=False),
sa.Column("extra_obstacles", sa.Integer(), nullable=False),
sa.Column("deck_cards", sa.String(), nullable=False),
sa.Column("completed_crew_1", sa.Boolean(), nullable=False),
sa.Column("completed_crew_2", sa.Boolean(), nullable=False),
sa.Column("completed_crew_3", sa.Boolean(), nullable=False),
sa.Column("captain_player_id", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"player",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("player_name", sa.String(), nullable=False),
sa.Column("is_ready", sa.Boolean(), nullable=False),
sa.Column("rank", sa.Integer(), nullable=False),
sa.Column("hand_cards", sa.String(), nullable=False),
sa.Column("role", sa.String(), nullable=True),
sa.Column("previous_role", sa.String(), nullable=True),
sa.Column("is_creator", sa.Boolean(), nullable=False),
sa.Column("avatar_look", sa.String(), nullable=False),
sa.Column("avatar_smell", sa.String(), nullable=False),
sa.Column("first_words", sa.String(), nullable=False),
sa.Column("good_at_math", sa.String(), nullable=False),
sa.Column("other_like", sa.String(), nullable=False),
sa.Column("other_like_from_player_id", sa.String(), nullable=True),
sa.Column("other_hate", sa.String(), nullable=False),
sa.Column("other_hate_from_player_id", sa.String(), nullable=True),
sa.Column("created_techniques", sa.String(), nullable=False),
sa.Column("swapped_techniques", sa.String(), nullable=False),
sa.Column("tech_jack", sa.String(), nullable=False),
sa.Column("tech_queen", sa.String(), nullable=False),
sa.Column("tech_king", sa.String(), nullable=False),
sa.Column("completed_personal_1", sa.Boolean(), nullable=False),
sa.Column("completed_personal_2", sa.Boolean(), nullable=False),
sa.Column("completed_personal_3", sa.Boolean(), nullable=False),
sa.Column("needs_name", sa.Boolean(), nullable=False),
sa.Column("needs_rank_3_bonus", sa.Boolean(), nullable=False),
sa.Column("is_dead", sa.Boolean(), nullable=False),
sa.Column("is_ghost", sa.Boolean(), nullable=False),
sa.Column("tax_banned", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_player_game_id"), "player", ["game_id"])
op.create_table(
"obstacle",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("original_card", sa.String(), nullable=False),
sa.Column("suit", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=False),
sa.Column("current_value", sa.Integer(), nullable=False),
sa.Column("played_cards", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_obstacle_game_id"), "obstacle", ["game_id"])
op.create_table(
"challenge",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("challenge_type", sa.String(), nullable=False),
sa.Column("target_player_id", sa.String(), nullable=False),
sa.Column("acting_player_id", sa.String(), nullable=False),
sa.Column("challenger_player_id", sa.String(), nullable=True),
sa.Column("obstacle_ids", sa.String(), nullable=False),
sa.Column("temp_card", sa.String(), nullable=True),
sa.Column("stakes", sa.String(), nullable=False),
sa.Column("plays", sa.String(), nullable=False),
sa.Column("status", sa.String(), nullable=False),
sa.Column("tax_type", sa.String(), nullable=True),
sa.Column("tax_target_id", sa.String(), nullable=True),
sa.Column("tax_state", sa.String(), nullable=True),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_challenge_game_id"), "challenge", ["game_id"])
op.create_table(
"vote",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("voter_player_id", sa.String(), nullable=False),
sa.Column("nominated_player_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_vote_game_id"), "vote", ["game_id"])
op.create_table(
"gameevent",
sa.Column("id", sa.String(), nullable=False),
sa.Column("game_id", sa.String(), nullable=False),
sa.Column("timestamp", sa.Float(), nullable=False),
sa.Column("message", sa.String(), nullable=False),
sa.Column("kind", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["game_id"], ["game.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_gameevent_game_id"), "gameevent", ["game_id"])
def downgrade() -> None:
op.drop_table("gameevent")
op.drop_table("vote")
op.drop_table("challenge")
op.drop_table("obstacle")
op.drop_table("player")
op.drop_table("game")