"""membership_sync — whether the roster actually synced, and when. Milestone 387, step C3. `platform_membership` (0091) records what was SEEN. This records whether looking happened at all — a different fact, and the one that makes an empty roster readable. Without it, three situations collapse into one: the account subscribes to nothing, the sweep never ran, or the sweep failed. All three leave zero rows in `platform_membership`. "You are tracking 12 sources you no longer subscribe to" is correct in the first case and an invitation to cancel things the operator is actively paying for in the other two, which is why C4 gates its CONCLUSIONS on `last_success_at` rather than merely displaying it. Two timestamps rather than one, deliberately: `last_attempt_at` moves every run, `last_success_at` only on a clean walk, and the gap between them is what lets the UI say "last synced 3 days ago, tried 20 minutes ago, failing". Revision ID: 0095 Revises: 0094 Create Date: 2026-09-11 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0095" down_revision: Union[str, None] = "0094" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "membership_sync", sa.Column("id", sa.Integer(), nullable=False), sa.Column("platform", sa.String(length=64), nullable=False), sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_count", sa.Integer(), nullable=True), # No CHECK: this carries an exception class name, and the vocabulary is # whatever the client raises — same call as source.error_type. sa.Column("last_error_type", sa.String(length=64), nullable=True), sa.Column("last_error_message", sa.Text(), nullable=True), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.PrimaryKeyConstraint("id", name=op.f("pk_membership_sync")), # The upsert's conflict target, named explicitly because the service # references it by name in ON CONFLICT. sa.UniqueConstraint("platform", name="uq_membership_sync_platform"), ) # No secondary indexes: one row per platform, so every read is a short scan # and an index would be write cost buying nothing (#3301 removed seven of # that shape). Same reasoning as platform_membership in 0091. def downgrade() -> None: op.drop_table("membership_sync")