left4me/l4d2web/alembic/versions/0010_server_live_state.py
mwiegand e25e7098f6
refactor(live-state): drop redundant ix_sps_server_recent index
The two indexes ix_sps_server_open and ix_sps_server_recent were
byte-identical because SQLAlchemy's Index(name, *cols) form drops the
DESC ordering the spec intended. Rather than reach for text("left_at
DESC"), drop the second index entirely — SQLite scans the ASC index
backwards at no measurable cost. Spec and plan updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:27:01 +02:00

109 lines
3.9 KiB
Python

"""server_live_state schema
Revision ID: 0010_server_live_state
Revises: 0009_user_password_changed_at
Create Date: 2026-05-12
"""
from __future__ import annotations
import secrets
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0010_server_live_state"
down_revision: Union[str, Sequence[str], None] = "0009_user_password_changed_at"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add rcon_password to servers, default ''
with op.batch_alter_table("servers") as batch:
batch.add_column(
sa.Column(
"rcon_password",
sa.String(length=64),
nullable=False,
server_default="",
)
)
# 2. Backfill every existing row with a freshly generated password.
conn = op.get_bind()
rows = conn.execute(sa.text("SELECT id FROM servers")).fetchall()
for row in rows:
conn.execute(
sa.text("UPDATE servers SET rcon_password = :pw WHERE id = :id"),
{"pw": secrets.token_urlsafe(32), "id": row.id},
)
# 3. server_live_state — run-length-encoded snapshots
op.create_table(
"server_live_state",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"server_id",
sa.Integer(),
sa.ForeignKey("servers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(), nullable=False),
sa.Column("players", sa.Integer(), nullable=False),
sa.Column("max_players", sa.Integer(), nullable=False),
sa.Column("bots", sa.Integer(), nullable=False),
sa.Column("map", sa.String(length=64), nullable=False),
sa.Column("hibernating", sa.Boolean(), nullable=False),
sqlite_autoincrement=True,
)
op.create_index(
"ix_sls_server_started",
"server_live_state",
["server_id", "started_at"],
)
# 4. server_player_session — connection intervals
op.create_table(
"server_player_session",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"server_id",
sa.Integer(),
sa.ForeignKey("servers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("steam_id_64", sa.String(length=20), nullable=False),
sa.Column("joined_at", sa.DateTime(), nullable=False),
sa.Column("left_at", sa.DateTime(), nullable=True),
sa.Column("name_at_join", sa.String(length=64), nullable=False),
sa.Column("min_ping", sa.Integer(), nullable=False),
sa.Column("max_ping", sa.Integer(), nullable=False),
sqlite_autoincrement=True,
)
op.create_index("ix_sps_server_open", "server_player_session", ["server_id", "left_at"])
op.create_index(
"ix_sps_steam_history", "server_player_session", ["steam_id_64", "joined_at"]
)
# 5. steam_user_profile — 24h profile cache
op.create_table(
"steam_user_profile",
sa.Column("steam_id_64", sa.String(length=20), primary_key=True),
sa.Column("persona_name", sa.String(length=64), nullable=False),
sa.Column("avatar_url", sa.Text(), nullable=False),
sa.Column("fetched_at", sa.DateTime(), nullable=False),
)
def downgrade() -> None:
op.drop_table("steam_user_profile")
op.drop_index("ix_sps_steam_history", table_name="server_player_session")
op.drop_index("ix_sps_server_open", table_name="server_player_session")
op.drop_table("server_player_session")
op.drop_index("ix_sls_server_started", table_name="server_live_state")
op.drop_table("server_live_state")
with op.batch_alter_table("servers") as batch:
batch.drop_column("rcon_password")