2026-04-29 00:37:45 +08:00
|
|
|
"""Create Apple IAP schema and mark the squashed chain head.
|
|
|
|
|
|
|
|
|
|
Revision ID: 20260428_0004
|
|
|
|
|
Revises: 20260428_squash_0004
|
|
|
|
|
Create Date: 2026-04-28 00:04:00
|
|
|
|
|
|
|
|
|
|
Squashed history: creates the Apple IAP table with the corrected RLS policy
|
|
|
|
|
from the start. The revision id intentionally remains the previous head so
|
|
|
|
|
databases already stamped at 20260428_0004 stay recognized as current.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from typing import Sequence, Union
|
|
|
|
|
|
|
|
|
|
from alembic import op
|
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
|
|
|
|
|
|
revision: str = "20260428_0004"
|
|
|
|
|
down_revision: Union[str, Sequence[str], None] = "20260428_squash_0004"
|
|
|
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
|
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upgrade() -> None:
|
|
|
|
|
op.create_table(
|
|
|
|
|
"apple_iap_transactions",
|
|
|
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
|
|
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
|
|
|
sa.Column("product_code", sa.String(length=32), nullable=False),
|
|
|
|
|
sa.Column("app_store_product_id", sa.String(length=128), nullable=False),
|
|
|
|
|
sa.Column("transaction_id", sa.String(length=64), nullable=False),
|
|
|
|
|
sa.Column("original_transaction_id", sa.String(length=64), nullable=True),
|
|
|
|
|
sa.Column("web_order_line_item_id", sa.String(length=64), nullable=True),
|
|
|
|
|
sa.Column("environment", sa.String(length=16), nullable=False),
|
|
|
|
|
sa.Column("bundle_id", sa.String(length=128), nullable=False),
|
|
|
|
|
sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True),
|
|
|
|
|
sa.Column("purchase_date", sa.Text(), nullable=False),
|
|
|
|
|
sa.Column("revocation_date", sa.Text(), nullable=True),
|
|
|
|
|
sa.Column("status", sa.String(length=24), nullable=False),
|
|
|
|
|
sa.Column("credits", sa.BigInteger(), nullable=False),
|
|
|
|
|
sa.Column("currency", sa.String(length=8), nullable=True),
|
|
|
|
|
sa.Column("price_milliunits", sa.BigInteger(), nullable=True),
|
|
|
|
|
sa.Column("ledger_event_id", sa.String(length=64), nullable=True),
|
|
|
|
|
sa.Column("signed_transaction_info", sa.Text(), nullable=False),
|
2026-05-21 16:26:58 +08:00
|
|
|
sa.Column(
|
|
|
|
|
"apple_payload",
|
|
|
|
|
postgresql.JSONB(astext_type=sa.Text()),
|
|
|
|
|
server_default=sa.text("'{}'::jsonb"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
),
|
2026-04-29 00:37:45 +08:00
|
|
|
sa.Column("failure_code", sa.String(length=64), nullable=True),
|
2026-05-21 16:26:58 +08:00
|
|
|
sa.Column(
|
|
|
|
|
"created_at",
|
|
|
|
|
sa.DateTime(timezone=True),
|
|
|
|
|
server_default=sa.text("now()"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
),
|
|
|
|
|
sa.Column(
|
|
|
|
|
"updated_at",
|
|
|
|
|
sa.DateTime(timezone=True),
|
|
|
|
|
server_default=sa.text("now()"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
),
|
|
|
|
|
sa.CheckConstraint(
|
|
|
|
|
"environment in ('Sandbox', 'Production')",
|
|
|
|
|
name="ck_apple_iap_transactions_environment",
|
|
|
|
|
),
|
|
|
|
|
sa.CheckConstraint(
|
|
|
|
|
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
|
|
|
|
|
name="ck_apple_iap_transactions_status",
|
|
|
|
|
),
|
2026-04-29 00:37:45 +08:00
|
|
|
sa.PrimaryKeyConstraint("id"),
|
2026-05-21 16:26:58 +08:00
|
|
|
sa.UniqueConstraint(
|
|
|
|
|
"transaction_id", name="uq_apple_iap_transactions_transaction_id"
|
|
|
|
|
),
|
|
|
|
|
sa.UniqueConstraint(
|
|
|
|
|
"ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
op.create_index(
|
|
|
|
|
"ix_apple_iap_transactions_user_created_at",
|
|
|
|
|
"apple_iap_transactions",
|
|
|
|
|
["user_id", sa.text("created_at DESC")],
|
|
|
|
|
)
|
|
|
|
|
op.create_index(
|
|
|
|
|
"ix_apple_iap_transactions_status_updated_at",
|
|
|
|
|
"apple_iap_transactions",
|
|
|
|
|
["status", sa.text("updated_at DESC")],
|
2026-04-29 00:37:45 +08:00
|
|
|
)
|
|
|
|
|
_enable_service_only_rls("apple_iap_transactions")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def downgrade() -> None:
|
|
|
|
|
_drop_service_only_rls("apple_iap_transactions")
|
|
|
|
|
op.drop_table("apple_iap_transactions")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enable_service_only_rls(table_name: str) -> None:
|
|
|
|
|
for role in ["anon", "authenticated"]:
|
|
|
|
|
for action in ["select", "insert", "update", "delete"]:
|
2026-05-21 16:26:58 +08:00
|
|
|
op.execute(
|
|
|
|
|
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
|
|
|
|
|
)
|
2026-04-29 00:37:45 +08:00
|
|
|
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
|
|
|
|
|
for role in ["anon", "authenticated"]:
|
2026-05-21 16:26:58 +08:00
|
|
|
op.execute(
|
|
|
|
|
f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)"
|
|
|
|
|
)
|
|
|
|
|
op.execute(
|
|
|
|
|
f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)"
|
|
|
|
|
)
|
|
|
|
|
op.execute(
|
|
|
|
|
f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)"
|
|
|
|
|
)
|
|
|
|
|
op.execute(
|
|
|
|
|
f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)"
|
|
|
|
|
)
|
2026-04-29 00:37:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _drop_service_only_rls(table_name: str) -> None:
|
|
|
|
|
for role in ["anon", "authenticated"]:
|
|
|
|
|
for action in ["select", "insert", "update", "delete"]:
|
2026-05-21 16:26:58 +08:00
|
|
|
op.execute(
|
|
|
|
|
f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
|
|
|
|
|
)
|
2026-04-29 00:37:45 +08:00
|
|
|
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
|