From ed8fbfdfa893947b0f67a2354ea04ec40438b6fd Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 6 Jul 2026 18:52:01 +0000 Subject: [PATCH] feat: add StoreManager singleton for centralized store migration Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.sidecars | 1 + bot_bottle/cli/__init__.py | 12 +++---- bot_bottle/store_manager.py | 60 +++++++++++++++++++++++++++++++++ bot_bottle/supervise.py | 10 +++--- tests/unit/test_cli_dispatch.py | 2 ++ 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 bot_bottle/store_manager.py diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index 2e28b1a..285785a 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -70,6 +70,7 @@ COPY bot_bottle/migrations.py /app/migrations.py COPY bot_bottle/db_store.py /app/db_store.py COPY bot_bottle/queue_store.py /app/queue_store.py COPY bot_bottle/audit_store.py /app/audit_store.py +COPY bot_bottle/store_manager.py /app/store_manager.py COPY bot_bottle/supervise.py /app/supervise.py COPY bot_bottle/supervise_server.py /app/supervise_server.py COPY bot_bottle/sidecar_init.py /app/sidecar_init.py diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index f97ff53..8b38401 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -7,11 +7,9 @@ from __future__ import annotations import sys -from ..audit_store import AuditStore -from ..db_store import DbVersionError from ..log import Die, die, error from ..manifest import ManifestError -from ..queue_store import QueueStore +from ..store_manager import StoreManager from ._common import PROG from . import list as _list_mod from .cleanup import cmd_cleanup @@ -77,9 +75,8 @@ def main(argv: list[str] | None = None) -> int: if handler is None: usage() die(f"unknown command: {command}") - queue_store = QueueStore("") - audit_store = AuditStore() - if not queue_store.check_migrations() or not audit_store.check_migrations(): + mgr = StoreManager.instance() + if not mgr.check_migrations(): sys.stderr.write("bot-bottle: database schema is out of date\n") sys.stderr.write("Migrate now? [y/N] ") sys.stderr.flush() @@ -90,8 +87,7 @@ def main(argv: list[str] | None = None) -> int: if answer != "y": error("migration required — re-run and confirm to migrate") return 1 - queue_store.migrate() - audit_store.migrate() + mgr.migrate() try: return handler(rest) or 0 except ManifestError as e: diff --git a/bot_bottle/store_manager.py b/bot_bottle/store_manager.py new file mode 100644 index 0000000..5f2aa2f --- /dev/null +++ b/bot_bottle/store_manager.py @@ -0,0 +1,60 @@ +"""Singleton manager for all bot-bottle SQLite stores (PRD 0013).""" + +from __future__ import annotations + +from pathlib import Path + +try: + from .audit_store import AuditStore + from .queue_store import QueueStore +except ImportError: + from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + +_instance: StoreManager | None = None + + +class StoreManager: + """Owns db_path and delegates migrate/check_migrations across all stores. + + Use instance() for normal access. Call reset(db_path) in tests to swap + the singleton to a temp path, then reset() with no args to restore the + default.""" + + def __init__(self, db_path: Path | None = None) -> None: + if db_path is None: + # Lazy import to avoid a circular dependency: supervise imports + # StoreManager at module level; StoreManager must not import + # supervise at module level in return. + try: + from .supervise import host_db_path + except ImportError: + from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + db_path = host_db_path() + self.db_path = db_path + + @classmethod + def instance(cls) -> StoreManager: + global _instance + if _instance is None: + _instance = cls() + return _instance + + @classmethod + def reset(cls, db_path: Path | None = None) -> None: + """Replace the singleton. Pass db_path for test isolation; omit to restore default.""" + global _instance + _instance = cls(db_path) + + def check_migrations(self) -> bool: + return ( + QueueStore("", self.db_path).check_migrations() + and AuditStore(self.db_path).check_migrations() + ) + + def migrate(self) -> None: + QueueStore("", self.db_path).migrate() + AuditStore(self.db_path).migrate() + + +__all__ = ["StoreManager"] diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 38e3993..0a9fcad 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -222,10 +222,12 @@ class AuditEntry: try: from .queue_store import QueueStore from .audit_store import AuditStore + from .store_manager import StoreManager except ImportError: # Sidecar bundle: files are flat-copied under /app, not a package. from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module # --- Queue I/O ------------------------------------------------------------- @@ -356,12 +358,11 @@ class Supervise(ABC): """Stage the host database. Returns the plan; `internal_network` must be set by the launch step before .start runs.""" del stage_dir - db_path = host_db_path() - QueueStore(slug).migrate() - AuditStore(db_path).migrate() + mgr = StoreManager.instance() + mgr.migrate() return SupervisePlan( slug=slug, - db_path=db_path, + db_path=mgr.db_path, ) # --- Helpers --------------------------------------------------------------- @@ -384,6 +385,7 @@ __all__ = [ "Proposal", "QueueStore", "Response", + "StoreManager", "STATUSES", "STATUS_APPROVED", "STATUS_MODIFIED", diff --git a/tests/unit/test_cli_dispatch.py b/tests/unit/test_cli_dispatch.py index 06d18f6..34172ce 100644 --- a/tests/unit/test_cli_dispatch.py +++ b/tests/unit/test_cli_dispatch.py @@ -15,6 +15,7 @@ from bot_bottle.cli import main from bot_bottle.db_store import DbStore from bot_bottle.log import Die from bot_bottle.manifest import ManifestError +from bot_bottle.store_manager import StoreManager class TestMainDispatch(unittest.TestCase): @@ -22,6 +23,7 @@ class TestMainDispatch(unittest.TestCase): patcher = patch.object(DbStore, "check_migrations", return_value=True) self._mock_check = patcher.start() self.addCleanup(patcher.stop) + self.addCleanup(StoreManager.reset) def test_no_args_prints_usage_returns_2(self) -> None: with patch("sys.stderr", io.StringIO()):