From c10cd4d714c7708827cc3b0883198e17bcfdb34d Mon Sep 17 00:00:00 2001 From: Deeman Date: Mon, 16 Feb 2026 10:57:28 +0100 Subject: [PATCH] add sequential migration system with version tracking Replace the simple schema.sql runner with a proper sequential migration system that tracks applied versions in a _migrations table. Absorb scripts/migrate_to_paddle.py as versions/0001. Co-Authored-By: Claude Opus 4.6 --- padelnomics/scripts/migrate_to_paddle.py | 19 ---- .../src/padelnomics/migrations/migrate.py | 98 ++++++++++++++----- .../src/padelnomics/migrations/schema.sql | 7 ++ .../versions/0001_rename_ls_to_paddle.py | 17 ++++ .../migrations/versions/__init__.py | 0 5 files changed, 99 insertions(+), 42 deletions(-) delete mode 100644 padelnomics/scripts/migrate_to_paddle.py create mode 100644 padelnomics/src/padelnomics/migrations/versions/0001_rename_ls_to_paddle.py create mode 100644 padelnomics/src/padelnomics/migrations/versions/__init__.py diff --git a/padelnomics/scripts/migrate_to_paddle.py b/padelnomics/scripts/migrate_to_paddle.py deleted file mode 100644 index 35f2fee..0000000 --- a/padelnomics/scripts/migrate_to_paddle.py +++ /dev/null @@ -1,19 +0,0 @@ -"""One-time migration: rename lemonsqueezy columns to paddle.""" -import sqlite3 -import sys - -db_path = sys.argv[1] if len(sys.argv) > 1 else "data/app.db" -conn = sqlite3.connect(db_path) -conn.execute( - "ALTER TABLE subscriptions RENAME COLUMN lemonsqueezy_customer_id TO paddle_customer_id" -) -conn.execute( - "ALTER TABLE subscriptions RENAME COLUMN lemonsqueezy_subscription_id TO paddle_subscription_id" -) -conn.execute("DROP INDEX IF EXISTS idx_subscriptions_provider") -conn.execute( - "CREATE INDEX IF NOT EXISTS idx_subscriptions_provider ON subscriptions(paddle_subscription_id)" -) -conn.commit() -conn.close() -print(f"Migration complete: {db_path}") diff --git a/padelnomics/src/padelnomics/migrations/migrate.py b/padelnomics/src/padelnomics/migrations/migrate.py index ba2a383..fc72629 100644 --- a/padelnomics/src/padelnomics/migrations/migrate.py +++ b/padelnomics/src/padelnomics/migrations/migrate.py @@ -1,51 +1,103 @@ """ -Simple migration runner. Runs schema.sql against the database. +Sequential migration runner. + +- Runs schema.sql (idempotent CREATE IF NOT EXISTS for fresh DBs) +- Scans versions/ for NNNN_*.py files and runs unapplied ones in order +- Fresh DBs: marks all versions as applied without running them + (schema.sql already contains the final schema) """ + +import importlib import os +import re import sqlite3 import sys from pathlib import Path -# Add parent to path for imports sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from dotenv import load_dotenv load_dotenv() +VERSIONS_DIR = Path(__file__).parent / "versions" +VERSION_RE = re.compile(r"^(\d{4})_.+\.py$") + + +def _discover_versions(): + """Return sorted list of (name, module_path) for version files.""" + if not VERSIONS_DIR.is_dir(): + return [] + versions = [] + for f in sorted(VERSIONS_DIR.iterdir()): + if VERSION_RE.match(f.name): + versions.append(f.stem) # e.g. "0001_rename_ls_to_paddle" + return versions + + +def _is_fresh_db(conn): + """A DB is fresh if it has no application tables at all.""" + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table'" + " AND name NOT LIKE 'sqlite_%'" + ).fetchone() + return row is None + def migrate(): - """Run migrations.""" - # Get database path from env or default db_path = os.getenv("DATABASE_PATH", "data/app.db") - - # Ensure directory exists Path(db_path).parent.mkdir(parents=True, exist_ok=True) - - # Read schema - schema_path = Path(__file__).parent / "schema.sql" - schema = schema_path.read_text() - - # Connect and execute + conn = sqlite3.connect(db_path) - - # Enable WAL mode conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") - - # Run schema + + is_fresh = _is_fresh_db(conn) + + # schema.sql is always idempotent (CREATE IF NOT EXISTS) + schema = (Path(__file__).parent / "schema.sql").read_text() conn.executescript(schema) - conn.commit() - - print(f"✓ Migrations complete: {db_path}") - - # Show tables + + versions = _discover_versions() + applied = { + row[0] + for row in conn.execute("SELECT name FROM _migrations").fetchall() + } + pending = [v for v in versions if v not in applied] + + if is_fresh: + # Fresh DB — schema.sql already created final schema. + # Record all versions as applied without executing them. + for name in pending: + conn.execute("INSERT INTO _migrations (name) VALUES (?)", (name,)) + conn.commit() + print(f"✓ Fresh database initialised: {db_path}") + if pending: + print(f" Recorded {len(pending)} migration(s) as already applied") + elif pending: + for name in pending: + print(f" Applying {name}...") + mod = importlib.import_module( + f"padelnomics.migrations.versions.{name}" + ) + mod.up(conn) + conn.execute( + "INSERT INTO _migrations (name) VALUES (?)", (name,) + ) + conn.commit() + print(f"✓ Applied {len(pending)} migration(s): {db_path}") + else: + print(f"✓ All migrations already applied: {db_path}") + + # Show tables (excluding internal sqlite/fts tables) cursor = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + "SELECT name FROM sqlite_master WHERE type='table'" + " AND name NOT LIKE 'sqlite_%'" + " ORDER BY name" ) tables = [row[0] for row in cursor.fetchall()] print(f" Tables: {', '.join(tables)}") - + conn.close() diff --git a/padelnomics/src/padelnomics/migrations/schema.sql b/padelnomics/src/padelnomics/migrations/schema.sql index 7c44ac3..2dc8b21 100644 --- a/padelnomics/src/padelnomics/migrations/schema.sql +++ b/padelnomics/src/padelnomics/migrations/schema.sql @@ -1,6 +1,13 @@ -- Padelnomics Database Schema -- Run with: python -m padelnomics.migrations.migrate +-- Migration tracking +CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +); + -- Users CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/padelnomics/src/padelnomics/migrations/versions/0001_rename_ls_to_paddle.py b/padelnomics/src/padelnomics/migrations/versions/0001_rename_ls_to_paddle.py new file mode 100644 index 0000000..0b5d7be --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/versions/0001_rename_ls_to_paddle.py @@ -0,0 +1,17 @@ +"""Rename lemonsqueezy columns to paddle.""" + + +def up(conn): + conn.execute( + "ALTER TABLE subscriptions" + " RENAME COLUMN lemonsqueezy_customer_id TO paddle_customer_id" + ) + conn.execute( + "ALTER TABLE subscriptions" + " RENAME COLUMN lemonsqueezy_subscription_id TO paddle_subscription_id" + ) + conn.execute("DROP INDEX IF EXISTS idx_subscriptions_provider") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_subscriptions_provider" + " ON subscriptions(paddle_subscription_id)" + ) diff --git a/padelnomics/src/padelnomics/migrations/versions/__init__.py b/padelnomics/src/padelnomics/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29