"""Archive location, plan, and transfer-journal persistence (US06-01, US06-02). An archive location is a *medium*, not a path. External disks get mounted at different mountpoints, and a different disk can be mounted at the same one, so a recorded root alone can never prove "these bytes went to that volume". Each location therefore owns a marker file written onto the medium itself; its ``media_id`` is the stable identity, and the root is only where it was last seen. ``capabilities`` and ``state`` are the last probe result, kept so the UI can list locations without touching a sleeping disk. Preflight always re-probes — a stored state is a hint, never evidence. An ``ArchivePlan`` is one approved preflight turned into durable work, and each ``ArchiveOperation`` is one file's crash-safe journal row (US06-02). The row records what the transfer *intends* to do before it does it — source, destination, expected hash — because after a crash that intent plus the files on disk is the only evidence available for deciding whether an original may be removed. """ 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 ArchiveLocation(Base): __tablename__ = "archive_locations" id: Mapped[str] = mapped_column(String, primary_key=True) name: Mapped[str] = mapped_column(String, nullable=False, unique=True) root: Mapped[str] = mapped_column(String, nullable=False) # Written into the marker file on the medium; proves the right volume is mounted. media_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) capabilities: Mapped[str | None] = mapped_column(String) # JSON, last probe # online | offline | wrong_volume | unwritable — the last probe's verdict. state: Mapped[str] = mapped_column(String, nullable=False, default="offline") last_seen_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 ArchivePlan(Base): __tablename__ = "archive_plans" id: Mapped[str] = mapped_column(String, primary_key=True) location_id: Mapped[str] = mapped_column( ForeignKey("archive_locations.id"), nullable=False, index=True ) # The preflight token this plan was approved against; re-verified before apply. token: Mapped[str] = mapped_column(String, nullable=False) albums: Mapped[str | None] = mapped_column(String) # JSON array # archive | restore — the same journal read in the opposite direction (US06-04). direction: Mapped[str] = mapped_column(String, nullable=False, default="archive") # planned | applying | complete | failed state: Mapped[str] = mapped_column(String, nullable=False, default="planned") schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) byte_size: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Bumped on every claim and used as the fencing token, so a superseded attempt # cannot commit. version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) worker_id: Mapped[str | None] = mapped_column(String) completed_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 ArchiveOperation(Base): __tablename__ = "archive_operations" __table_args__ = ( UniqueConstraint("plan_id", "sequence", name="uq_archive_operations_plan_sequence"), ) id: Mapped[str] = mapped_column(String, primary_key=True) plan_id: Mapped[str] = mapped_column( ForeignKey("archive_plans.id", ondelete="CASCADE"), nullable=False, index=True ) sequence: Mapped[int] = mapped_column(Integer, nullable=False) album: Mapped[str] = mapped_column(String, nullable=False) asset_id: Mapped[str] = mapped_column(ForeignKey("assets.id"), nullable=False, index=True) # archive: library → medium. restore: medium → library (US06-04). ``source_path`` # and ``destination_path`` always mean "from" and "to" for this direction. direction: Mapped[str] = mapped_column(String, nullable=False, default="archive") source_path: Mapped[str] = mapped_column(String, nullable=False) destination_path: Mapped[str] = mapped_column(String, nullable=False) # Relative to the location root, because the medium can be mounted elsewhere. archive_path: Mapped[str] = mapped_column(String, nullable=False) expected_sha256: Mapped[str] = mapped_column(String, nullable=False) byte_size: Mapped[int | None] = mapped_column(Integer) same_filesystem: Mapped[bool | None] = mapped_column(Boolean) # planned | transferring | verified | removing | complete | failed 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)) removed_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() )