Add runtime DB schema migration and version tracking
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user