"""Upload batch persistence (US05-02). One batch is one approved album folder handed to ``immich-go``. It is the durable record of an irreversible external action, so it stores everything needed to answer "which exact bytes did we send, with which command, and how did it end?" after a crash: the preflight token that authorised it, the redacted command, the uploader version, the per-asset pre-upload hashes, every attempt, and where the raw report was written. Item rows keep both digests: SHA-256 is the app's byte identity and SHA-1 is what Immich/immich-go use to recognise a file it already has (concept ยง8). """ from __future__ import annotations from datetime import datetime from sqlalchemy import ( Boolean, DateTime, ForeignKey, Integer, String, func, ) from sqlalchemy.orm import Mapped, mapped_column from photo_pipeline.db import Base class UploadBatch(Base): __tablename__ = "upload_batches" id: Mapped[str] = mapped_column(String, primary_key=True) album: Mapped[str] = mapped_column(String, nullable=False, index=True) folder: Mapped[str] = mapped_column(String, nullable=False) album_name: Mapped[str] = mapped_column(String, nullable=False) # planned | running | cancelling | cancelled | succeeded | failed # | unknown_requires_verification state: Mapped[str] = mapped_column(String, nullable=False, default="planned") # The preflight token this batch was approved against; re-checked before every # attempt so changed bytes or decisions cannot be uploaded silently. preflight_token: Mapped[str] = mapped_column(String, nullable=False) allow_partial: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) command: Mapped[str | None] = mapped_column(String) # JSON array, redacted uploader_version: Mapped[str | None] = mapped_column(String) asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) attempt_count: 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 its outcome. version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) worker_id: Mapped[str | None] = mapped_column(String) report_path: Mapped[str | None] = mapped_column(String) report_bytes: Mapped[int | None] = mapped_column(Integer) report_truncated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) exit_code: Mapped[int | None] = mapped_column(Integer) # Parsed report evidence (US05-03). ``parser`` is NULL when the uploader's # version has no pinned grammar; ``outcome_state`` is then # ``requires_verification`` regardless of how the process exited. parser: Mapped[str | None] = mapped_column(String) parser_version: Mapped[int | None] = mapped_column(Integer) parsed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # verified | requires_verification; NULL until a report has been parsed. outcome_state: Mapped[str | None] = mapped_column(String) outcome_counts: Mapped[str | None] = mapped_column(String) # JSON, from the items report_counts: Mapped[str | None] = mapped_column(String) # JSON, uploader's own # Verification (US05-04). ``stale_bytes`` means at least one uploaded file has # been edited since: the batch carries a visible warning and cannot be re-run. verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) stale_bytes: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) error_code: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(String) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_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 UploadItem(Base): __tablename__ = "upload_items" batch_id: Mapped[str] = mapped_column( ForeignKey("upload_batches.id", ondelete="CASCADE"), primary_key=True ) asset_id: Mapped[str] = mapped_column(String, primary_key=True) path: Mapped[str] = mapped_column(String, nullable=False) # Hashes of the bytes as they were when the batch was created. sha256: Mapped[str | None] = mapped_column(String) sha1: Mapped[str | None] = mapped_column(String) # pending | sent | failed โ€” what the batch *process* did with this item; # ``sent`` only means the uploader exited successfully. state: Mapped[str] = mapped_column(String, nullable=False, default="pending") # What the uploader's report says happened (US05-03): uploaded | upgraded | # duplicate | skipped | failed | unknown. NULL before the report is parsed; # ``unknown`` whenever the report does not classify this file โ€” never success. outcome: Mapped[str | None] = mapped_column(String) evidence: Mapped[str | None] = mapped_column(String) # the bounded report line outcome_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Verification against the server (US05-04): present | absent | inconclusive | # manual. NULL until the item has been verified; ``inconclusive`` whenever the # server could not answer โ€” which is never treated as success. verification: Mapped[str | None] = mapped_column(String) verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # The bytes on disk at verification time, and whether they still are the bytes # this batch uploaded. observed_sha256: Mapped[str | None] = mapped_column(String) changed_after_upload: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) class UploadVerification(Base): """Append-only evidence for every verification and manual resolution (US05-04). The item row is a projection of the latest answer; this table is the history that answers "who decided this, on what evidence, and when?". Rows are never updated or deleted, so a manual resolution can always be told apart from server evidence. """ __tablename__ = "upload_verifications" id: Mapped[str] = mapped_column(String, primary_key=True) batch_id: Mapped[str] = mapped_column( ForeignKey("upload_batches.id", ondelete="CASCADE"), nullable=False, index=True ) asset_id: Mapped[str] = mapped_column(String, nullable=False) action: Mapped[str] = mapped_column(String, nullable=False) # verify | resolve source: Mapped[str] = mapped_column(String, nullable=False) # immich_api | operator # present | absent | inconclusive for a verify; the recorded outcome for a resolve. result: Mapped[str] = mapped_column(String, nullable=False) outcome: Mapped[str | None] = mapped_column(String) evidence: Mapped[str] = mapped_column(String, nullable=False) actor: Mapped[str | None] = mapped_column(String) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() )