""" Shared helpers for all Phase 1 SQLite → Postgres migration scripts. Usage in each script: from migration.utils import open_sqlite, get_pg, log_run, parse_dt, parse_json """ import json import sys from datetime import datetime, timezone from pathlib import Path import aiosqlite from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from config import settings # ── SQLite ──────────────────────────────────────────────────────────────────── async def open_sqlite() -> aiosqlite.Connection: """Open the SQLite database (read-only; no writes during migration).""" db_path = Path(settings.sqlite_db_path) if not db_path.exists(): print(f"ERROR: SQLite database not found at {db_path.resolve()}", file=sys.stderr) sys.exit(1) conn = await aiosqlite.connect(str(db_path)) conn.row_factory = aiosqlite.Row return conn # ── Postgres ────────────────────────────────────────────────────────────────── def _make_pg_session() -> async_sessionmaker: engine = create_async_engine(settings.database_url, pool_size=5, echo=False) return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) AsyncPgSession = _make_pg_session() # ── Type helpers ────────────────────────────────────────────────────────────── def parse_dt(value: str | None) -> datetime | None: """Parse a SQLite TEXT timestamp → timezone-aware datetime (UTC).""" if not value: return None for fmt in ( "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", ): try: dt = datetime.strptime(value, fmt) return dt.replace(tzinfo=timezone.utc) except ValueError: continue # ISO format with offset — let fromisoformat handle it try: dt = datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except ValueError: pass print(f"WARNING: could not parse timestamp {value!r} — using now()", file=sys.stderr) return datetime.now(timezone.utc) def parse_json(value: str | None, default=None): """Parse a SQLite TEXT JSON column → Python object.""" if value is None: return default try: return json.loads(value) except (json.JSONDecodeError, TypeError): return default # ── Migration run log ───────────────────────────────────────────────────────── async def log_run( script_name: str, source_rows: int, dest_rows: int, success: bool = True, notes: str | None = None, ) -> None: """Insert a row into _migration_runs recording this script's execution.""" async with AsyncPgSession() as session: await session.execute( text(""" INSERT INTO _migration_runs (script_name, ran_at, source_rows, dest_rows, success, notes) VALUES (:script_name, now(), :source_rows, :dest_rows, :success, :notes) """), { "script_name": script_name, "source_rows": source_rows, "dest_rows": dest_rows, "success": "ok" if success else "error", "notes": notes, }, ) await session.commit() # ── Count helper ────────────────────────────────────────────────────────────── async def pg_count(session: AsyncSession, table: str) -> int: row = await session.execute(text(f"SELECT COUNT(*) FROM {table}")) return row.scalar()