"""platform_membership — the learned roster of what the account actually pays for. Milestone 387, phase C. FC knows which creators it was told to follow and nothing about which ones the operator is subscribed to; this table is the memory that makes the drift in both directions observable. See the model docstring for why the roster is learned rather than looked up live, and why `status` holds the platform's own word rather than a normalised FC value. ## Nothing populates this yet, on purpose The sweep that fills it (C3) depends on a client seam (C2) that depends on characterising Patreon's real membership response from a captured sample (C0), which needs the operator's authenticated browser session. The table's SHAPE does not wait on that: it is deliberately free-form where C0's findings would otherwise dictate a column — `status` is an unconstrained String and `details` keeps the raw payload — so no capture can invalidate what is created here. An empty table is the correct intermediate state. It is not dead code: C5 reads it to explain a tier-limited source, and C4 reads it to reconcile. Revision ID: 0091 Revises: 0090 Create Date: 2026-09-10 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0091" down_revision: Union[str, None] = "0090" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "platform_membership", sa.Column("id", sa.Integer(), nullable=False), sa.Column("platform", sa.String(length=64), nullable=False), # Text, not a bounded String: an opaque upstream identifier we do not # mint, and guessing a ceiling for one is how a walk dies on a silent # truncation. sa.Column("external_campaign_id", sa.Text(), nullable=False), sa.Column("display_name", sa.Text(), nullable=True), sa.Column("url", sa.Text(), nullable=True), # No CHECK, deliberately (rule 36 considered and declined): the # vocabulary is each platform's own and is not ours to fix before C0 # has characterised even one of them. The service owns the whitelist. sa.Column("status", sa.String(length=32), nullable=True), sa.Column("tier_names", sa.JSON(), nullable=True), sa.Column("amount_cents", sa.Integer(), nullable=True), sa.Column("currency", sa.String(length=8), nullable=True), sa.Column( "first_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.Column( "last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.Column("details", sa.JSON(), nullable=False), sa.PrimaryKeyConstraint("id", name=op.f("pk_platform_membership")), # The upsert's conflict target. Named explicitly because # touch_membership references it by name in ON CONFLICT — an # autogenerated name would make that call break on a rename nobody # connected to it. sa.UniqueConstraint( "platform", "external_campaign_id", name="uq_platform_membership_platform_campaign", ), ) # No secondary indexes. This table holds one row per subscription — tens, # not millions — so every query against it is a short scan and an index # would be write cost buying nothing (#3301 removed seven of that shape). # The unique constraint above already backs the only lookup that matters. def downgrade() -> None: op.drop_table("platform_membership")