#!/usr/bin/env python3
"""Simple versioned SQL migration runner for Command Sovereignty.
Migrations live in migrations/ as NNN_description.sql files (e.g.
001_add_index_users_email.sql). Applied migrations are tracked in a
schema_migrations table. Each migration runs inside a transaction.
Usage:
.venv/bin/python scripts/migrate.py # apply pending migrations
.venv/bin/python scripts/migrate.py --status # show applied/pending
.venv/bin/python scripts/migrate.py --dry-run # list what would run
.venv/bin/python scripts/migrate.py --create "add index on users.email"
"""
import argparse
import datetime
import os
import re
import sys
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MIGRATIONS_DIR = os.path.join(BASE_DIR, 'migrations')
sys.path.insert(0, BASE_DIR)
# Load env file the same way the service does
ENV_FILE = os.path.expanduser('~/.config/command-sovereignty/env')
if os.path.exists(ENV_FILE):
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
os.environ.setdefault(key.strip(), value.strip())
from sqlalchemy import create_engine, text # noqa: E402
MIGRATION_RE = re.compile(r'^(\d{3,})_[\w-]+\.sql$')
def get_engine():
url = os.environ.get('DATABASE_URL')
if not url:
print('ERROR: DATABASE_URL not set (checked env and ~/.config/command-sovereignty/env)')
sys.exit(1)
if url.startswith('postgres://'):
url = url.replace('postgres://', 'postgresql://', 1)
return create_engine(url)
def ensure_table(engine):
with engine.begin() as conn:
conn.execute(text(
'CREATE TABLE IF NOT EXISTS schema_migrations ('
'version VARCHAR(255) PRIMARY KEY, '
'applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)'
))
def applied_versions(engine):
with engine.connect() as conn:
rows = conn.execute(text('SELECT version FROM schema_migrations')).fetchall()
return {r[0] for r in rows}
def discover_migrations():
if not os.path.isdir(MIGRATIONS_DIR):
return []
files = []
for name in sorted(os.listdir(MIGRATIONS_DIR)):
m = MIGRATION_RE.match(name)
if m:
files.append((name, os.path.join(MIGRATIONS_DIR, name)))
elif name.endswith('.sql'):
print(f'WARNING: skipping {name} (must match NNN_description.sql)')
return files
def cmd_status(engine):
applied = applied_versions(engine)
migrations = discover_migrations()
if not migrations:
print('No migration files found in migrations/')
return
for name, _ in migrations:
marker = 'applied' if name in applied else 'PENDING'
print(f' [{marker:>7}] {name}')
def cmd_migrate(engine, dry_run=False):
applied = applied_versions(engine)
pending = [(n, p) for n, p in discover_migrations() if n not in applied]
if not pending:
print('Database is up to date. No pending migrations.')
return
for name, path in pending:
if dry_run:
print(f'Would apply: {name}')
continue
print(f'Applying {name} ...', end=' ', flush=True)
with open(path) as f:
sql = f.read()
try:
with engine.begin() as conn:
# Split on semicolons to handle multi-statement migrations (SQLite limitation)
statements = [s.strip() for s in sql.split(';') if s.strip() and not s.strip().startswith('--')]
for stmt in statements:
conn.execute(text(stmt))
conn.execute(
text('INSERT INTO schema_migrations (version) VALUES (:v)'),
{'v': name},
)
print('OK')
except Exception as e:
print('FAILED')
print(f'ERROR in {name}: {e}')
print('Transaction rolled back. Fix the migration and re-run.')
sys.exit(1)
if not dry_run:
print(f'Applied {len(pending)} migration(s).')
def cmd_create(description):
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
existing = discover_migrations()
next_num = 1
if existing:
next_num = max(int(MIGRATION_RE.match(n).group(1)) for n, _ in existing) + 1
slug = re.sub(r'[^\w]+', '_', description.lower()).strip('_')[:60]
name = f'{next_num:03d}_{slug}.sql'
path = os.path.join(MIGRATIONS_DIR, name)
with open(path, 'w') as f:
f.write(f'-- Migration: {description}\n'
f'-- Created: {datetime.date.today().isoformat()}\n\n')
print(f'Created {path}')
def main():
parser = argparse.ArgumentParser(description='Run versioned SQL migrations')
parser.add_argument('--status', action='store_true', help='show migration status')
parser.add_argument('--dry-run', action='store_true', help='list pending without applying')
parser.add_argument('--create', metavar='DESC', help='create a new empty migration file')
args = parser.parse_args()
if args.create:
cmd_create(args.create)
return
engine = get_engine()
ensure_table(engine)
if args.status:
cmd_status(engine)
else:
cmd_migrate(engine, dry_run=args.dry_run)
if __name__ == '__main__':
main()