78 lines
3.3 KiB
Python
78 lines
3.3 KiB
Python
"""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))
|