"""source-collapse: one Source per (artist, platform) — consolidate junk per-post Sources Revision ID: 0022 Revises: 0021 Create Date: 2026-05-26 Closes the operator-flagged 2026-05-26 issue where the filesystem importer called _find_or_create_source(url=sd.post_url), creating one Source row per imported post URL. Operator's Atole artist had 406 Source rows where there should have been 1 (the /cw/Atole subscription Source). Source represents a subscription feed (one per artist+platform — the gallery-dl URL polled by the FC-3 downloader). Posts hang off it. The filesystem importer was misusing Source as a per-post key. Migration steps per (artist_id, platform) group with >1 Source: 1. Pick canonical — prefer a URL NOT matching '/posts/$' (real campaign URL like /cw/Atole); else min(id). 2. PRE-merge any Posts under non-canonical sources whose external_post_id ALREADY exists under the canonical source. (Same gallery-dl post imported via two different sidecar paths can plant two Post rows with identical external_post_id under different Sources for the same artist.) Repoint ImageProvenance + ImageRecord.primary_post_id to the canonical-side Post, dedupe ImageProvenance against alembic 0021's uq, then delete the non-canonical-side Post. This MUST happen before step 3 — Postgres fires uq_post_source_external_id row-by-row during the bulk UPDATE and the merge-after-reparent ordering 500s on first collision (operator-hit during v26.05.26.1 deploy, 2026-05-26). 3. Reparent remaining Posts onto canonical (no collisions possible now). 4. Reparent ImageProvenance.source_id off the non-canonical sources. 5. Delete the orphan Source rows. 6. If the canonical Source's URL still looks like a per-post URL (no campaign URL existed among candidates), rewrite it to 'sidecar::' so the artist detail page shows something readable. """ from typing import Sequence, Union from alembic import op from sqlalchemy import text revision: str = "0022" down_revision: Union[str, None] = "0021" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None _POST_URL_RE = r"/posts/[^/]+$" def upgrade() -> None: conn = op.get_bind() # Find (artist_id, platform) groups with > 1 Source row. groups = conn.execute(text(""" SELECT artist_id, platform FROM source GROUP BY artist_id, platform HAVING COUNT(*) > 1 """)).fetchall() for artist_id, platform in groups: rows = conn.execute( text(""" SELECT id, url FROM source WHERE artist_id = :a AND platform = :p ORDER BY id ASC """), {"a": artist_id, "p": platform}, ).fetchall() # Canonical: first row whose URL doesn't look like a per-post URL; # else min(id). canonical_id = None for sid, url in rows: if not _matches_post_url(url): canonical_id = sid break if canonical_id is None: canonical_id = rows[0][0] other_ids = [sid for sid, _ in rows if sid != canonical_id] if not other_ids: continue # STEP 2: PRE-merge ALL Posts with duplicate external_post_id # across the entire (canonical + others) group, BEFORE the bulk # reparent. Two cases must both be handled: # (A) canonical has Post X with epid=N; an "other" source has # Post Y with epid=N → after bulk UPDATE, (canonical, N) # collides with itself. # (B) two different "other" sources each have a Post with # epid=N; canonical has none → after bulk UPDATE, both # are repointed to (canonical, N) and the second collides. # The earlier version of this migration only handled (A); the # operator's deploy 2026-05-26 tripped (B) at line 139. # Fix: group ALL Posts in the (artist, platform) by epid; for # any group with count>1, pick the keep (prefer one already # under canonical; else lowest id) and merge the rest into it. all_posts = conn.execute( text(""" SELECT external_post_id, id, source_id FROM post WHERE source_id = :canonical OR source_id = ANY(:others) ORDER BY external_post_id, id """), {"canonical": canonical_id, "others": other_ids}, ).fetchall() by_epid: dict = {} for epid, post_id, src_id in all_posts: by_epid.setdefault(epid, []).append((post_id, src_id)) for _epid, posts in by_epid.items(): if len(posts) <= 1: continue # Prefer a Post already under canonical as the keep. canonical_posts = [p for p in posts if p[1] == canonical_id] if canonical_posts: keep_id = canonical_posts[0][0] else: keep_id = posts[0][0] # already sorted by id ASC drop_ids = [p[0] for p in posts if p[0] != keep_id] for drop_id in drop_ids: # Pre-delete image_provenance rows under drop_ whose # image_record_id ALREADY has a provenance under keep — # the UPDATE below would otherwise repoint them and # trip uq_image_provenance_image_post (alembic 0021) # row-by-row before any after-the-fact dedupe could # run. Operator's v26.05.26.3 deploy 2026-05-26 tripped # this at line 123. conn.execute( text(""" DELETE FROM image_provenance WHERE post_id = :drop_ AND image_record_id IN ( SELECT image_record_id FROM image_provenance WHERE post_id = :keep ) """), {"keep": keep_id, "drop_": drop_id}, ) # Now safe to repoint the survivors. conn.execute( text(""" UPDATE image_provenance SET post_id = :keep WHERE post_id = :drop_ """), {"keep": keep_id, "drop_": drop_id}, ) conn.execute( text(""" UPDATE image_record SET primary_post_id = :keep WHERE primary_post_id = :drop_ """), {"keep": keep_id, "drop_": drop_id}, ) conn.execute( text("DELETE FROM post WHERE id = :drop_"), {"drop_": drop_id}, ) # STEP 3: Bulk reparent the remaining Posts off the other # Sources. After step 2, no collisions on # (canonical, external_post_id) are possible. conn.execute( text(""" UPDATE post SET source_id = :canonical WHERE source_id = ANY(:others) """), {"canonical": canonical_id, "others": other_ids}, ) # STEP 4: Reparent ImageProvenance.source_id (denormalized FK). # No UNIQUE on source_id; safe bulk update. conn.execute( text(""" UPDATE image_provenance SET source_id = :canonical WHERE source_id = ANY(:others) """), {"canonical": canonical_id, "others": other_ids}, ) # STEP 5: Drop the orphan Sources. conn.execute( text("DELETE FROM source WHERE id = ANY(:others)"), {"others": other_ids}, ) # If the canonical's URL still looks per-post (no campaign URL # existed among the candidates), rewrite to a synthetic anchor so # the artist detail page renders something readable. canonical_url = conn.execute( text("SELECT url FROM source WHERE id = :id"), {"id": canonical_id}, ).scalar_one() if _matches_post_url(canonical_url): slug = conn.execute( text("SELECT slug FROM artist WHERE id = :id"), {"id": artist_id}, ).scalar_one() conn.execute( text(""" UPDATE source SET url = :new_url, enabled = false WHERE id = :id """), { "id": canonical_id, "new_url": f"sidecar:{platform}:{slug}", }, ) def downgrade() -> None: # Lossy migration — orphan Sources deleted, Posts reparented, Posts # merged. No safe downgrade. If you need to roll back the schema # invariant, fork from 0021 and re-run filesystem imports. pass def _matches_post_url(url: str) -> bool: """True if url ends with /posts/ (gallery-dl-style per-post URL).""" import re return bool(re.search(_POST_URL_RE, url or ""))