Files
Lamont/core/migrations/__init__.py
Chelsea Lee fbdf33e894
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
Build reusable bot framework
2026-07-19 21:53:24 -05:00

203 lines
6.3 KiB
Python

"""Versioned PostgreSQL migrations for the framework and feature modules."""
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
import re
import psycopg2.extras
from core import postgres
MIGRATION_NAME = re.compile(r"^(?P<version>\d+)(?:[_-].*)?\.sql$")
LOCK_NAME = "llm-bot-framework:schema-migrations"
class MigrationError(RuntimeError):
"""Raised when migration history is inconsistent or cannot be applied."""
@dataclass(frozen=True)
class Migration:
namespace: str
version: int
name: str
path: Path
checksum: str
sql: str
def _project_root(project_root=None):
if project_root is not None:
return Path(project_root).resolve()
return Path(__file__).resolve().parents[2]
def _read_namespace(namespace, directory):
migrations = []
if not directory.is_dir():
return migrations
for path in sorted(directory.glob("*.sql")):
match = MIGRATION_NAME.match(path.name)
if not match:
raise MigrationError(f"Invalid migration filename: {path}")
sql = path.read_text(encoding="utf-8")
migrations.append(
Migration(
namespace=namespace,
version=int(match.group("version")),
name=path.name,
path=path,
checksum=sha256(sql.encode("utf-8")).hexdigest(),
sql=sql,
)
)
return migrations
def discover_migrations(project_root=None):
"""Return core and feature migrations in deterministic application order."""
root = _project_root(project_root)
migrations = _read_namespace("core", root / "config" / "migrations")
modules_root = root / "modules"
if modules_root.is_dir():
for module_path in sorted(modules_root.iterdir(), key=lambda path: path.name):
if module_path.is_dir() and not module_path.name.startswith("_"):
migrations.extend(
_read_namespace(module_path.name, module_path / "migrations")
)
seen = set()
for migration in migrations:
key = (migration.namespace, migration.version)
if key in seen:
raise MigrationError(
f"Duplicate migration {migration.namespace}:{migration.version}"
)
seen.add(key)
return sorted(
migrations,
key=lambda item: (
item.namespace != "core",
item.namespace,
item.version,
item.name,
),
)
def _ensure_history_table(cursor):
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
namespace VARCHAR(255) NOT NULL,
version INTEGER NOT NULL,
checksum CHAR(64) NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (namespace, version)
)
"""
)
def _applied_migrations(cursor):
cursor.execute(
"""
SELECT namespace, version, checksum, applied_at
FROM schema_migrations
ORDER BY namespace, version
"""
)
return {
(row["namespace"], row["version"]): dict(row) for row in cursor.fetchall()
}
def upgrade(project_root=None):
"""Apply pending migrations transactionally under a PostgreSQL advisory lock."""
migrations = discover_migrations(project_root)
applied_now = []
with postgres.get_connection() as connection:
with connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (LOCK_NAME,))
_ensure_history_table(cursor)
applied = _applied_migrations(cursor)
for migration in migrations:
key = (migration.namespace, migration.version)
previous = applied.get(key)
if previous:
if previous["checksum"] != migration.checksum:
raise MigrationError(
"Applied migration checksum changed: "
f"{migration.namespace}:{migration.version}"
)
continue
cursor.execute(migration.sql)
cursor.execute(
"""
INSERT INTO schema_migrations (namespace, version, checksum)
VALUES (%s, %s, %s)
""",
(migration.namespace, migration.version, migration.checksum),
)
applied_now.append(migration)
return applied_now
def migration_status(project_root=None):
"""Return applied, pending, changed, and source-missing migration records."""
migrations = discover_migrations(project_root)
with postgres.get_connection() as connection:
with connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cursor:
cursor.execute(
"SELECT to_regclass('public.schema_migrations') AS table_name"
)
if cursor.fetchone()["table_name"] is None:
applied = {}
else:
applied = _applied_migrations(cursor)
status = []
source_keys = set()
for migration in migrations:
key = (migration.namespace, migration.version)
source_keys.add(key)
record = applied.get(key)
state = "pending"
applied_at = None
if record:
state = "applied" if record["checksum"] == migration.checksum else "changed"
applied_at = record["applied_at"]
status.append(
{
"namespace": migration.namespace,
"version": migration.version,
"name": migration.name,
"checksum": migration.checksum,
"applied_at": applied_at,
"state": state,
}
)
for key, record in sorted(applied.items()):
if key not in source_keys:
status.append(
{
"namespace": key[0],
"version": key[1],
"name": None,
"checksum": record["checksum"],
"applied_at": record["applied_at"],
"state": "missing",
}
)
return status