from __future__ import annotations import argparse from datetime import datetime, timezone import hashlib import json from pathlib import Path import sys from typing import Any SOURCE_ROOT = Path(__file__).resolve().parents[1] if str(SOURCE_ROOT) not in sys.path: sys.path.insert(0, str(SOURCE_ROOT)) from app.persistence import atomic_write_many_bytes def _read_json(path: Path) -> tuple[dict[str, Any], bytes]: content = path.read_bytes() value = json.loads(content.decode("utf-8")) if not isinstance(value, dict): raise ValueError(f"{path} must contain a JSON object") return value, content def _json_bytes(value: dict[str, Any]) -> bytes: return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") def build_migration( draft: dict[str, Any], old_catalog: dict[str, Any], old_catalog_bytes: bytes, new_catalog: dict[str, Any], new_catalog_bytes: bytes, *, excluded_copy_ids: set[str], ) -> tuple[dict[str, Any], dict[str, Any]]: if old_catalog.get("schema_version") != 1: raise ValueError("old catalog must use schema v1") if new_catalog.get("schema_version") != 2: raise ValueError("new catalog must use schema v2") if draft.get("schema_version") != 1: raise ValueError("draft must use schema v1") old_digest = hashlib.sha256(old_catalog_bytes).hexdigest() if draft.get("base_catalog_digest") != old_digest: raise ValueError("draft does not match the supplied old catalog") overrides = draft.get("overrides") insertions = draft.get("insertions") if not isinstance(overrides, dict) or not isinstance(insertions, list): raise ValueError("draft collections are invalid") new_items = new_catalog.get("items") new_anchors = new_catalog.get("anchors") if not isinstance(new_items, dict) or not isinstance(new_anchors, dict): raise ValueError("new catalog collections are invalid") retained_overrides: dict[str, Any] = {} removed_overrides: list[dict[str, Any]] = [] for copy_id, override in overrides.items(): if copy_id in excluded_copy_ids: removed_overrides.append({"copy_id": copy_id, "reason": "explicitly_excluded", "override": override}) elif copy_id not in new_items: removed_overrides.append({"copy_id": copy_id, "reason": "not_registered_in_v2", "override": override}) else: retained_overrides[copy_id] = override retained_insertions: list[dict[str, Any]] = [] removed_insertions: list[dict[str, Any]] = [] for insertion in insertions: anchor = new_anchors.get(insertion.get("anchor_id")) if isinstance(insertion, dict) else None if not isinstance(anchor, dict) or anchor.get("scope") != insertion.get("workspace_id"): removed_insertions.append({"reason": "anchor_not_registered_in_v2", "insertion": insertion}) else: retained_insertions.append(insertion) migrated_at = _now() new_digest = hashlib.sha256(new_catalog_bytes).hexdigest() migrated = { "schema_version": 1, "base_catalog_digest": new_digest, "revision": int(draft.get("revision", -1)) + 1, "updated_at": migrated_at, "overrides": retained_overrides, "insertions": retained_insertions, } report = { "schema_version": 1, "migrated_at": migrated_at, "from_catalog_digest": old_digest, "to_catalog_digest": new_digest, "from_revision": draft.get("revision"), "to_revision": migrated["revision"], "retained_override_ids": sorted(retained_overrides), "removed_overrides": removed_overrides, "retained_insertion_ids": [item.get("id") for item in retained_insertions], "removed_insertions": removed_insertions, } return migrated, report def migrate_files( draft_path: Path, old_catalog_path: Path, new_catalog_path: Path, report_path: Path, *, excluded_copy_ids: set[str], ) -> tuple[dict[str, Any], dict[str, Any]]: draft, _draft_bytes = _read_json(draft_path) old_catalog, old_bytes = _read_json(old_catalog_path) new_catalog, new_bytes = _read_json(new_catalog_path) migrated, report = build_migration( draft, old_catalog, old_bytes, new_catalog, new_bytes, excluded_copy_ids=excluded_copy_ids, ) atomic_write_many_bytes( ( (draft_path, _json_bytes(migrated)), (report_path, _json_bytes(report)), ) ) return migrated, report def main() -> None: parser = argparse.ArgumentParser(description="Migrate a UI copy draft from catalog v1 to catalog v2") parser.add_argument("--draft", type=Path, required=True) parser.add_argument("--old-catalog", type=Path, required=True) parser.add_argument("--new-catalog", type=Path, required=True) parser.add_argument("--report", type=Path, required=True) parser.add_argument("--exclude-copy-id", action="append", default=[]) args = parser.parse_args() migrated, report = migrate_files( args.draft, args.old_catalog, args.new_catalog, args.report, excluded_copy_ids=set(args.exclude_copy_id), ) print(json.dumps({ "revision": migrated["revision"], "retained_overrides": len(report["retained_override_ids"]), "removed_overrides": len(report["removed_overrides"]), }, ensure_ascii=False)) if __name__ == "__main__": main()