"""add temporal validity to categories Revision ID: d9c6533f8a1b Revises: 78ec55b9e1d1 Create Date: 2026-10-25 00:00:00.000000 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "d9c6533f8a1b" down_revision: str | Sequence[str] | None = "78ec55b9e1d1" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: with op.batch_alter_table("category") as batch_op: batch_op.drop_constraint("uq_category_name_parent", type_="unique") batch_op.add_column( sa.Column( "valid_from", sa.DateTime(timezone=True), nullable=True, info={ "description": "UTC start of validity; NULL means valid since the indefinite past." }, ) ) batch_op.add_column( sa.Column( "valid_to", sa.DateTime(timezone=True), nullable=True, info={ "description": "UTC end of validity; NULL means the version is currently valid." }, ) ) batch_op.add_column( sa.Column( "last_action", sa.String(), nullable=False, server_default="created", info={ "description": "Most recent lifecycle action recorded for this category version." }, ) ) # Existing rows predate temporal tracking, so their start is unbounded. op.execute("UPDATE category SET last_action = 'migrated'") # Separate root and non-root indexes so NULL parent IDs are unique too. op.create_index( "uq_category_active_name_parent", "category", ["name", "parent_id"], unique=True, sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"), postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"), ) op.create_index( "uq_category_active_root_name", "category", ["name"], unique=True, sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NULL"), postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NULL"), ) def downgrade() -> None: bind = op.get_bind() has_temporal_history = bind.execute( sa.text( "SELECT 1 FROM category " "WHERE valid_from IS NOT NULL OR valid_to IS NOT NULL LIMIT 1" ) ).first() if has_temporal_history: raise RuntimeError( "Cannot downgrade category validity while temporal history exists; " "the downgrade would discard it." ) op.drop_index("uq_category_active_root_name", table_name="category") op.drop_index("uq_category_active_name_parent", table_name="category") with op.batch_alter_table("category") as batch_op: batch_op.drop_column("last_action") batch_op.drop_column("valid_to") batch_op.drop_column("valid_from") batch_op.create_unique_constraint( "uq_category_name_parent", ["name", "parent_id"] )