"""add manual miner inventory schema Revision ID: 0008_manual_miner_inventory Revises: 0007_identity_and_roles Create Date: 2026-07-28 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql revision: str = "0008_manual_miner_inventory" down_revision: Union[str, None] = "0007_identity_and_roles" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None TAG_TRIMMED_NAME_SQL = ( "regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')" ) def upgrade() -> None: op.add_column("miners", sa.Column("control_board_sn", sa.Text(), nullable=True)) op.add_column( "miners", sa.Column( "lifecycle_status", sa.Text(), nullable=False, server_default=sa.text("'active'"), ), ) op.create_check_constraint( "ck_miners_lifecycle_status", "miners", "lifecycle_status IN ('active', 'spare', 'retired')", ) op.execute( """ DO $$ DECLARE conflicting_asset_ids TEXT; BEGIN SELECT string_agg( format('%L (%s records)', normalized_asset_id, record_count), ', ' ORDER BY normalized_asset_id ) INTO conflicting_asset_ids FROM ( SELECT lower(trim(asset_tag)) AS normalized_asset_id, COUNT(*) AS record_count FROM miners WHERE asset_tag IS NOT NULL AND trim(asset_tag) <> '' GROUP BY lower(trim(asset_tag)) HAVING COUNT(*) > 1 ) conflicts; IF conflicting_asset_ids IS NOT NULL THEN RAISE EXCEPTION USING MESSAGE = 'Cannot enforce globally unique Asset IDs. ' || 'Conflicting normalized values: ' || conflicting_asset_ids, HINT = 'Correct the conflicting miners.asset_tag values ' || 'before upgrading, then run the migration again.'; END IF; END $$; """ ) op.create_index( "uq_miners_asset_tag_ci", "miners", [sa.text("lower(trim(asset_tag))")], unique=True, postgresql_where=sa.text("asset_tag IS NOT NULL AND trim(asset_tag) <> ''"), ) op.create_table( "tags", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column("name", sa.Text(), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.CheckConstraint( f"{TAG_TRIMMED_NAME_SQL} <> ''", name="ck_tags_name_not_blank", ), ) op.create_index( "uq_tags_name_ci", "tags", [sa.text(f"lower({TAG_TRIMMED_NAME_SQL})")], unique=True, ) op.create_table( "miner_tags", sa.Column( "miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "tag_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.PrimaryKeyConstraint("miner_id", "tag_id", name="pk_miner_tags"), ) op.create_index("idx_miner_tags_tag_id", "miner_tags", ["tag_id"]) # Discovery continues to receive now() defaults. Manual creation can # explicitly store NULL to represent an asset that has never been observed. op.alter_column( "miners", "first_seen_at", existing_type=sa.DateTime(timezone=True), existing_server_default=sa.text("now()"), nullable=True, ) op.alter_column( "miners", "last_seen_at", existing_type=sa.DateTime(timezone=True), existing_server_default=sa.text("now()"), nullable=True, ) def downgrade() -> None: # created_at is non-null in the pre-0008 schema, making this deterministic. op.execute( """ UPDATE miners SET first_seen_at = COALESCE(first_seen_at, created_at), last_seen_at = COALESCE(last_seen_at, created_at) WHERE first_seen_at IS NULL OR last_seen_at IS NULL; """ ) op.alter_column( "miners", "last_seen_at", existing_type=sa.DateTime(timezone=True), existing_server_default=sa.text("now()"), nullable=False, ) op.alter_column( "miners", "first_seen_at", existing_type=sa.DateTime(timezone=True), existing_server_default=sa.text("now()"), nullable=False, ) op.drop_index("idx_miner_tags_tag_id", table_name="miner_tags") op.drop_table("miner_tags") op.drop_index("uq_tags_name_ci", table_name="tags") op.drop_table("tags") op.drop_index("uq_miners_asset_tag_ci", table_name="miners") op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") op.drop_column("miners", "lifecycle_status") op.drop_column("miners", "control_board_sn")