mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat(config): add migration framework and config schema
Add simple SQL migration runner tracking applied migrations in schema_migrations table. First migration creates: - config schema - config.adapters table (name, enabled, cadence_s, settings JSONB) - config.api_keys table (alias, encrypted_value BYTEA) - NOTIFY triggers for real-time config change detection - Seeds NWS adapter row from current TOML config Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fab452aa02
commit
a9b7dcab62
2 changed files with 189 additions and 0 deletions
64
sql/migrations/001_create_config_schema.sql
Normal file
64
sql/migrations/001_create_config_schema.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
-- Migration: 001_create_config_schema
|
||||
-- Creates the config schema with adapters and api_keys tables.
|
||||
-- Also seeds the NWS adapter row from current TOML config.
|
||||
|
||||
-- Create config schema
|
||||
CREATE SCHEMA config;
|
||||
|
||||
-- Adapters configuration table
|
||||
CREATE TABLE config.adapters (
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
cadence_s INTEGER NOT NULL,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
paused_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- API keys table (encrypted values)
|
||||
CREATE TABLE config.api_keys (
|
||||
alias TEXT PRIMARY KEY,
|
||||
encrypted_value BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
rotated_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Notify function for config changes
|
||||
CREATE OR REPLACE FUNCTION config.notify_config_change()
|
||||
RETURNS trigger AS $$
|
||||
DECLARE
|
||||
key_value TEXT;
|
||||
BEGIN
|
||||
-- Handle different table structures
|
||||
IF TG_TABLE_NAME = 'adapters' THEN
|
||||
key_value := COALESCE(NEW.name, OLD.name, '');
|
||||
ELSIF TG_TABLE_NAME = 'api_keys' THEN
|
||||
key_value := COALESCE(NEW.alias, OLD.alias, '');
|
||||
ELSE
|
||||
key_value := '';
|
||||
END IF;
|
||||
|
||||
PERFORM pg_notify('config_changed', TG_TABLE_NAME || ':' || key_value);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for adapters table
|
||||
CREATE TRIGGER adapters_notify
|
||||
AFTER INSERT OR UPDATE OR DELETE ON config.adapters
|
||||
FOR EACH ROW EXECUTE FUNCTION config.notify_config_change();
|
||||
|
||||
-- Trigger for api_keys table
|
||||
CREATE TRIGGER api_keys_notify
|
||||
AFTER INSERT OR UPDATE OR DELETE ON config.api_keys
|
||||
FOR EACH ROW EXECUTE FUNCTION config.notify_config_change();
|
||||
|
||||
-- Seed NWS adapter from current TOML config values
|
||||
INSERT INTO config.adapters (name, enabled, cadence_s, settings)
|
||||
VALUES (
|
||||
'nws',
|
||||
true,
|
||||
60,
|
||||
'{"states": ["ID", "OR", "WA", "MT", "WY", "UT", "NV"], "contact_email": "mj@k7zvx.com"}'::jsonb
|
||||
);
|
||||
125
src/central/migrate.py
Normal file
125
src/central/migrate.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Simple database migration runner.
|
||||
|
||||
Tracks applied migrations in a `schema_migrations` table. Migrations are
|
||||
plain SQL files in `sql/migrations/` named with numeric prefixes:
|
||||
001_create_config_schema.sql
|
||||
002_add_operators_table.sql
|
||||
...
|
||||
|
||||
Usage:
|
||||
central-migrate [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent.parent.parent / "sql" / "migrations"
|
||||
|
||||
|
||||
async def ensure_migrations_table(conn: asyncpg.Connection) -> None:
|
||||
"""Create the schema_migrations table if it doesn't exist."""
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def get_applied_migrations(conn: asyncpg.Connection) -> set[str]:
|
||||
"""Return set of already-applied migration versions."""
|
||||
rows = await conn.fetch("SELECT version FROM schema_migrations")
|
||||
return {row["version"] for row in rows}
|
||||
|
||||
|
||||
def discover_migrations(migrations_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Find all .sql files in migrations directory, sorted by name.
|
||||
|
||||
Returns list of (version, path) tuples where version is the filename
|
||||
without extension.
|
||||
"""
|
||||
if not migrations_dir.exists():
|
||||
return []
|
||||
|
||||
migrations = []
|
||||
for f in sorted(migrations_dir.glob("*.sql")):
|
||||
version = f.stem # e.g., "001_create_config_schema"
|
||||
migrations.append((version, f))
|
||||
return migrations
|
||||
|
||||
|
||||
async def apply_migration(
|
||||
conn: asyncpg.Connection, version: str, sql_path: Path, dry_run: bool = False
|
||||
) -> None:
|
||||
"""Apply a single migration."""
|
||||
sql = sql_path.read_text()
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would apply: {version}")
|
||||
print(f" SQL: {sql[:200]}..." if len(sql) > 200 else f" SQL: {sql}")
|
||||
return
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(sql)
|
||||
await conn.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES ($1)", version
|
||||
)
|
||||
print(f"Applied: {version}")
|
||||
|
||||
|
||||
async def run_migrations(dsn: str, dry_run: bool = False) -> int:
|
||||
"""Run all pending migrations.
|
||||
|
||||
Returns number of migrations applied.
|
||||
"""
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
await ensure_migrations_table(conn)
|
||||
applied = await get_applied_migrations(conn)
|
||||
pending = [
|
||||
(v, p) for v, p in discover_migrations(MIGRATIONS_DIR) if v not in applied
|
||||
]
|
||||
|
||||
if not pending:
|
||||
print("No pending migrations.")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(pending)} pending migration(s).")
|
||||
for version, path in pending:
|
||||
await apply_migration(conn, version, path, dry_run)
|
||||
|
||||
return len(pending)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def async_main() -> None:
|
||||
"""Async entry point."""
|
||||
parser = argparse.ArgumentParser(description="Run database migrations")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be applied without executing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from central.bootstrap_config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
count = await run_migrations(settings.db_dsn, dry_run=args.dry_run)
|
||||
|
||||
if count > 0 and not args.dry_run:
|
||||
print(f"Successfully applied {count} migration(s).")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point."""
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue