"""An open grouping — a synthetic post that a later drop can still join. Milestone 388, step E3. E2's synthetic post was sealed at creation: a creator who added two more variants the next day started a second post. These two columns let the group stay open and absorb the follow-up, without the post either freezing or thrashing the feed. ## Why openness is derived rather than stored There is no `closed_at` here on purpose. A group is open if it grew (or started) within `ml_settings.discord_group_close_after_hours`, so openness is a comparison rather than a state — which means lowering the setting closes old groups and raising it reopens them, with nothing to repair either way. A stored flag would need its own sweep to set it and its own repair path to ever change the policy, for no gain. ## Why `resurfaced_at` is separate from `last_grew_at` They answer different questions. `last_grew_at` is when the group last absorbed something — it decides how long the group stays joinable and is what the card shows. `resurfaced_at` is the FEED POSITION, advanced only when the anti-thrash rule fires, so a group that gains one image a day updates in place while a genuine second wave moves once. Folding them together would make every addition a bump, which is the annoyance this step exists to avoid. Both are NULL on every ordinary post, so the feed's sort key can COALESCE through `resurfaced_at` without moving anything that is not a grouping. Revision ID: 0093 Revises: 0092 Create Date: 2026-09-10 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0093" down_revision: Union[str, None] = "0092" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column( "post", sa.Column("last_grew_at", sa.DateTime(timezone=True), nullable=True), ) op.add_column( "post", sa.Column("resurfaced_at", sa.DateTime(timezone=True), nullable=True), ) # No index on either. The feed already sorts on an unindexed # COALESCE(post_date, downloaded_at) expression, so adding resurfaced_at to # that COALESCE changes nothing about how the query plans — and inventing a # functional index here would be guessing at the fix for a cost nobody has # measured. Measuring it is step B2's job. op.add_column( "ml_settings", sa.Column( "discord_group_close_after_hours", sa.Float(), server_default=sa.text("168"), nullable=False, ), ) op.add_column( "ml_settings", sa.Column( "discord_group_resurface_min_images", sa.Integer(), server_default="2", nullable=False, ), ) op.add_column( "ml_settings", sa.Column( "discord_group_resurface_cooldown_hours", sa.Float(), server_default=sa.text("24"), nullable=False, ), ) def downgrade() -> None: op.drop_column("ml_settings", "discord_group_resurface_cooldown_hours") op.drop_column("ml_settings", "discord_group_resurface_min_images") op.drop_column("ml_settings", "discord_group_close_after_hours") op.drop_column("post", "resurfaced_at") op.drop_column("post", "last_grew_at")