feat: add StoreManager singleton for centralized store migration
lint / lint (push) Successful in 1m59s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Failing after 1m3s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 18:52:01 +00:00
parent 5fcca2060f
commit ed8fbfdfa8
5 changed files with 73 additions and 12 deletions
+1
View File
@@ -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/db_store.py /app/db_store.py
COPY bot_bottle/queue_store.py /app/queue_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/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.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
+4 -8
View File
@@ -7,11 +7,9 @@ from __future__ import annotations
import sys import sys
from ..audit_store import AuditStore
from ..db_store import DbVersionError
from ..log import Die, die, error from ..log import Die, die, error
from ..manifest import ManifestError from ..manifest import ManifestError
from ..queue_store import QueueStore from ..store_manager import StoreManager
from ._common import PROG from ._common import PROG
from . import list as _list_mod from . import list as _list_mod
from .cleanup import cmd_cleanup from .cleanup import cmd_cleanup
@@ -77,9 +75,8 @@ def main(argv: list[str] | None = None) -> int:
if handler is None: if handler is None:
usage() usage()
die(f"unknown command: {command}") die(f"unknown command: {command}")
queue_store = QueueStore("") mgr = StoreManager.instance()
audit_store = AuditStore() if not mgr.check_migrations():
if not queue_store.check_migrations() or not audit_store.check_migrations():
sys.stderr.write("bot-bottle: database schema is out of date\n") sys.stderr.write("bot-bottle: database schema is out of date\n")
sys.stderr.write("Migrate now? [y/N] ") sys.stderr.write("Migrate now? [y/N] ")
sys.stderr.flush() sys.stderr.flush()
@@ -90,8 +87,7 @@ def main(argv: list[str] | None = None) -> int:
if answer != "y": if answer != "y":
error("migration required — re-run and confirm to migrate") error("migration required — re-run and confirm to migrate")
return 1 return 1
queue_store.migrate() mgr.migrate()
audit_store.migrate()
try: try:
return handler(rest) or 0 return handler(rest) or 0
except ManifestError as e: except ManifestError as e:
+60
View File
@@ -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"]
+6 -4
View File
@@ -222,10 +222,12 @@ class AuditEntry:
try: try:
from .queue_store import QueueStore from .queue_store import QueueStore
from .audit_store import AuditStore from .audit_store import AuditStore
from .store_manager import StoreManager
except ImportError: except ImportError:
# Sidecar bundle: files are flat-copied under /app, not a package. # 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 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 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 ------------------------------------------------------------- # --- Queue I/O -------------------------------------------------------------
@@ -356,12 +358,11 @@ class Supervise(ABC):
"""Stage the host database. Returns the plan; `internal_network` """Stage the host database. Returns the plan; `internal_network`
must be set by the launch step before .start runs.""" must be set by the launch step before .start runs."""
del stage_dir del stage_dir
db_path = host_db_path() mgr = StoreManager.instance()
QueueStore(slug).migrate() mgr.migrate()
AuditStore(db_path).migrate()
return SupervisePlan( return SupervisePlan(
slug=slug, slug=slug,
db_path=db_path, db_path=mgr.db_path,
) )
# --- Helpers --------------------------------------------------------------- # --- Helpers ---------------------------------------------------------------
@@ -384,6 +385,7 @@ __all__ = [
"Proposal", "Proposal",
"QueueStore", "QueueStore",
"Response", "Response",
"StoreManager",
"STATUSES", "STATUSES",
"STATUS_APPROVED", "STATUS_APPROVED",
"STATUS_MODIFIED", "STATUS_MODIFIED",
+2
View File
@@ -15,6 +15,7 @@ from bot_bottle.cli import main
from bot_bottle.db_store import DbStore from bot_bottle.db_store import DbStore
from bot_bottle.log import Die from bot_bottle.log import Die
from bot_bottle.manifest import ManifestError from bot_bottle.manifest import ManifestError
from bot_bottle.store_manager import StoreManager
class TestMainDispatch(unittest.TestCase): class TestMainDispatch(unittest.TestCase):
@@ -22,6 +23,7 @@ class TestMainDispatch(unittest.TestCase):
patcher = patch.object(DbStore, "check_migrations", return_value=True) patcher = patch.object(DbStore, "check_migrations", return_value=True)
self._mock_check = patcher.start() self._mock_check = patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
self.addCleanup(StoreManager.reset)
def test_no_args_prints_usage_returns_2(self) -> None: def test_no_args_prints_usage_returns_2(self) -> None:
with patch("sys.stderr", io.StringIO()): with patch("sys.stderr", io.StringIO()):