"""membership_sync — did the roster actually sync, and when. Milestone 387, step C3. `platform_membership` records what was SEEN. This records whether looking happened at all, and that is a different fact — the one that makes an empty roster readable. ## Why this table has to exist Without it, three very different situations are one indistinguishable state: * the account genuinely subscribes to nothing, * the sweep has never run, * the sweep ran and failed. All three produce zero rows in `platform_membership`. Telling the operator "you are tracking 12 sources you do not subscribe to" is correct in the first case and catastrophic in the other two — it is an invitation to cancel things they are actively paying for. C4 must therefore gate its CONCLUSIONS on `last_success_at`, not merely display it. `MAX(platform_membership.last_seen_at)` was the tempting shortcut and does not work: it cannot distinguish "synced fine, found nothing" from "never synced". `task_run` was the other candidate and is worse — its retention prunes ok rows after 24h, so a sweep that last succeeded three days ago would leave no trace at all. ## Separate attempt and success timestamps, deliberately `last_attempt_at` moves every run; `last_success_at` moves only on a clean walk. The GAP between them is the staleness signal, and keeping them apart is what lets the UI say "last synced 3 days ago, last tried 20 minutes ago, failing" — which is a different message from either half alone. """ from datetime import datetime from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from .base import Base class MembershipSync(Base): __tablename__ = "membership_sync" __table_args__ = ( UniqueConstraint("platform", name="uq_membership_sync_platform"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) platform: Mapped[str] = mapped_column(String(64), nullable=False) # Moves on EVERY run, success or not — so "we are trying" is visible even # while "we are succeeding" is not. last_attempt_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Moves only on a COMPLETE walk. This is the freshness signal C4 gates its # conclusions on; NULL means never — which must never be rendered as zero. last_success_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # How many memberships the last SUCCESSFUL walk saw. Paired with # last_success_at so "0" is only ever readable as a real zero. last_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cleared on success. Plain String, no CHECK — this carries an exception # class name (PatreonAuthError, PatreonDriftError, ...) and the vocabulary # is whatever the client raises, exactly as source.error_type works. last_error_type: Mapped[str | None] = mapped_column(String(64), nullable=True) last_error_message: Mapped[str | None] = mapped_column(Text, nullable=True) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), )