"""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")