diff --git a/src/hqt/db/engine.py b/src/hqt/db/engine.py index 55e9866..84cd2a7 100644 --- a/src/hqt/db/engine.py +++ b/src/hqt/db/engine.py @@ -2,7 +2,7 @@ from sqlalchemy import Engine, create_engine from sqlalchemy.orm import sessionmaker from hqt.config import Settings -from hqt.db.models import Base +from hqt.db.migrations import migrate def get_engine(settings: Settings) -> Engine: @@ -16,5 +16,4 @@ def get_session_factory(engine: Engine) -> sessionmaker: def ensure_db(settings: Settings) -> None: settings.db_path.parent.mkdir(parents=True, exist_ok=True) - engine = get_engine(settings) - Base.metadata.create_all(engine) + migrate(get_engine(settings)) diff --git a/src/hqt/db/migrations.py b/src/hqt/db/migrations.py new file mode 100644 index 0000000..7ee828c --- /dev/null +++ b/src/hqt/db/migrations.py @@ -0,0 +1,46 @@ +import logging +from collections.abc import Callable + +from sqlalchemy import Connection, Engine, inspect + +from hqt.db.models import Base + +log = logging.getLogger(__name__) + +# Ordered schema migrations. Each entry upgrades the schema from the previous +# version to `version`. Version 1 (BASELINE_VERSION) is the schema produced by +# Base.metadata.create_all, so entries here start at version 2, e.g.: +# (2, lambda conn: conn.exec_driver_sql( +# "ALTER TABLE sessions ADD COLUMN foo TEXT")), +MIGRATIONS: list[tuple[int, Callable[[Connection], None]]] = [] + +BASELINE_VERSION = 1 +LATEST_VERSION = MIGRATIONS[-1][0] if MIGRATIONS else BASELINE_VERSION + + +def migrate(engine: Engine) -> None: + """Create or upgrade the database schema to LATEST_VERSION. + + Fresh databases are created with create_all and stamped directly. + Databases at user_version 0 that already have tables predate versioning + and are treated as the baseline schema. + """ + with engine.begin() as conn: + version = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0 + if version > LATEST_VERSION: + raise RuntimeError( + f"Database schema version {version} is newer than this hqt " + f"supports ({LATEST_VERSION}); upgrade hqt or use a fresh database." + ) + if not inspect(conn).get_table_names(): + Base.metadata.create_all(conn) + conn.exec_driver_sql(f"PRAGMA user_version = {LATEST_VERSION}") + return + if version == 0: + version = BASELINE_VERSION + for target, fn in MIGRATIONS: + if target > version: + log.info("Migrating database schema to version %d", target) + fn(conn) + version = target + conn.exec_driver_sql(f"PRAGMA user_version = {version}") diff --git a/tests/test_db.py b/tests/test_db.py index 4cdaf1d..fe0e085 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,9 +1,13 @@ from pathlib import Path + +import pytest from sqlalchemy import inspect from hqt.config import Settings +from hqt.db import migrations from hqt.db.engine import ensure_db, get_engine, get_session_factory -from hqt.db.models import Harness, Project, Session +from hqt.db.migrations import migrate +from hqt.db.models import Base, Harness, Project, Session def _tmp_settings(tmp_path: Path) -> Settings: @@ -69,3 +73,53 @@ def test_session_with_fk(tmp_path): assert s.project.name == "proj" assert s.harness.name == "claude-code" assert s in p.sessions + + +def _user_version(engine) -> int: + with engine.connect() as conn: + return conn.exec_driver_sql("PRAGMA user_version").scalar() or 0 + + +def test_fresh_db_stamped_latest_version(tmp_path): + settings = _tmp_settings(tmp_path) + ensure_db(settings) + assert _user_version(get_engine(settings)) == migrations.LATEST_VERSION + + +def test_unversioned_existing_db_stamped_baseline(tmp_path): + # Simulates a user DB created before versioning existed: tables present, + # user_version still 0. + settings = _tmp_settings(tmp_path) + settings.db_path.parent.mkdir(parents=True, exist_ok=True) + engine = get_engine(settings) + Base.metadata.create_all(engine) + migrate(engine) + assert _user_version(engine) == migrations.BASELINE_VERSION + + +def test_db_from_newer_hqt_raises(tmp_path): + settings = _tmp_settings(tmp_path) + ensure_db(settings) + engine = get_engine(settings) + with engine.begin() as conn: + conn.exec_driver_sql("PRAGMA user_version = 9999") + with pytest.raises(RuntimeError, match="newer"): + migrate(engine) + + +def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch): + settings = _tmp_settings(tmp_path) + ensure_db(settings) # stamps BASELINE_VERSION + applied: list[int] = [] + monkeypatch.setattr( + migrations, + "MIGRATIONS", + [(2, lambda conn: applied.append(2)), (3, lambda conn: applied.append(3))], + ) + monkeypatch.setattr(migrations, "LATEST_VERSION", 3) + engine = get_engine(settings) + migrate(engine) + assert applied == [2, 3] + assert _user_version(engine) == 3 + migrate(engine) # re-run is a no-op + assert applied == [2, 3]