"""library_placement_run — the placement reconciler's plan/apply/undo ledger. Milestone #421 step 3. The survey (#4245) measured 33,789 ImageRecord rows sitting outside their artist's canonical directory, across 56 artists. This table holds one run of the sweep that trues them up: the plan, what it did, and where every file came from. ## Why the moves live in a table rather than a log line `ImageRecord.path` is the only pointer at the bytes, so a move rewrites the row. Once that write lands, the previous location exists nowhere — unless it was recorded first. `moves` is that record, which is what makes a 33,789-file operation something the operator can undo per artist after looking at the result, rather than a one-way door. An `applied` row is therefore HISTORY, not state (lesson #4226). Any future retention on this table may prune `ready`, `cancelled` and `error` runs; an `applied` one is only disposable once someone decides undo is no longer wanted. That is deliberately not a timer's decision, and no pruning is added here. Revision ID: 0099 Revises: 0098 Create Date: 2026-09-21 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql revision: str = "0099" down_revision: Union[str, None] = "0098" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "library_placement_run", sa.Column("id", sa.Integer(), nullable=False), sa.Column( "status", sa.String(length=16), server_default="running", nullable=False, ), # SET NULL, not CASCADE: deleting an artist must not destroy the # record of where their files were moved. sa.Column("artist_id", sa.Integer(), nullable=True), sa.Column( "started_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "planned_count", sa.Integer(), server_default="0", nullable=False, ), sa.Column( "moved_count", sa.Integer(), server_default="0", nullable=False, ), sa.Column( "refused_count", sa.Integer(), server_default="0", nullable=False, ), sa.Column( "moves", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False, ), sa.Column( "refusals", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False, ), sa.Column("error", sa.Text(), nullable=True), sa.ForeignKeyConstraint( ["artist_id"], ["artist.id"], name="fk_library_placement_run_artist_id", ondelete="SET NULL", ), sa.PrimaryKeyConstraint("id"), ) op.create_index( "ix_library_placement_run_status", "library_placement_run", ["status"], ) op.create_index( "ix_library_placement_run_artist_id", "library_placement_run", ["artist_id"], ) def downgrade() -> None: # Dropping this table destroys the only record of where moved files came # from. That is correct for a downgrade — the code that reads it is going # away too — but it is worth saying out loud rather than discovering. op.drop_index( "ix_library_placement_run_artist_id", table_name="library_placement_run", ) op.drop_index( "ix_library_placement_run_status", table_name="library_placement_run", ) op.drop_table("library_placement_run")