#!/usr/bin/env python3 """Apply pending SQL migrations to the accounts database. This project has no Alembic. SQLAlchemy ``create_all()`` only creates *missing tables*, so column additions to the long-lived accounts DB are done with the hand-written ``.sql`` files in ``deploy/migrations/``. This runner applies the ones a given database hasn't seen yet and records them in a ``schema_migrations`` table, so it is safe (and cheap) to run on every deploy. It resolves the database exactly as the app does (``db.py``): ``THERMOGRAPH_ACCOUNTS_DB`` if set, otherwise ``/data/accounts.sqlite``. Fresh database: the app's ``create_all()`` builds the *current* schema on first start, so there is nothing to ALTER. This runner detects that (no file yet, or no ``user`` table) and records the existing migrations as a baseline instead of replaying them, so they never run against a schema that already has the columns. Usage (wired into deploy.sh / deploy-dev.sh, but runnable by hand too): /opt/thermograph/.venv/bin/python deploy/migrate-db.py """ import glob import os import shutil import sqlite3 import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.dirname(HERE) MIGRATIONS_DIR = os.path.join(HERE, "migrations") DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.join(REPO, "data", "accounts.sqlite") def _applied(conn: sqlite3.Connection) -> set[str]: conn.execute( "CREATE TABLE IF NOT EXISTS schema_migrations (" "name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)" ) return {row[0] for row in conn.execute("SELECT name FROM schema_migrations")} def _record(conn: sqlite3.Connection, name: str) -> None: conn.execute( "INSERT OR IGNORE INTO schema_migrations(name, applied_at) " "VALUES (?, datetime('now'))", (name,), ) def _has_user_table(conn: sqlite3.Connection) -> bool: row = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='user'" ).fetchone() return row is not None def main() -> None: migrations = sorted(glob.glob(os.path.join(MIGRATIONS_DIR, "*.sql"))) if not migrations: print("migrate-db: no migrations found") return os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) fresh = not os.path.exists(DB_PATH) conn = sqlite3.connect(DB_PATH) try: applied = _applied(conn) pending = [m for m in migrations if os.path.basename(m) not in applied] if not pending: print(f"migrate-db: up to date ({len(applied)} applied) -> {DB_PATH}") return # Fresh DB (or one whose accounts schema hasn't been built yet): the app's # create_all() will produce the current schema on next start, so record the # existing migrations as a baseline rather than ALTERing a table that isn't # there. Only genuinely older databases need the ALTERs replayed. if fresh or not _has_user_table(conn): for m in pending: _record(conn, os.path.basename(m)) conn.commit() print(f"migrate-db: fresh database, baselined {len(pending)} migration(s) -> {DB_PATH}") return # These are the authoritative, non-regenerable accounts. Back the DB up # before touching it, next to the DB with a timestamp. backup = f"{DB_PATH}.bak-{time.strftime('%Y%m%d-%H%M%S')}" shutil.copy2(DB_PATH, backup) print(f"migrate-db: backed up -> {backup}") for m in pending: name = os.path.basename(m) with open(m, encoding="utf-8") as f: sql = f.read() try: conn.executescript(sql) except sqlite3.OperationalError as e: msg = str(e).lower() # Idempotent: a column/index this migration adds already exists # (applied by hand before, or created fresh from the model). Treat # as already-applied and record it; re-raise anything unexpected. if "duplicate column" in msg or "already exists" in msg: print(f"migrate-db: {name} already present in schema, recording") else: conn.rollback() print(f"migrate-db: FAILED on {name}: {e}", file=sys.stderr) raise _record(conn, name) conn.commit() print(f"migrate-db: applied {name}") print(f"migrate-db: done ({len(pending)} newly recorded) -> {DB_PATH}") finally: conn.close() if __name__ == "__main__": main()