feat: add StoreManager singleton for centralized store migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()):
|
||||||
|
|||||||
Reference in New Issue
Block a user