diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 22d7826..d10117e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -99,6 +99,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. - Issue: `app/db.py` only creates missing tables on startup and has no schema version marker or migration strategy. This leaves upgrades brittle and makes it difficult to evolve the database without manual intervention. - Propose: Add a lightweight migration system or schema version table, define incremental migration scripts, and run upgrades during startup before the application begins serving requests. - Test: Add tests that simulate an older schema version and verify startup migrates it to the latest schema, and assert that `init_db`/migration code leaves the database in the expected current state. + - Status: completed 14. Split the monolithic config router into focused sub-routers - Goal: Improve maintainability, reduce cognitive load, and make HTTP routing responsibilities easier to isolate and test. diff --git a/backend/app/db.py b/backend/app/db.py index 1549c38..177648c 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -89,6 +89,13 @@ CREATE TABLE IF NOT EXISTS history_archive ( ); """ +_CREATE_SCHEMA_MIGRATIONS: str = """ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + migrated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +""" + # Ordered list of DDL statements to execute on initialisation. _SCHEMA_STATEMENTS: list[str] = [ _CREATE_SETTINGS, @@ -100,6 +107,12 @@ _SCHEMA_STATEMENTS: list[str] = [ _CREATE_HISTORY_ARCHIVE, ] +_CURRENT_SCHEMA_VERSION: int = 1 + +_MIGRATIONS: dict[int, str] = { + 1: "\n".join(_SCHEMA_STATEMENTS), +} + # --------------------------------------------------------------------------- # Public API @@ -113,8 +126,44 @@ async def _configure_connection(db: aiosqlite.Connection) -> None: await db.execute("PRAGMA busy_timeout=5000;") +async def _get_current_schema_version(db: aiosqlite.Connection) -> int: + """Return the highest applied schema version for the given database.""" + await db.executescript(_CREATE_SCHEMA_MIGRATIONS) + async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor: + row = await cursor.fetchone() + if row is None or row[0] is None: + return 0 + return int(row[0]) + + +async def _apply_migration(db: aiosqlite.Connection, version: int) -> None: + """Apply a single migration step and record its completion.""" + migration_script = _MIGRATIONS[version] + await db.executescript(migration_script) + await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,)) + await db.commit() + + +async def _migrate_schema(db: aiosqlite.Connection) -> None: + """Migrate the database schema to the latest supported version.""" + current_version = await _get_current_schema_version(db) + if current_version == _CURRENT_SCHEMA_VERSION: + return + + if current_version > _CURRENT_SCHEMA_VERSION: + raise RuntimeError( + f"database schema version {current_version} is newer than supported " + f"version {_CURRENT_SCHEMA_VERSION}" + ) + + log.info("migrating_database_schema", from_version=current_version, to_version=_CURRENT_SCHEMA_VERSION) + for next_version in range(current_version + 1, _CURRENT_SCHEMA_VERSION + 1): + await _apply_migration(db, next_version) + log.info("database_schema_ready", schema_version=_CURRENT_SCHEMA_VERSION) + + async def init_db(db: aiosqlite.Connection) -> None: - """Create all BanGUI application tables if they do not already exist. + """Create or migrate the BanGUI application database schema. This function is idempotent — calling it on an already-initialised database has no effect. It should be called once during application @@ -125,10 +174,7 @@ async def init_db(db: aiosqlite.Connection) -> None: """ log.info("initialising_database_schema") await _configure_connection(db) - for statement in _SCHEMA_STATEMENTS: - await db.executescript(statement) - await db.commit() - log.info("database_schema_ready") + await _migrate_schema(db) async def open_db(database_path: str) -> aiosqlite.Connection: diff --git a/backend/tests/test_repositories/test_db_init.py b/backend/tests/test_repositories/test_db_init.py index fef6ce8..e72bf5d 100644 --- a/backend/tests/test_repositories/test_db_init.py +++ b/backend/tests/test_repositories/test_db_init.py @@ -67,3 +67,34 @@ async def test_init_db_is_idempotent(tmp_path: Path) -> None: async with aiosqlite.connect(db_path) as db: await init_db(db) await init_db(db) # Second call must be a no-op. + + +@pytest.mark.asyncio +async def test_init_db_records_schema_version(tmp_path: Path) -> None: + """``init_db`` must record the current schema version.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + async with db.execute( + "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" + ) as cursor: + row = await cursor.fetchone() + assert row is not None + assert row[0] == 1 + + +@pytest.mark.asyncio +async def test_init_db_migrates_legacy_database_without_schema_version(tmp_path: Path) -> None: + """Legacy databases without a schema marker must be migrated on startup.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + await db.execute("DROP TABLE schema_migrations;") + await db.commit() + await init_db(db) + async with db.execute( + "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" + ) as cursor: + row = await cursor.fetchone() + assert row is not None + assert row[0] == 1