"""initial schema: users, documents, reports, jobs, service_tokens, invoices Revision ID: 0001 Revises: Create Date: 2026-01-01 Hand-written (docs/ARCHITECTURE.md §7). status/queue/adapter columns are TEXT+CHECK (additive migrations). UUIDs default to gen_random_uuid() server-side (built into Postgres 13+; we run pg16). """ 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 = "0001" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ── users ──────────────────────────────────────────────────────────────── op.create_table( "users", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column("telegram_id", sa.BigInteger, unique=True), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column("credits_left", sa.Integer, nullable=False, server_default=sa.text("0")), sa.CheckConstraint("credits_left >= 0", name="users_credits_nonneg"), ) # ── documents ──────────────────────────────────────────────────────────── op.create_table( "documents", 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("s3_key", sa.Text, nullable=False), sa.Column("extracted_s3_key", sa.Text), sa.Column("filename", sa.Text, nullable=False), sa.Column("mime", sa.Text, nullable=False), sa.Column("bytes", sa.BigInteger, nullable=False, server_default=sa.text("0")), sa.Column("status", sa.String, nullable=False, server_default=sa.text("'queued'")), sa.Column("stage", sa.Text), sa.Column("refunded", 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( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "status IN ('queued','extracting','ocr','analyzing','done','failed')", name="documents_status_check", ), ) op.create_index("documents_user_created_idx", "documents", ["user_id", sa.text("created_at DESC")]) op.create_index("documents_status_idx", "documents", ["status"]) # ── reports ────────────────────────────────────────────────────────────── op.create_table( "reports", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, unique=True, ), sa.Column("content_json", postgresql.JSONB, nullable=False), sa.Column("markdown", sa.Text, nullable=False), sa.Column("model_used", sa.Text), sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default=sa.text("0")), sa.Column("eval_tokens", sa.Integer, nullable=False, server_default=sa.text("0")), sa.Column("latency_ms", sa.Integer, nullable=False, server_default=sa.text("0")), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), ) # ── jobs ───────────────────────────────────────────────────────────────── op.create_table( "jobs", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column( "document_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, ), sa.Column("correlation_id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("queue", sa.String, nullable=False), sa.Column("attempts", sa.Integer, nullable=False, server_default=sa.text("0")), sa.Column("max_attempts", sa.Integer, nullable=False, server_default=sa.text("5")), sa.Column("last_failure_class", sa.Text), sa.Column("last_error", sa.Text), sa.Column("dlq", sa.Boolean, nullable=False, server_default=sa.text("false")), sa.Column("status", sa.String, nullable=False, server_default=sa.text("'pending'")), 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(), ), sa.CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"), sa.CheckConstraint( "status IN ('pending','running','retrying','dlq','done')", name="jobs_status_check", ), ) op.create_index("jobs_correlation_idx", "jobs", ["correlation_id"]) op.create_index("jobs_document_idx", "jobs", ["document_id"]) # ── service_tokens ─────────────────────────────────────────────────────── op.create_table( "service_tokens", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()"), ), sa.Column("name", sa.Text, unique=True, nullable=False), sa.Column("token_hash", sa.Text, nullable=False), sa.Column("adapter", sa.String, nullable=False), 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( "adapter IN ('bot','web','cli')", name="service_tokens_adapter_check" ), ) # ── invoices (STUB — no ЮKassa logic this refactor) ───────────────────── op.create_table( "invoices", 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("amount", sa.Integer, nullable=False), sa.Column("status", sa.String, nullable=False, server_default=sa.text("'draft'")), sa.Column("provider", sa.Text), sa.Column("external_id", sa.Text), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column("paid_at", sa.DateTime(timezone=True)), sa.CheckConstraint( "status IN ('draft','pending','succeeded','cancelled','refunded')", name="invoices_status_check", ), ) op.create_index("invoices_user_idx", "invoices", ["user_id", sa.text("created_at DESC")]) def downgrade() -> None: op.drop_index("invoices_user_idx", table_name="invoices") op.drop_table("invoices") op.drop_table("service_tokens") op.drop_index("jobs_document_idx", table_name="jobs") op.drop_index("jobs_correlation_idx", table_name="jobs") op.drop_table("jobs") op.drop_table("reports") op.drop_index("documents_status_idx", table_name="documents") op.drop_index("documents_user_created_idx", table_name="documents") op.drop_table("documents") op.drop_table("users")