Files
photoanalyzer/migrations/versions/0012_archive_plans.py

97 lines
4.4 KiB
Python

"""Archive plans, per-item transfer journal, and archived asset location (US06-02).
Revision ID: 0012_archive_plans
Revises: 0011_archive_locations
Create Date: 2026-08-16
The journal is what makes removing an original recoverable: every item records its
source, destination, expected hash, and the state it had reached before the process
died. Assets gain the archive location and relative path so an offline original is
still explained rather than looking missing.
"""
import sqlalchemy as sa
from alembic import op
revision = "0012_archive_plans"
down_revision = "0011_archive_locations"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"archive_plans",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"location_id",
sa.String(),
sa.ForeignKey("archive_locations.id"),
nullable=False,
index=True,
),
# The preflight token this plan was approved against.
sa.Column("token", sa.String(), nullable=False),
sa.Column("albums", sa.String(), nullable=True), # JSON array
# planned | applying | complete | failed
sa.Column("state", sa.String(), nullable=False, server_default="planned"),
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("asset_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("byte_size", sa.Integer(), nullable=False, server_default="0"),
# Bumped on every claim and used as the fencing token.
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("worker_id", sa.String(), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
)
op.create_table(
"archive_operations",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"plan_id",
sa.String(),
sa.ForeignKey("archive_plans.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("album", sa.String(), nullable=False),
sa.Column("asset_id", sa.String(), sa.ForeignKey("assets.id"), nullable=False, index=True),
sa.Column("source_path", sa.String(), nullable=False),
sa.Column("destination_path", sa.String(), nullable=False),
# Relative to the location root: the medium can be mounted anywhere later.
sa.Column("archive_path", sa.String(), nullable=False),
sa.Column("expected_sha256", sa.String(), nullable=False),
sa.Column("byte_size", sa.Integer(), nullable=True),
sa.Column("same_filesystem", sa.Boolean(), nullable=True),
# planned | transferring | verified | removing | complete | failed
sa.Column("journal_state", sa.String(), nullable=False, server_default="planned"),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("fencing_token", sa.Integer(), nullable=True),
sa.Column("worker_id", sa.String(), nullable=True),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_code", sa.String(), nullable=True),
sa.Column("error_message", sa.String(), nullable=True),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.UniqueConstraint("plan_id", "sequence", name="uq_archive_operations_plan_sequence"),
)
# Plain columns: SQLite cannot ALTER a table to add a foreign key, and the
# relationship is enforced by the service that writes them.
op.add_column("assets", sa.Column("archive_location_id", sa.String(), nullable=True))
op.add_column("assets", sa.Column("archive_path", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("assets", "archive_path")
op.drop_column("assets", "archive_location_id")
op.drop_table("archive_operations")
op.drop_table("archive_plans")