"""One-shot copy of the accounts database from SQLite into PostgreSQL. Run ONCE during the cutover, after ``alembic upgrade head`` has built the empty Postgres schema. The copy goes through the mapped ORM tables, so SQLAlchemy's type processors coerce the values automatically — CHAR(36) UUID strings → native ``uuid``, 0/1 → ``bool``, epoch floats stay floats, JSON stays JSON — which a raw ``pg_dump``/CSV path would need manual casts for. Login tokens (``access_token``) are deliberately NOT copied: they're ephemeral, so skipping them just makes everyone re-login once at cutover and avoids the tz/uuid fiddliness. Integer-PK sequences are reset afterwards, or the first post-migration insert would collide. Usage: THERMOGRAPH_DATABASE_URL=postgresql+psycopg://thermograph:PW@127.0.0.1:5432/thermograph \\ python migrate_accounts_to_pg.py --sqlite /opt/thermograph/data/accounts.sqlite """ import argparse import os import sys from sqlalchemy import create_engine, insert, select, text from accounts import models # Parents before children (FK order); AccessToken is intentionally omitted. TABLES = [ models.User.__table__, models.Subscription.__table__, models.PushSubscription.__table__, models.PendingDigest.__table__, models.Notification.__table__, # references user + subscription ] # Integer surrogate-PK tables whose sequence must be advanced past the copied max. SEQUENCE_TABLES = ["subscription", "push_subscription", "pending_digest", "notification"] def _pg_sync_url() -> str: url = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip() if not url.startswith("postgresql"): sys.exit("THERMOGRAPH_DATABASE_URL must be a postgresql URL (the migration target)") return url.replace("+asyncpg", "+psycopg") def migrate(sqlite_path: str) -> None: if not os.path.exists(sqlite_path): sys.exit(f"source SQLite file not found: {sqlite_path}") src = create_engine(f"sqlite:///{sqlite_path}", future=True) dst = create_engine(_pg_sync_url(), future=True) counts = {} with src.connect() as s, dst.begin() as d: for table in TABLES: rows = [dict(r._mapping) for r in s.execute(select(table))] if rows: d.execute(insert(table), rows) # explicit PKs preserved (FKs stay intact) counts[table.name] = len(rows) # Advance each identity sequence past the copied rows. for name in SEQUENCE_TABLES: d.execute(text( f"SELECT setval(pg_get_serial_sequence('{name}', 'id'), " f"COALESCE((SELECT MAX(id) FROM {name}), 1), (SELECT COUNT(*) > 0 FROM {name}))" )) src.dispose() dst.dispose() print("Copied (access_token skipped — users re-login once):") for name, n in counts.items(): print(f" {name:18} {n}") print("Sequences reset for:", ", ".join(SEQUENCE_TABLES)) if __name__ == "__main__": ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--sqlite", default=os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite"), help="path to the source accounts.sqlite (default: data/accounts.sqlite)") migrate(os.path.abspath(ap.parse_args().sqlite))