Files
srbhr--resume-matcher/apps/backend/app/scripts/migrate_tinydb_to_sqlite.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

143 lines
5.1 KiB
Python

"""Idempotent one-time importer: legacy TinyDB ``database.json`` → SQLite.
Safe to run repeatedly and on every startup:
- no legacy file present → no-op;
- SQLite already has rows → skip (assume already migrated);
- otherwise → copy resumes/jobs/improvements 1:1 (preserving
primary keys and timestamps), enforce the single-master invariant, then
rename the legacy file to ``database.json.migrated`` as a rollback artifact.
Run standalone with ``uv run python -m app.scripts.migrate_tinydb_to_sqlite``.
"""
import asyncio
import logging
from pathlib import Path
from typing import Any
from app.config import settings
from app.database import Database, db
from app.models import Improvement, Job, Resume, _utcnow_iso
logger = logging.getLogger(__name__)
_JOB_CORE_FIELDS = {"job_id", "content", "resume_id", "created_at"}
def _legacy_path() -> Path:
return settings.db_path # data/database.json
async def migrate(database: Database | None = None) -> dict[str, Any]:
"""Import the legacy TinyDB file into SQLite if needed.
Returns a summary dict: ``{"status": ..., counts...}``.
"""
database = database or db
legacy = _legacy_path()
if not legacy.exists():
return {"status": "no_legacy_file"}
stats = await database.get_stats()
if (stats["total_resumes"] or stats["total_jobs"] or stats["total_improvements"]):
logger.info("SQLite already populated; skipping TinyDB import.")
return {"status": "already_populated"}
# Gated import — tinydb is only needed for this one-time migration.
from tinydb import TinyDB
tdb = TinyDB(legacy)
try:
resumes = list(tdb.table("resumes").all())
jobs = list(tdb.table("jobs").all())
improvements = list(tdb.table("improvements").all())
finally:
tdb.close()
# Enforce the single-master invariant: if multiple resumes claim master,
# keep the earliest by created_at and demote the rest.
masters = [r for r in resumes if r.get("is_master")]
if len(masters) > 1:
masters.sort(key=lambda r: r.get("created_at", ""))
keep = masters[0].get("resume_id")
for r in resumes:
if r.get("is_master") and r.get("resume_id") != keep:
r["is_master"] = False
logger.warning(
"Legacy DB had %d masters; kept %s, demoted the rest.", len(masters), keep
)
async with database._session() as session:
for r in resumes:
session.add(
Resume(
resume_id=r["resume_id"],
content=r.get("content", ""),
content_type=r.get("content_type", "md"),
filename=r.get("filename"),
is_master=bool(r.get("is_master", False)),
parent_id=r.get("parent_id"),
processed_data=r.get("processed_data"),
processing_status=r.get("processing_status", "pending"),
cover_letter=r.get("cover_letter"),
outreach_message=r.get("outreach_message"),
interview_prep=r.get("interview_prep"),
title=r.get("title"),
original_markdown=r.get("original_markdown"),
created_at=r.get("created_at") or _utcnow_iso(),
updated_at=r.get("updated_at") or r.get("created_at") or _utcnow_iso(),
)
)
for j in jobs:
meta = {k: v for k, v in j.items() if k not in _JOB_CORE_FIELDS}
session.add(
Job(
job_id=j["job_id"],
content=j.get("content", ""),
resume_id=j.get("resume_id"),
created_at=j.get("created_at") or _utcnow_iso(),
metadata_json=meta,
)
)
for imp in improvements:
session.add(
Improvement(
request_id=imp["request_id"],
original_resume_id=imp.get("original_resume_id", ""),
tailored_resume_id=imp.get("tailored_resume_id", ""),
job_id=imp.get("job_id", ""),
improvements=imp.get("improvements", []),
created_at=imp.get("created_at") or _utcnow_iso(),
)
)
await session.commit()
# Rename the legacy file so a restart doesn't re-trigger and we keep a
# rollback artifact.
migrated = legacy.with_suffix(legacy.suffix + ".migrated")
try:
legacy.rename(migrated)
except OSError as e:
logger.warning("Could not rename legacy DB file: %s", e)
summary = {
"status": "migrated",
"resumes": len(resumes),
"jobs": len(jobs),
"improvements": len(improvements),
}
logger.info("TinyDB → SQLite import complete: %s", summary)
return summary
def main() -> None:
"""Console entry point for a manual run."""
logging.basicConfig(level=logging.INFO)
result = asyncio.run(migrate())
print(result)
if __name__ == "__main__":
main()