feat: add user_version-based schema migrations

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:02:53 -04:00
parent a7851e7007
commit 8870b8f3ef
3 changed files with 103 additions and 4 deletions
+2 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from hqt.config import Settings from hqt.config import Settings
from hqt.db.models import Base from hqt.db.migrations import migrate
def get_engine(settings: Settings) -> Engine: def get_engine(settings: Settings) -> Engine:
@@ -16,5 +16,4 @@ def get_session_factory(engine: Engine) -> sessionmaker:
def ensure_db(settings: Settings) -> None: def ensure_db(settings: Settings) -> None:
settings.db_path.parent.mkdir(parents=True, exist_ok=True) settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings) migrate(get_engine(settings))
Base.metadata.create_all(engine)
+46
View File
@@ -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}")
+55 -1
View File
@@ -1,9 +1,13 @@
from pathlib import Path from pathlib import Path
import pytest
from sqlalchemy import inspect from sqlalchemy import inspect
from hqt.config import Settings 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.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: 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.project.name == "proj"
assert s.harness.name == "claude-code" assert s.harness.name == "claude-code"
assert s in p.sessions 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]