|
| 1 | +-- GitHub paths are case-insensitive, but repos.url is UNIQUE case-sensitively. |
| 2 | +-- Writers that predate URL lowercasing (cargo initial sync on 2026-06-19, maven |
| 3 | +-- enrichment before CM-1305) inserted mixed-case variants of rows that already |
| 4 | +-- existed lowercase: ~40k duplicate groups, ~10k of them serving duplicate |
| 5 | +-- security contacts from both variants. The CM-1305 backfills re-pointed |
| 6 | +-- package links to the lowercase rows but left the stale variants behind. |
| 7 | +-- |
| 8 | +-- Keeper per group: the all-lowercase row when present (links were already |
| 9 | +-- re-pointed to it), else the lowest id. Only package_repos is primary data |
| 10 | +-- and gets re-pointed (repo_docker comes along since it is a free UPDATE); |
| 11 | +-- contacts, snapshots, and scorecard rows on losers are derived and re-fill |
| 12 | +-- via the regular sweeps, so they are dropped with the loser rows. |
| 13 | + |
| 14 | +CREATE TEMP TABLE repo_merge_members AS |
| 15 | +SELECT id AS repo_id, keeper_id, id = keeper_id AS is_keeper |
| 16 | +FROM ( |
| 17 | + SELECT |
| 18 | + id, |
| 19 | + FIRST_VALUE(id) OVER ( |
| 20 | + PARTITION BY LOWER(url) |
| 21 | + ORDER BY (url = LOWER(url)) DESC, id |
| 22 | + ) AS keeper_id, |
| 23 | + COUNT(*) OVER (PARTITION BY LOWER(url)) AS group_size |
| 24 | + FROM repos |
| 25 | + WHERE host = 'github' |
| 26 | +) grouped |
| 27 | +WHERE group_size > 1; |
| 28 | + |
| 29 | +CREATE INDEX ON repo_merge_members (repo_id); |
| 30 | +ANALYZE repo_merge_members; |
| 31 | + |
| 32 | +-- Keep one link per (package, group), ranked the way consumers pick links |
| 33 | +-- (sqlFragments bestRepoLink: confidence DESC, declared-on-ties) so the merge |
| 34 | +-- preserves the strongest provenance; keeper status only breaks full ties. |
| 35 | +-- ROW_NUMBER instead of an EXISTS check against the keeper because 3-variant |
| 36 | +-- groups exist: two losers linking the same package would collide on |
| 37 | +-- UNIQUE (package_id, repo_id) after the re-point below. |
| 38 | +DELETE FROM package_repos |
| 39 | +WHERE id IN ( |
| 40 | + SELECT id |
| 41 | + FROM ( |
| 42 | + SELECT pr.id, |
| 43 | + ROW_NUMBER() OVER ( |
| 44 | + PARTITION BY m.keeper_id, pr.package_id |
| 45 | + ORDER BY pr.confidence DESC, (pr.source = 'declared') DESC, |
| 46 | + m.is_keeper DESC, pr.verified_at DESC, pr.id |
| 47 | + ) AS rn |
| 48 | + FROM package_repos pr |
| 49 | + JOIN repo_merge_members m ON m.repo_id = pr.repo_id |
| 50 | + ) ranked |
| 51 | + WHERE rn > 1 |
| 52 | +); |
| 53 | + |
| 54 | +-- Re-point surviving loser links via insert + delete, never an UPDATE: |
| 55 | +-- repo_id is part of the Tinybird packageRepos sorting key (CDC models key |
| 56 | +-- changes as delete + insert; a key-mutating UPDATE would leave both links |
| 57 | +-- live under FINAL with no way to reconcile). The fresh row streams cleanly |
| 58 | +-- with a new id and a NOW() version; the stale loser keys (these deletes |
| 59 | +-- included) are removed by the post-migration Tinybird datasource rebuild. |
| 60 | +INSERT INTO package_repos (package_id, repo_id, source, confidence, verified_at, created_at) |
| 61 | +SELECT pr.package_id, m.keeper_id, pr.source, pr.confidence, NOW(), NOW() |
| 62 | +FROM package_repos pr |
| 63 | +JOIN repo_merge_members m ON m.repo_id = pr.repo_id |
| 64 | +WHERE NOT m.is_keeper; |
| 65 | + |
| 66 | +DELETE FROM package_repos pr |
| 67 | +USING repo_merge_members m |
| 68 | +WHERE pr.repo_id = m.repo_id |
| 69 | + AND NOT m.is_keeper; |
| 70 | + |
| 71 | +UPDATE repo_docker d |
| 72 | +SET repo_id = m.keeper_id |
| 73 | +FROM repo_merge_members m |
| 74 | +WHERE d.repo_id = m.repo_id |
| 75 | + AND NOT m.is_keeper; |
| 76 | + |
| 77 | +DELETE FROM repo_scorecard_checks c |
| 78 | +USING repo_merge_members m |
| 79 | +WHERE c.repo_id = m.repo_id |
| 80 | + AND NOT m.is_keeper; |
| 81 | + |
| 82 | +-- packages.repository_url is denormalized and must keep matching the |
| 83 | +-- canonical repos.url (the maven backfill updates the two atomically for the |
| 84 | +-- same reason). Joined on repos directly, not the merge map, so it also |
| 85 | +-- covers packages pointing at mixed-case singletons normalized below; runs |
| 86 | +-- while loser urls still exist to match against. last_synced_at is the |
| 87 | +-- packages watermark, bumping it ships the correction to Tinybird. |
| 88 | +UPDATE packages p |
| 89 | +SET repository_url = LOWER(p.repository_url), last_synced_at = NOW() |
| 90 | +FROM repos r |
| 91 | +WHERE r.host = 'github' |
| 92 | + AND p.repository_url = r.url |
| 93 | + AND p.repository_url <> LOWER(p.repository_url); |
| 94 | + |
| 95 | +-- Cascades security_contacts and repo_activity_snapshot on losers. |
| 96 | +DELETE FROM repos r |
| 97 | +USING repo_merge_members m |
| 98 | +WHERE r.id = m.repo_id |
| 99 | + AND NOT m.is_keeper; |
| 100 | + |
| 101 | +-- Lowercase every surviving mixed-case github url: keepers whose group had |
| 102 | +-- no lowercase variant, plus mixed-case singletons that never had a |
| 103 | +-- duplicate. Singletons must be normalized before the guard index exists — |
| 104 | +-- writers upsert with ON CONFLICT (url), which does not arbitrate on the |
| 105 | +-- LOWER(url) index, so a later insert of the same repo's canonical lowercase |
| 106 | +-- url (all writers lowercase now) would raise unique_violation instead of |
| 107 | +-- upserting. Safe against UNIQUE (url): any two rows sharing LOWER(url) were |
| 108 | +-- a group above, and their losers are gone by now. |
| 109 | +UPDATE repos r |
| 110 | +SET url = LOWER(r.url), updated_at = NOW() |
| 111 | +WHERE r.host = 'github' |
| 112 | + AND r.url <> LOWER(r.url); |
| 113 | + |
| 114 | +-- The recurrence-guard unique index lives in the next migration: it must be |
| 115 | +-- built CONCURRENTLY (repos takes continuous worker writes), which cannot run |
| 116 | +-- inside this transaction. |
0 commit comments