"""Per-plugin Alembic migration runner + install/uninstall lifecycle (SPEC §11.1). Each plugin ships its own revision directory (``Plugin.migrations``) and runs against a dedicated version table, so its schema history is independent of core and of other plugins. ``install`` upgrades to head; ``uninstall --drop-data`` runs the plugin's downgrade chain and removes the version table, leaving zero residue (the §12 M3 acceptance round-trip). Builtin (core) plugins refuse removal. """ from __future__ import annotations import asyncio import logging from pathlib import Path from alembic import command from alembic.config import Config from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import NullPool from app.config import get_settings from app.plugins import loader from app.plugins.types import Plugin logger = logging.getLogger(__name__) _SHARED_ENV = str(Path(__file__).parent / "alembic") class PluginMigrationError(Exception): """Raised when a plugin's migrations can't be run.""" def _version_table(plugin: Plugin) -> str: # plugin.name is validated [a-z][a-z0-9_]* at construction, so this is safe to # interpolate into DDL. return f"alembic_version_plg_{plugin.name}" def _config(plugin: Plugin) -> Config: if not plugin.migrations: raise PluginMigrationError(f"plugin {plugin.name!r} declares no migrations") cfg = Config() cfg.set_main_option("script_location", _SHARED_ENV) cfg.set_main_option("version_locations", plugin.migrations) cfg.set_main_option("path_separator", "os") # split version_locations on os.pathsep only cfg.set_main_option("version_table", _version_table(plugin)) return cfg def upgrade(plugin: Plugin, revision: str = "head") -> None: """Run the plugin's migrations up to ``revision`` (default: head).""" command.upgrade(_config(plugin), revision) def downgrade(plugin: Plugin, revision: str = "base") -> None: """Run the plugin's downgrades to ``revision`` (default: base = drop all).""" command.downgrade(_config(plugin), revision) async def _drop_version_table(plugin: Plugin) -> None: # Fresh engine (not the shared global one) so this is safe to call from any loop. engine = create_async_engine(get_settings().database_url, poolclass=NullPool) try: async with engine.begin() as conn: await conn.execute(text(f"DROP TABLE IF EXISTS {_version_table(plugin)}")) finally: await engine.dispose() def install(name: str) -> None: """Apply a plugin's migrations (create its ``plg_*`` tables). No-op if none.""" plugin = loader.get_plugin(name) if plugin is None: raise PluginMigrationError(f"unknown plugin {name!r}") if plugin.migrations is None: logger.info("plugin %s has no migrations; nothing to install", name) return logger.info("installing plugin %s: upgrade -> head", name) upgrade(plugin, "head") def uninstall(name: str, *, drop_data: bool = False) -> None: """Uninstall a plugin. Refuses builtin plugins. Without ``drop_data`` the schema/data are kept (a reversible disable). With ``drop_data`` the downgrade chain runs and the version table is removed, so nothing remains in the database (SPEC §12 M3 acceptance).""" plugin = loader.assert_removable(name) # raises PluginRemovalError for builtin/unknown if not drop_data: logger.info("uninstalling plugin %s (data kept)", name) return if plugin.migrations is None: return logger.info("uninstalling plugin %s --drop-data: downgrade -> base", name) downgrade(plugin, "base") asyncio.run(_drop_version_table(plugin))