feat: introduce universal database migration system

This commit is contained in:
mmacierzanka committed 2026-09-30 17:55:35 +02:00
1 parent 6f50fce3f9
commit 1584b5fbd0
6 files changed
+192 -32

No files matched your search

+26 -3
View File
@@ -29,9 +29,33 @@ adapterem wybranym w korzeniu aplikacji. Baza działa jako usługa Compose, a je
trwale przechowuje wolumen `postgres_data`. Przy pierwszym uruchomieniu
bot importuje istniejące pliki JSON z `config/` do bazy, a pliki źródłowe zostają
na miejscu jako kopia. Starsze wspólne dane są przypisywane do pierwszego serwera,
do którego bot należy. Uploady pozostają w
do którego bot należy. Historia jest zapisywana w kolumnach `id`, `title`, `url`,
`duration` i `is_local`, wraz z `guild_id` oraz pozycją. Uploady pozostają w
katalogach `uploads/<guild_id>/`.
### Migracje bazy
Przy uruchomieniu bot automatycznie stosuje oczekujące migracje PostgreSQL.
Zastosowane wersje i ich nazwy są zapisywane w tabeli `schema_migrations`.
Każda migracja jest osobną klasą z metodami `apply` i `rollback`, w osobnym
pliku w `zyrarbot/storage/adapters/postgres/migrations/`. Pliki należy nazywać
numerami z zerami wiodącymi, aby porządek leksykograficzny pokrywał się z kolejnością,
na przykład `0001_initial_schema.py`, `0002_add_something.py`. Migrację trzeba
dodać także do krotki `MIGRATIONS` w `migrations/__init__.py`. Obecna migracja
`0001_initial_schema` tworzy początkowy schemat.
Rollback wszystkich wersji wykonaj poleceniem:
```bash
docker compose exec discord-bot python -m zyrarbot.storage.adapters.postgres.migration_runner rollback
```
Aby cofnąć wersje nowsze niż wskazana, dodaj `--to N`, na przykład `--to 1`
pozostawi migrację w wersji 1. Przed rollbackiem zatrzymaj bota poleceniem
`docker compose stop discord-bot`. Rollback wykonuje migracje w odwrotnej kolejności,
usuwa ich wpisy z `schema_migrations` i może trwale skasować dane z cofanych tabel.
Po rollbacku uruchomienie bota ponownie zastosuje brakujące migracje.
## Komendy Discorda
Wszystkie komendy działają w kontekście bieżącego serwera. Discord pokazuje wybór
@@ -86,8 +110,7 @@ zyrarbot/
│ ├── ports/ GuildConfigRepository, HistoryRepository,
│ │ QueueRepository, UploadsRepository, Persistence
│ ├── adapters/
│ │ ├── postgres/ PostgresGuildConfig, PostgresHistory, PostgresQueue,
│ │ │ PostgresPersistence, PostgresDatabase
│ │ ├── postgres/ adaptery PostgreSQL oraz migrations/ z runnerem
│ │ ├── filesystem/ Adapter plików uploadu
│ │ └── json/ Adapter plikowego magazynu JSON
│ └── upload_file_info.py UploadFileInfo
+6 -29
View File
@@ -8,6 +8,9 @@ from typing import Any, Iterator
import psycopg
from zyrarbot.storage.adapters.postgres.migration_runner import MigrationRunner
from zyrarbot.storage.adapters.postgres.migrations import MIGRATIONS
logger = logging.getLogger(__name__)
@@ -30,33 +33,7 @@ class PostgresDatabase:
"password": password,
}
with self.connect() as connection:
schema = """
CREATE TABLE IF NOT EXISTS guilds (
id BIGINT PRIMARY KEY,
notification_channel_id BIGINT,
default_plays INTEGER
);
CREATE TABLE IF NOT EXISTS history_entries (
guild_id BIGINT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT,
duration DOUBLE PRECISION,
is_local BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT history_entries_guild_position_key UNIQUE (guild_id, position)
);
CREATE TABLE IF NOT EXISTS queue_snapshots (
guild_id BIGINT PRIMARY KEY REFERENCES guilds(id) ON DELETE CASCADE,
tracks JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY
);
"""
for statement in schema.split(";"):
if statement.strip():
connection.execute(statement)
MigrationRunner(MIGRATIONS).apply_pending(connection)
@contextmanager
def connect(self) -> Iterator[psycopg.Connection[Any]]:
@@ -67,7 +44,7 @@ class PostgresDatabase:
"""Import legacy JSON files once, retaining the source files as a backup."""
base = Path(config_dir)
with self.connect() as connection:
if connection.execute("SELECT 1 FROM migrations WHERE name='json-v1'").fetchone():
if connection.execute("SELECT 1 FROM data_imports WHERE name='json-v1'").fetchone():
return
config_path = base / "config.json"
@@ -107,7 +84,7 @@ class PostgresDatabase:
"ON CONFLICT(guild_id) DO NOTHING",
(int(path.stem), json.dumps(tracks, ensure_ascii=False)),
)
connection.execute("INSERT INTO migrations(name) VALUES ('json-v1')")
connection.execute("INSERT INTO data_imports(name) VALUES ('json-v1')")
def _read_json(path: Path) -> dict[str, Any]:
@@ -0,0 +1,80 @@
from __future__ import annotations
import argparse
import os
import psycopg
from zyrarbot.storage.adapters.postgres.migrations.base import Migration
class MigrationRunner:
"""Apply registered PostgreSQL migrations once, in version order."""
def __init__(self, migrations: tuple[Migration, ...]):
versions = [migration.version for migration in migrations]
if len(versions) != len(set(versions)):
raise ValueError("Migration versions must be unique")
self._migrations = tuple(sorted(migrations, key=lambda migration: migration.version))
def apply_pending(self, connection: psycopg.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
rows = connection.execute("SELECT version FROM schema_migrations").fetchall()
applied_versions = {row[0] for row in rows}
for migration in self._migrations:
if migration.version in applied_versions:
continue
with connection.transaction():
migration.apply(connection)
connection.execute(
"INSERT INTO schema_migrations(version, name) VALUES (%s, %s)",
(migration.version, migration.name),
)
def rollback_to(self, connection: psycopg.Connection, target_version: int = 0) -> None:
"""Roll back applied migrations newer than target_version."""
rows = connection.execute("SELECT version FROM schema_migrations").fetchall()
applied_versions = {row[0] for row in rows}
known_versions = {migration.version for migration in self._migrations}
unknown_versions = applied_versions - known_versions
if unknown_versions:
raise ValueError(f"No registered migrations for versions: {sorted(unknown_versions)}")
for migration in reversed(self._migrations):
if migration.version <= target_version or migration.version not in applied_versions:
continue
with connection.transaction():
migration.rollback(connection)
connection.execute(
"DELETE FROM schema_migrations WHERE version = %s", (migration.version,)
)
def main() -> None:
parser = argparse.ArgumentParser(description="Manage PostgreSQL schema migrations")
parser.add_argument("action", choices=("rollback",))
parser.add_argument("--to", type=int, default=0, dest="target_version")
args = parser.parse_args()
with psycopg.connect(
host=os.environ.get("POSTGRES_HOST", "localhost"),
port=int(os.environ.get("POSTGRES_PORT", "5432")),
dbname=os.environ.get("POSTGRES_DB", "zyrarbot"),
user=os.environ.get("POSTGRES_USER", "zyrarbot"),
password=os.environ.get("POSTGRES_PASSWORD", "zyrarbot"),
) as connection:
MigrationRunner(MIGRATIONS).rollback_to(connection, args.target_version)
if __name__ == "__main__":
from zyrarbot.storage.adapters.postgres.migrations import MIGRATIONS
main()
@@ -0,0 +1,57 @@
from __future__ import annotations
import psycopg
class InitialSchemaMigration:
"""Create the initial application schema."""
version = 1
name = "initial_schema"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute(
"""
CREATE TABLE guilds (
id BIGINT PRIMARY KEY,
notification_channel_id BIGINT,
default_plays INTEGER
)
"""
)
connection.execute(
"""
CREATE TABLE history_entries (
guild_id BIGINT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT,
duration DOUBLE PRECISION,
is_local BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT history_entries_guild_position_key UNIQUE (guild_id, position)
)
"""
)
connection.execute(
"""
CREATE TABLE queue_snapshots (
guild_id BIGINT PRIMARY KEY REFERENCES guilds(id) ON DELETE CASCADE,
tracks JSONB NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE data_imports (
name TEXT PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute("DROP TABLE data_imports")
connection.execute("DROP TABLE queue_snapshots")
connection.execute("DROP TABLE history_entries")
connection.execute("DROP TABLE guilds")
@@ -0,0 +1,9 @@
"""Ordered PostgreSQL schema migrations."""
from importlib import import_module
InitialSchemaMigration = import_module(
".0001_initial_schema", __name__
).InitialSchemaMigration
MIGRATIONS = (InitialSchemaMigration(),)
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Protocol
import psycopg
class Migration(Protocol):
version: int
name: str
def apply(self, connection: psycopg.Connection) -> None: ...
def rollback(self, connection: psycopg.Connection) -> None: ...