"""api_keys + api_key_requests tables for B2B API (Stage 3). Revision ID: 0002 Revises: 0001 Create Date: 2026-08-07 Hand-written (docs/ARCHITECTURE.md §7 + docs/TICKETS.md T-E3-001). status/rate columns are TEXT+CHECK / INTEGER so later migrations remain additive. """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "0002" down_revision: str | None = "0001" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ── api_keys ───────────────────────────────────────────────────────────── op.create_table( "api_keys", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ), sa.Column("name", sa.Text, nullable=False), sa.Column("key_hash", sa.Text, nullable=False), sa.Column("rate_limit_rps", sa.Integer, nullable=False, server_default=sa.text("3")), sa.Column("monthly_quota", sa.Integer, nullable=True), sa.Column("monthly_used", sa.Integer, nullable=False, server_default=sa.text("0")), sa.Column( "resets_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("(now() + interval '1 month')"), ), sa.Column("revoked", sa.Boolean, nullable=False, server_default=sa.text("false")), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column("last_used_at", sa.DateTime(timezone=True)), sa.CheckConstraint("rate_limit_rps > 0", name="api_keys_rate_limit_positive"), sa.CheckConstraint("monthly_used >= 0", name="api_keys_monthly_used_nonneg"), sa.CheckConstraint( "monthly_quota IS NULL OR monthly_quota >= 0", name="api_keys_monthly_quota_nonneg", ), sa.UniqueConstraint("user_id", "name", name="api_keys_user_name_unique"), ) op.create_index("api_keys_user_idx", "api_keys", ["user_id"]) op.create_index("api_keys_hash_idx", "api_keys", ["key_hash"]) # ── api_key_requests ───────────────────────────────────────────────────── op.create_table( "api_key_requests", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "api_key_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), ) op.create_index( "api_key_requests_key_created_idx", "api_key_requests", ["api_key_id", sa.text("created_at DESC")], ) def downgrade() -> None: op.drop_index("api_key_requests_key_created_idx", table_name="api_key_requests") op.drop_table("api_key_requests") op.drop_index("api_keys_hash_idx", table_name="api_keys") op.drop_index("api_keys_user_idx", table_name="api_keys") op.drop_table("api_keys")