Files
TSSBOT/BOT/storage.py
T
deploy d07afdee21 rename long_name → name in TSS teams schema
teams_data and team_name_history used long_name (an SRE concept for
teams with both a long and short name). TSS teams have one name only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:07:37 +00:00

687 lines
26 KiB
Python

"""TSSBOT storage layer — SQLite paths, idempotent DB init, and insert helpers.
Two databases live under ``STORAGE_VOL_PATH`` (set in ``TSSBOT/.env``):
* ``tss_teams.db`` — replay-sourced TSS team registry
* ``tss_battles.db`` — per-match summary + per-player/per-vehicle game history
One row is written to ``player_games_hist`` per vehicle *actually used* by each
player. Player-level stats (kills, deaths, etc.) are Spectra player totals and
are duplicated across vehicle rows — aggregation queries that sum stats must
``DISTINCT`` on session_id first to avoid double-counting.
Session IDs are stored as hex strings throughout (Spectra sends decimal integers;
``tss_ws._session_id`` normalises them before anything hits the DB).
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import aiosqlite
log = logging.getLogger("tssbot.storage")
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
def _require_storage_dir() -> Path:
raw = os.environ.get("STORAGE_VOL_PATH", "").strip()
if not raw:
raise RuntimeError("STORAGE_VOL_PATH must be set in TSSBOT/.env")
p = Path(raw).expanduser().resolve()
p.mkdir(parents=True, exist_ok=True)
return p
STORAGE_DIR: Path = _require_storage_dir()
TSS_BATTLES_DB_PATH: Path = STORAGE_DIR / "tss_battles.db"
TSS_TEAMS_DB_PATH: Path = STORAGE_DIR / "tss_teams.db"
# ---------------------------------------------------------------------------
# tss_battles.db — match_summary + player_games_hist
# ---------------------------------------------------------------------------
_MATCH_SUMMARY_SQL = """
CREATE TABLE IF NOT EXISTS match_summary (
session_id TEXT PRIMARY KEY,
mission_mode TEXT,
mission_name TEXT,
level_path TEXT,
mission_path TEXT,
difficulty TEXT,
starttime_unix INTEGER,
endtime_unix INTEGER,
duration REAL,
draw INTEGER NOT NULL DEFAULT 0,
winning_slot TEXT,
losing_slot TEXT,
received_unix INTEGER,
tournament_id INTEGER,
tournament_name TEXT,
match_id TEXT,
bracket TEXT
)
"""
_MATCH_SUMMARY_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_ms_mission_name ON match_summary(mission_name)",
"CREATE INDEX IF NOT EXISTS idx_ms_level_path ON match_summary(level_path)",
"CREATE INDEX IF NOT EXISTS idx_ms_mission_path ON match_summary(mission_path)",
"CREATE INDEX IF NOT EXISTS idx_ms_endtime ON match_summary(endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_ms_difficulty ON match_summary(difficulty)",
]
_PLAYER_GAMES_SQL = """
CREATE TABLE IF NOT EXISTS player_games_hist (
UID TEXT NOT NULL,
nick TEXT NOT NULL,
team_name TEXT,
team_slot TEXT,
session_id TEXT NOT NULL,
vehicle TEXT,
vehicle_internal TEXT,
ground_kills INTEGER NOT NULL DEFAULT 0,
air_kills INTEGER NOT NULL DEFAULT 0,
assists INTEGER NOT NULL DEFAULT 0,
captures INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
missile_evades INTEGER NOT NULL DEFAULT 0,
shell_interceptions INTEGER NOT NULL DEFAULT 0,
team_kills_stat INTEGER NOT NULL DEFAULT 0,
country_id INTEGER,
victor_bool TEXT NOT NULL DEFAULT 'Loss',
endtime_unix INTEGER NOT NULL DEFAULT 0,
team_id INTEGER,
tss_role TEXT,
pvp_ratio REAL,
UNIQUE (UID, session_id, vehicle_internal)
)
"""
_PLAYER_GAMES_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_pgh_session ON player_games_hist(session_id)",
"CREATE INDEX IF NOT EXISTS idx_pgh_uid_time ON player_games_hist(UID, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_team_name_time ON player_games_hist(team_name, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_endtime ON player_games_hist(endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_nick ON player_games_hist(nick COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_pgh_session_team_name ON player_games_hist(session_id, team_name)",
"CREATE INDEX IF NOT EXISTS idx_pgh_team_id_time ON player_games_hist(team_id, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_vehicle_internal ON player_games_hist(vehicle_internal)",
]
# ---------------------------------------------------------------------------
# tss_teams.db — teams_data + team_members + team_name_history
# ---------------------------------------------------------------------------
_TEAMS_DATA_SQL = """
CREATE TABLE IF NOT EXISTS teams_data (
team_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
members INTEGER NOT NULL DEFAULT 0,
captain_uid TEXT
)
"""
_TEAMS_DATA_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_teams_data_name ON teams_data(name COLLATE NOCASE)",
]
_TEAM_MEMBERS_SQL = """
CREATE TABLE IF NOT EXISTS team_members (
team_id INTEGER NOT NULL,
uid TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'player',
PRIMARY KEY (team_id, uid)
)
"""
_TEAM_MEMBERS_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_team_members_team_id ON team_members(team_id)",
"CREATE INDEX IF NOT EXISTS idx_team_members_uid ON team_members(uid)",
]
_TEAM_NAME_HISTORY_SQL = """
CREATE TABLE IF NOT EXISTS team_name_history (
team_id INTEGER NOT NULL,
name TEXT NOT NULL,
first_seen INTEGER,
last_seen INTEGER,
PRIMARY KEY (team_id, name)
)
"""
_TEAM_NAME_HISTORY_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_team_name_history_name ON team_name_history(name COLLATE NOCASE)",
]
# ---------------------------------------------------------------------------
# Init
# ---------------------------------------------------------------------------
_PRAGMAS = (
"PRAGMA journal_mode=WAL;",
"PRAGMA synchronous=NORMAL;",
"PRAGMA busy_timeout=5000;",
"PRAGMA temp_store=MEMORY;",
)
async def _apply(conn: aiosqlite.Connection, statements: list[str]) -> None:
for sql in statements:
await conn.execute(sql)
async def _existing_columns(conn: aiosqlite.Connection, table: str) -> set[str]:
rows = await conn.execute_fetchall(f"PRAGMA table_info({table})")
return {row[1] for row in rows}
async def _rebuild_table(
conn: aiosqlite.Connection,
table: str,
create_sql: str,
desired_columns: list[str],
) -> None:
"""Rebuild a table when its schema contains obsolete or missing columns."""
existing = await _existing_columns(conn, table)
desired = set(desired_columns)
if existing == desired:
return
old_table = f"{table}_old_schema"
await conn.execute(f"DROP TABLE IF EXISTS {old_table}")
await conn.execute(f"ALTER TABLE {table} RENAME TO {old_table}")
await conn.execute(create_sql)
common = [column for column in desired_columns if column in existing]
if common:
columns = ", ".join(common)
await conn.execute(
f"INSERT OR IGNORE INTO {table} ({columns}) "
f"SELECT {columns} FROM {old_table}"
)
await conn.execute(f"DROP TABLE {old_table}")
log.info("migrated %s: rebuilt replay-native schema", table)
async def _init_battles_db() -> None:
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
for sql in _PRAGMAS:
await conn.execute(sql)
await conn.execute(_MATCH_SUMMARY_SQL)
await conn.execute(_PLAYER_GAMES_SQL)
await _rebuild_table(conn, "match_summary", _MATCH_SUMMARY_SQL, [
"session_id", "mission_mode", "mission_name", "level_path",
"mission_path", "difficulty",
"starttime_unix", "endtime_unix", "duration", "draw",
"winning_slot", "losing_slot", "received_unix", "tournament_id",
"tournament_name", "match_id", "bracket",
])
await _rebuild_table(conn, "player_games_hist", _PLAYER_GAMES_SQL, [
"UID", "nick", "team_name", "team_slot", "session_id",
"vehicle", "vehicle_internal", "ground_kills", "air_kills",
"assists", "captures", "deaths", "score", "missile_evades",
"shell_interceptions", "team_kills_stat", "country_id",
"victor_bool", "endtime_unix", "team_id", "tss_role", "pvp_ratio",
])
await _apply(conn, _MATCH_SUMMARY_INDEXES)
await _apply(conn, _PLAYER_GAMES_INDEXES)
await conn.commit()
async def _init_teams_db() -> None:
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
for sql in _PRAGMAS:
await conn.execute(sql)
await conn.execute(_TEAMS_DATA_SQL)
await conn.execute(_TEAM_MEMBERS_SQL)
await conn.execute(_TEAM_NAME_HISTORY_SQL)
await conn.execute("DROP TABLE IF EXISTS teams_points")
await _rebuild_table(conn, "teams_data", _TEAMS_DATA_SQL, [
"team_id", "name", "members", "captain_uid",
])
await _rebuild_table(conn, "team_members", _TEAM_MEMBERS_SQL, [
"team_id", "uid", "role",
])
await _apply(conn, _TEAMS_DATA_INDEXES)
await _apply(conn, _TEAM_MEMBERS_INDEXES)
await _apply(conn, _TEAM_NAME_HISTORY_INDEXES)
await conn.commit()
async def init_tss_dbs() -> None:
"""Create both TSSBOT SQLite databases if they don't already exist.
Idempotent — safe to call on every startup. Sets WAL pragmas, creates
tables, and ensures indexes.
"""
await _init_battles_db()
await _init_teams_db()
log.info(
"TSS DBs initialised: %s, %s",
TSS_BATTLES_DB_PATH,
TSS_TEAMS_DB_PATH,
)
# ---------------------------------------------------------------------------
# Insertion helpers
# ---------------------------------------------------------------------------
async def insert_match(game: Dict[str, Any]) -> None:
"""Insert one row into match_summary from a normalised game dict.
``game["_id"]`` must already be a hex string (normalised by tss_ws).
Safe to call multiple times; newer replay metadata refreshes the row.
"""
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
for sql in _PRAGMAS:
await conn.execute(sql)
tss = game.get("tss") or {}
await conn.execute(
"""
INSERT INTO match_summary
(session_id, mission_mode, mission_name, level_path, mission_path,
difficulty,
starttime_unix, endtime_unix, duration,
draw, winning_slot, losing_slot, received_unix,
tournament_id, tournament_name, match_id, bracket)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
mission_mode = excluded.mission_mode,
mission_name = excluded.mission_name,
level_path = excluded.level_path,
mission_path = excluded.mission_path,
difficulty = excluded.difficulty,
starttime_unix = excluded.starttime_unix,
endtime_unix = excluded.endtime_unix,
duration = excluded.duration,
draw = excluded.draw,
winning_slot = excluded.winning_slot,
losing_slot = excluded.losing_slot,
tournament_id = excluded.tournament_id,
tournament_name = excluded.tournament_name,
match_id = excluded.match_id,
bracket = excluded.bracket
""",
(
str(game["_id"]),
str(game.get("mission_mode") or ""),
str(game.get("mission_name") or ""),
str(game.get("level_path") or ""),
str(game.get("mission_path") or ""),
str(game.get("difficulty") or ""),
int(game.get("start_ts") or 0),
int(game.get("end_ts") or 0),
float(game.get("duration") or 0),
1 if game.get("draw") else 0,
str(game.get("winner") or ""),
str(game.get("loser") or ""),
int(time.time()),
tss.get("tournament_id"),
str(tss.get("tournament_name") or ""),
str(tss.get("match_id") or ""),
str(tss.get("bracket") or ""),
),
)
await conn.commit()
async def insert_player_games(game: Dict[str, Any]) -> None:
"""Insert one row per used vehicle per player into player_games_hist.
victor_bool is set to 'Win' when the player's team slot matches the
winning slot, 'Loss' otherwise.
Safe to call multiple times; newer replay metadata refreshes existing rows.
"""
session_id = str(game["_id"])
winner_slot = str(game.get("winner") or "")
end_ts = int(game.get("end_ts") or 0)
players = game.get("players") or {}
tss = game.get("tss") or {}
tss_players: dict[str, dict[str, Any]] = {}
tss_teams: dict[str, dict[str, Any]] = {}
for slot in ("1", "2"):
team = tss.get(slot)
if not isinstance(team, dict):
continue
tss_teams[slot] = team
for member in team.get("players") or []:
if isinstance(member, dict) and member.get("uid") is not None:
tss_players[str(member["uid"])] = member
rows = []
for uid_str, p in players.items():
victor_bool = "Win" if str(p.get("team", "")) == winner_slot else "Loss"
tss_team = tss_teams.get(str(p.get("team") or ""), {})
tss_player = tss_players.get(str(uid_str), {})
used_units = [u for u in (p.get("units") or []) if u.get("used")]
if not used_units:
continue
for unit in used_units:
rows.append((
str(uid_str),
str(p.get("name") or ""),
str(tss_team.get("team_name") or tss_team.get("name") or ""),
str(p.get("team") or ""), # team_slot ("1" or "2")
session_id,
str(unit.get("unit_normalized") or ""),
str(unit.get("unit") or ""),
int(p.get("ground_kills") or 0),
int(p.get("air_kills") or 0),
int(p.get("assists") or 0),
int(p.get("captures") or 0),
int(p.get("deaths") or 0),
int(p.get("score") or 0),
int(p.get("missile_evades") or 0),
int(p.get("shell_interceptions") or 0),
int(p.get("team_kills") or 0),
p.get("country_id"),
victor_bool,
end_ts,
tss_team.get("team_id"),
str(tss_player.get("role") or ""),
tss_player.get("pvp_ratio"),
))
if not rows:
return
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
for sql in _PRAGMAS:
await conn.execute(sql)
await conn.executemany(
"""
INSERT INTO player_games_hist
(UID, nick, team_name, team_slot, session_id,
vehicle, vehicle_internal,
ground_kills, air_kills, assists, captures, deaths, score,
missile_evades, shell_interceptions, team_kills_stat,
country_id, victor_bool, endtime_unix, team_id, tss_role, pvp_ratio)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(UID, session_id, vehicle_internal) DO UPDATE SET
nick = excluded.nick,
team_name = excluded.team_name,
team_slot = excluded.team_slot,
vehicle = excluded.vehicle,
ground_kills = excluded.ground_kills,
air_kills = excluded.air_kills,
assists = excluded.assists,
captures = excluded.captures,
deaths = excluded.deaths,
score = excluded.score,
missile_evades = excluded.missile_evades,
shell_interceptions = excluded.shell_interceptions,
team_kills_stat = excluded.team_kills_stat,
country_id = excluded.country_id,
victor_bool = excluded.victor_bool,
endtime_unix = excluded.endtime_unix,
team_id = excluded.team_id,
tss_role = excluded.tss_role,
pvp_ratio = excluded.pvp_ratio
""",
rows,
)
await conn.commit()
async def upsert_tss_teams(game: Dict[str, Any]) -> None:
"""Upsert replay-sourced team identity, roster, and name history."""
tss = game.get("tss") or {}
seen_unix = int(game.get("end_ts") or time.time())
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
for sql in _PRAGMAS:
await conn.execute(sql)
for slot in ("1", "2"):
team = tss.get(slot)
if not isinstance(team, dict) or team.get("team_id") is None:
continue
team_id = int(team["team_id"])
team_name = str(team.get("team_name") or team.get("name") or "")
if not team_name:
continue
members = [
member for member in (team.get("players") or [])
if isinstance(member, dict) and member.get("uid") is not None
]
await conn.execute(
"""
INSERT INTO teams_data (team_id, name, members, captain_uid)
VALUES (?, ?, ?, ?)
ON CONFLICT(team_id) DO UPDATE SET
name = excluded.name,
members = excluded.members,
captain_uid = excluded.captain_uid
""",
(team_id, team_name, len(members), str(team.get("captain_id") or "")),
)
await conn.execute(
"""
INSERT INTO team_name_history (team_id, name, first_seen, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT(team_id, name) DO UPDATE SET last_seen = excluded.last_seen
""",
(team_id, team_name, seen_unix, seen_unix),
)
if "players" in team:
await conn.execute("DELETE FROM team_members WHERE team_id = ?", (team_id,))
for member in members:
await conn.execute(
"""
INSERT INTO team_members (team_id, uid, role)
VALUES (?, ?, ?)
ON CONFLICT(team_id, uid) DO UPDATE SET role = excluded.role
""",
(team_id, str(member["uid"]), str(member.get("role") or "player")),
)
await conn.commit()
# ---------------------------------------------------------------------------
# Team resolve / lookup (python twin of the Rust backend's find_team)
# ---------------------------------------------------------------------------
async def resolve_team(name_or_id: str) -> Optional[Dict[str, Any]]:
"""Resolve a team by numeric ID or full TSS team name."""
text = (name_or_id or "").strip()
if not text:
return None
try:
as_id: Optional[int] = int(text)
except ValueError:
as_id = None
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""
SELECT team_id, name
FROM teams_data
WHERE team_id = ?1
OR name = ?2 COLLATE NOCASE
LIMIT 1
""",
(as_id, text),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def search_teams(query: str, limit: int = 25) -> List[Dict[str, Any]]:
"""Autocomplete-friendly TSS team-name search."""
q = (query or "").strip()
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
if not q:
async with conn.execute(
"""
SELECT team_id, name FROM teams_data
ORDER BY members DESC, name COLLATE NOCASE
LIMIT ?
""",
(limit,),
) as cur:
rows = await cur.fetchall()
else:
like = f"%{q}%"
async with conn.execute(
"""
SELECT team_id, name FROM teams_data
WHERE name LIKE ?1 COLLATE NOCASE
ORDER BY CASE
WHEN name = ?2 COLLATE NOCASE THEN 0
ELSE 1
END
LIMIT ?3
""",
(like, q, limit),
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Player resolve / aggregate (derived from player_games_hist)
# ---------------------------------------------------------------------------
async def search_players(query: str, limit: int = 25) -> List[Dict[str, Any]]:
"""Autocomplete-friendly player search by nick. Returns [{uid, nick}]."""
q = (query or "").strip()
if len(q) < 2:
return []
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
await conn.create_function("ulower", 1, str.lower)
async with conn.execute(
"""
SELECT nick, UID FROM (
SELECT nick, UID, MAX(endtime_unix) AS last_seen
FROM player_games_hist
WHERE nick LIKE ? COLLATE NOCASE
GROUP BY UID
ORDER BY
CASE WHEN ulower(nick) = ulower(?) THEN 0
WHEN ulower(nick) LIKE ulower(?) THEN 1
ELSE 2 END,
last_seen DESC
LIMIT ?
)
""",
(f"{q}%", q, f"{q}%", limit),
) as cur:
rows = await cur.fetchall()
return [{"uid": r["UID"], "nick": r["nick"]} for r in rows]
async def resolve_players(name: str) -> List[Dict[str, Any]]:
"""Resolve a nick to candidates (exact match first, else substring).
Returns ``[{uid, nick}]`` grouped by UID.
"""
name = (name or "").strip()
if not name:
return []
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"SELECT UID, MIN(nick) AS nick FROM player_games_hist "
"WHERE nick = ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25",
(name,),
) as cur:
rows = list(await cur.fetchall())
if not rows:
async with conn.execute(
"SELECT UID, MIN(nick) AS nick FROM player_games_hist "
"WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25",
(f"%{name}%",),
) as cur:
rows = list(await cur.fetchall())
return [{"uid": r["UID"], "nick": r["nick"]} for r in rows]
async def latest_nick_for_uid(uid: str) -> str:
"""Best-effort latest nick for a UID; falls back to the UID string."""
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
async with conn.execute(
"SELECT nick FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT 1",
(uid,),
) as cur:
row = await cur.fetchone()
return row[0] if row else str(uid)
async def player_career(uid: str) -> Optional[Dict[str, Any]]:
"""Aggregate career stats for a UID.
Player totals (kills/deaths/...) are Spectra per-player totals duplicated
across that player's vehicle rows, so we collapse to one value per session
(MAX) before summing — see the module docstring.
"""
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""
SELECT
COUNT(*) AS battles,
SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) AS losses,
SUM(gk) AS ground_kills, SUM(ak) AS air_kills,
SUM(asi) AS assists, SUM(cap) AS captures, SUM(de) AS deaths
FROM (
SELECT session_id,
MAX(victor_bool) AS victor_bool,
MAX(ground_kills) AS gk, MAX(air_kills) AS ak,
MAX(assists) AS asi, MAX(captures) AS cap, MAX(deaths) AS de
FROM player_games_hist
WHERE UID = ?
GROUP BY session_id
)
""",
(uid,),
) as cur:
row = await cur.fetchone()
if not row or not row["battles"]:
return None
career = dict(row)
career["nick"] = await latest_nick_for_uid(uid)
career["uid"] = str(uid)
return career
async def player_teams(uid: str) -> List[Dict[str, Any]]:
"""Teams a UID has appeared with, most recent first."""
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""
SELECT team_id,
MAX(team_name) AS team_name,
COUNT(DISTINCT session_id) AS games,
MAX(endtime_unix) AS last_seen
FROM player_games_hist
WHERE UID = ?
GROUP BY team_id, team_name
ORDER BY last_seen DESC
""",
(uid,),
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]