feat: add user_version-based schema migrations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user