"""Rename plan and per-operation journal persistence (US04-01, US04-02). A plan is the complete, reviewable preview of a set of folder renames: every source, destination, affected asset, expected hash, and validation issue. Nothing here touches the filesystem — applying a plan is US04-03. The journal columns on ``RenameOperation`` belong to US04-02's crash-safe state machine and are created with the table so the journal never has to re-migrate the rows the planner writes. They stay at their defaults until apply runs. """ from __future__ import annotations from datetime import datetime from sqlalchemy import ( Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from photo_pipeline.db import Base class RenamePlan(Base): __tablename__ = "rename_plans" id: Mapped[str] = mapped_column(String, primary_key=True) # planned | validated | invalid | applying | applied | failed | rolled_back state: Mapped[str] = mapped_column(String, nullable=False, default="planned") schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) checksum: Mapped[str | None] = mapped_column(String) blockers: Mapped[str | None] = mapped_column(String) # JSON array operation_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) class RenameOperation(Base): __tablename__ = "rename_operations" __table_args__ = ( UniqueConstraint("plan_id", "sequence", name="uq_rename_operations_plan_sequence"), ) id: Mapped[str] = mapped_column(String, primary_key=True) plan_id: Mapped[str] = mapped_column( ForeignKey("rename_plans.id", ondelete="CASCADE"), nullable=False, index=True ) sequence: Mapped[int] = mapped_column(Integer, nullable=False) operation: Mapped[str] = mapped_column(String, nullable=False) album: Mapped[str | None] = mapped_column(String) source_path: Mapped[str] = mapped_column(String, nullable=False) destination_path: Mapped[str] = mapped_column(String, nullable=False) asset_ids: Mapped[str | None] = mapped_column(String) # JSON array expected_sha256: Mapped[str | None] = mapped_column(String) # JSON {asset_id: sha} asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) same_filesystem: Mapped[bool | None] = mapped_column(Boolean) case_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) issues: Mapped[str | None] = mapped_column(String) # JSON array # ── journal (US04-02) ──────────────────────────────────────────────────── journal_state: Mapped[str] = mapped_column(String, nullable=False, default="planned") attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) fencing_token: Mapped[int | None] = mapped_column(Integer) worker_id: Mapped[str | None] = mapped_column(String) verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) error_code: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(String) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() )