diff --git a/src/hqt/db/engine.py b/src/hqt/db/engine.py index 84cd2a7..1053656 100644 --- a/src/hqt/db/engine.py +++ b/src/hqt/db/engine.py @@ -11,7 +11,10 @@ def get_engine(settings: Settings) -> Engine: def get_session_factory(engine: Engine) -> sessionmaker: - return sessionmaker(bind=engine) + # expire_on_commit=False: services open a session per operation and return + # ORM rows to the TUI after the session closes; loaded attributes must + # stay readable on those detached instances. + return sessionmaker(bind=engine, expire_on_commit=False) def ensure_db(settings: Settings) -> None: diff --git a/src/hqt/errors.py b/src/hqt/errors.py new file mode 100644 index 0000000..eb06bac --- /dev/null +++ b/src/hqt/errors.py @@ -0,0 +1,8 @@ +class ServiceError(Exception): + """Expected, user-facing failure in a service operation. + + Raised for recoverable conditions: missing DB rows (project/session + deleted underneath an action), harnesses no longer installed, duplicate + project paths. The TUI catches this and shows a notification instead of + letting the worker crash. + """ diff --git a/tests/test_db.py b/tests/test_db.py index fe0e085..f080593 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -123,3 +123,16 @@ def test_pending_migrations_applied_in_order_once(tmp_path, monkeypatch): assert _user_version(engine) == 3 migrate(engine) # re-run is a no-op assert applied == [2, 3] + + +def test_rows_stay_readable_after_session_closes(tmp_path): + # Services use per-operation sessions; rows they return must remain + # readable after the originating session closes (expire_on_commit=False). + settings = _tmp_settings(tmp_path) + ensure_db(settings) + factory = get_session_factory(get_engine(settings)) + with factory() as session: + p = Project(name="x", path="/x") + session.add(p) + session.commit() + assert p.name == "x" # would raise DetachedInstanceError without the flag