#!/usr/bin/env python3 """ appflowy_fix_migrations.py — Iteratively fix AppFloyo Cloud SQLx migration ordering bugs by skipping failing migrations one at a time. Usage: python3 scripts/appflowy_fix_migrations.py Run this on neon-64gb (or SSH there). """ import re, subprocess, time, sys DB_URL = "postgres://postgres:postgres@localhost:5432/appflowy?sslmode=disable" MAX_ITER = 50 # safety limit MIGRATION_PATTERN = re.compile( r'while executing migration (\d+): error returned from database: (.*?)(?:\n|$)' ) def get_skipped() -> set[str]: """Get already-skipped migrations from file.""" try: with open("/tmp/appflowy_skipped.txt") as f: return set(line.strip() for line in f if line.strip()) except FileNotFoundError: return set() def save_skipped(s: set[str]): with open("/tmp/appflowy_skipped.txt", "w") as f: f.write("\n".join(sorted(s)) + "\n") def mark_migration_done(version: str): """Insert a fake successful entry in _sqlx_migrations.""" subprocess.run( ["psql", "-h", "localhost", "-U", "postgres", "-d", "appflowy", "-c", f"INSERT INTO public._sqlx_migrations " f"(version, description, installed_on, success, checksum, execution_time) " f"SELECT '{version}', 'auto-skipped (ordering bug)', NOW(), true, " f"decode('00', 'hex'), 0 " f"WHERE NOT EXISTS (SELECT 1 FROM public._sqlx_migrations " f"WHERE version = '{version}')"], capture_output=True, env={"PGPASSWORD": "postgres"} ) def run_container(): """Start appflowy_cloud and return container ID.""" result = subprocess.run( ["podman", "run", "-d", "--name", "appflowy_fixer", "--network", "host", "-e", f"APPFLOWY_DATABASE_URL={DB_URL}", "-e", "APPFLOWY_ENVIRONMENT=production", "-e", "APPFLOWY_REDIS_URI=redis://localhost:6379", "-e", "APPFLOWY_GOTRUE_JWT_SECRET=jwt_", "-e", "APPFLOWY_GOTRUE_JWT_EXP=3600", "-e", "APPFLOWY_GOTRUE_BASE_URL=http://localhost:9999", "-e", "APPFLOWY_BASE_URL=https://researchstack.info/appflowy", "-e", "APPFLOWY_WEB_URL=https://researchstack.info/appflowy", "-e", "APPFLOWY_ENCRYPTION_KEY=sk_encryption_key_at_least_32_chars_abcdef1234567890", "-e", "APPFLOWY_JWT_SECRET=jwt_", "-e", "APPFLOWY_CREATE_BUCKET=false", "-e", "APPFLOWY_S3_ENABLED=false", "-e", "APPFLOWY_SEARCH_ENABLED=false", "-e", "RUST_LOG=info", "docker.io/appflowyinc/appflowy_cloud:latest"], capture_output=True, text=True, timeout=30 ) return result.stdout.strip() def get_logs(container_id: str) -> str: """Get container logs.""" result = subprocess.run( ["podman", "logs", container_id], capture_output=True, text=True, timeout=10 ) return result.stdout + result.stderr def main(): skipped = get_skipped() print(f"Already skipped: {len(skipped)} migrations") for iteration in range(MAX_ITER): print(f"\n--- Iteration {iteration + 1} ---") # Remove old container subprocess.run(["podman", "rm", "-f", "appflowy_fixer"], capture_output=True) # Start container cid = run_container() print(f"Started: {cid[:12]}") # Wait for startup time.sleep(15) # Check logs logs = get_logs(cid) # Check health health = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:8000/api/health"], capture_output=True, text=True, timeout=5 ).stdout.strip() if health == "200": print(f"✅ AppFloyo Cloud is UP! (health check 200)") subprocess.run(["podman", "rename", "appflowy_fixer", "appflowy_cloud"]) save_skipped(skipped) print(f"Total migrations skipped: {len(skipped)}") return 0 # Find failing migration match = MIGRATION_PATTERN.search(logs) if match: ver = match.group(1) err = match.group(2).strip()[:80] print(f"❌ Migration {ver} failed: {err}") mark_migration_done(ver) skipped.add(ver) save_skipped(skipped) print(f" Skipped migration {ver}") else: # Check if there's another error error_lines = [l for l in logs.split("\n") if "error" in l.lower() or "fail" in l.lower() or "Error" in l] for l in error_lines[:5]: print(f" {l.strip()[:120]}") print("❌ Could not parse migration error. Manual intervention needed.") print(f" Full logs: {logs[:2000]}") return 1 print("❌ Max iterations reached without success") return 1 if __name__ == "__main__": sys.exit(main())