"""LibraryPlacementRun — one run of the placement reconciler (milestone #421). The library is keyed on the Artist row's `slug`, one directory per artist. Every writer agrees on that now (`utils.paths.canonical_subdir`, task #4244), but ~33,789 rows were written under older rules and sit in some other artist's directory. This row is a run of the sweep that trues them up. State machine, mirroring LibraryAuditRun: running -> ready -> applied -> reverted \\-> cancelled (any) -> error ## The `moves` column does three jobs `moves` is the plan: `[{"image_id": 1, "from": "...", "to": "..."}, ...]`. 1. **Preview.** It is what the operator reads before agreeing. 2. **Apply.** The apply executes THIS list rather than re-deriving the set, so the preview cannot describe a different set from the apply. That is rule 93's guarantee reached the way LibraryAuditRun reaches it — the plan is materialised, not recomputed. 3. **Revert.** `from` is retained, so a batch that looks wrong in the gallery goes back where it came from. ## An applied run IS the undo ledger — it must never be pruned This is the trap lesson #4226 names: a record that answers both "what is the current plan" and "what happened" gets deleted by whatever forgets the first. A `ready` run is disposable state. An `applied` run is HISTORY, and it is the only record of where 33,789 files used to be — delete it and the moves become irreversible. No pruning exists for this table today, and that is deliberate. If retention is ever added here, it may prune `ready`, `cancelled` and `error` runs; an `applied` run is only safe to drop once someone decides the moves are settled and undo is no longer wanted, which is an operator decision and not a timer's. """ from datetime import datetime from typing import Any from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from .base import Base class LibraryPlacementRun(Base): __tablename__ = "library_placement_run" id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[str] = mapped_column( String(16), nullable=False, default="running", index=True, server_default="running", ) # running | ready | applied | reverted | cancelled | error # Scope. NULL = the whole library; set = one artist, which is how this is # meant to be used — do one artist, look at it in the gallery, continue or # revert. ondelete SET NULL rather than CASCADE: deleting an artist must # not destroy the record of where their files were moved. artist_id: Mapped[int | None] = mapped_column( ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True, ) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) finished_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) planned_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) moved_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) refused_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) # [{"image_id": int, "from": str, "to": str}, ...] — see the module # docstring. This is the plan, the audit trail and the undo, in that order # of appearance and in one place. moves: Mapped[list[dict[str, Any]]] = mapped_column( JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb"), ) # [{"image_id": int, "reason": str}, ...] — rows the apply declined to # touch, with why. A refusal is an expected outcome, not an error: the # world moves between plan and apply, and every gate fails closed. refusals: Mapped[list[dict[str, Any]]] = mapped_column( JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb"), ) error: Mapped[str | None] = mapped_column(Text, nullable=True)