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.
|
- 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.
|
- 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.
|
- 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
|
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.
|
- 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.
|
# Ordered list of DDL statements to execute on initialisation.
|
||||||
_SCHEMA_STATEMENTS: list[str] = [
|
_SCHEMA_STATEMENTS: list[str] = [
|
||||||
_CREATE_SETTINGS,
|
_CREATE_SETTINGS,
|
||||||
@@ -100,6 +107,12 @@ _SCHEMA_STATEMENTS: list[str] = [
|
|||||||
_CREATE_HISTORY_ARCHIVE,
|
_CREATE_HISTORY_ARCHIVE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_CURRENT_SCHEMA_VERSION: int = 1
|
||||||
|
|
||||||
|
_MIGRATIONS: dict[int, str] = {
|
||||||
|
1: "\n".join(_SCHEMA_STATEMENTS),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
@@ -113,8 +126,44 @@ async def _configure_connection(db: aiosqlite.Connection) -> None:
|
|||||||
await db.execute("PRAGMA busy_timeout=5000;")
|
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:
|
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
|
This function is idempotent — calling it on an already-initialised
|
||||||
database has no effect. It should be called once during application
|
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")
|
log.info("initialising_database_schema")
|
||||||
await _configure_connection(db)
|
await _configure_connection(db)
|
||||||
for statement in _SCHEMA_STATEMENTS:
|
await _migrate_schema(db)
|
||||||
await db.executescript(statement)
|
|
||||||
await db.commit()
|
|
||||||
log.info("database_schema_ready")
|
|
||||||
|
|
||||||
|
|
||||||
async def open_db(database_path: str) -> aiosqlite.Connection:
|
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:
|
async with aiosqlite.connect(db_path) as db:
|
||||||
await init_db(db)
|
await init_db(db)
|
||||||
await init_db(db) # Second call must be a no-op.
|
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