From 1584b5fbd0930631fc94740f36cf10141a227546 Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Wed, 30 Sep 2026 17:55:27 +0200 Subject: [PATCH] feat: introduce universal database migration system --- README.md | 29 ++++++- .../storage/adapters/postgres/database.py | 35 ++------ .../adapters/postgres/migration_runner.py | 80 +++++++++++++++++++ .../migrations/0001_initial_schema.py | 57 +++++++++++++ .../adapters/postgres/migrations/__init__.py | 9 +++ .../adapters/postgres/migrations/base.py | 14 ++++ 6 files changed, 192 insertions(+), 32 deletions(-) create mode 100644 zyrarbot/storage/adapters/postgres/migration_runner.py create mode 100644 zyrarbot/storage/adapters/postgres/migrations/0001_initial_schema.py create mode 100644 zyrarbot/storage/adapters/postgres/migrations/__init__.py create mode 100644 zyrarbot/storage/adapters/postgres/migrations/base.py diff --git a/README.md b/README.md index da88772..44b8d91 100644 --- a/README.md +++ b/README.md @@ -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//`. +### 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 diff --git a/zyrarbot/storage/adapters/postgres/database.py b/zyrarbot/storage/adapters/postgres/database.py index d88aee1..390d4eb 100644 --- a/zyrarbot/storage/adapters/postgres/database.py +++ b/zyrarbot/storage/adapters/postgres/database.py @@ -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]: diff --git a/zyrarbot/storage/adapters/postgres/migration_runner.py b/zyrarbot/storage/adapters/postgres/migration_runner.py new file mode 100644 index 0000000..24b5bdd --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/migration_runner.py @@ -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() diff --git a/zyrarbot/storage/adapters/postgres/migrations/0001_initial_schema.py b/zyrarbot/storage/adapters/postgres/migrations/0001_initial_schema.py new file mode 100644 index 0000000..9f3aa24 --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/migrations/0001_initial_schema.py @@ -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") diff --git a/zyrarbot/storage/adapters/postgres/migrations/__init__.py b/zyrarbot/storage/adapters/postgres/migrations/__init__.py new file mode 100644 index 0000000..6254bee --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/migrations/__init__.py @@ -0,0 +1,9 @@ +"""Ordered PostgreSQL schema migrations.""" + +from importlib import import_module + +InitialSchemaMigration = import_module( + ".0001_initial_schema", __name__ +).InitialSchemaMigration + +MIGRATIONS = (InitialSchemaMigration(),) diff --git a/zyrarbot/storage/adapters/postgres/migrations/base.py b/zyrarbot/storage/adapters/postgres/migrations/base.py new file mode 100644 index 0000000..80b98da --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/migrations/base.py @@ -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: ...