diff --git a/src/services/statcastAggregateService.js b/src/services/statcastAggregateService.js index 2826804..391a33b 100644 --- a/src/services/statcastAggregateService.js +++ b/src/services/statcastAggregateService.js @@ -206,7 +206,12 @@ async function refreshSeason(opts = {}) { const batch = rows.slice(i, i + chunk).map(toDbRow); const { error } = await sb .from('statcast_aggregates') - .upsert(batch, { onConflict: 'sport,season,source_id' }); + // ROLE is part of the key: a two-way player has a real batter profile AND + // a real pitcher profile, and merging them would invent one player out of + // two genuinely different sets of measurements. (Found by inducing the + // job: without role, one batch hit the same row twice and Postgres + // refused the chunk.) + .upsert(batch, { onConflict: 'sport,season,source_id,role' }); if (error) { summary.ok = false; summary.reason = `upsert failed: ${error.message}`; diff --git a/supabase/migrations/030_statcast_aggregates.sql b/supabase/migrations/030_statcast_aggregates.sql index a70421a..8cc431a 100644 --- a/supabase/migrations/030_statcast_aggregates.sql +++ b/supabase/migrations/030_statcast_aggregates.sql @@ -60,7 +60,9 @@ create table if not exists statcast_aggregates ( updated_at timestamptz not null default now(), source text not null default 'baseball_savant', - primary key (sport, season, source_id) + -- ROLE is in the key: two-way players have a real batter profile AND a + -- real pitcher profile (see migration 031). + primary key (sport, season, source_id, role) ); create index if not exists statcast_agg_player_key_idx on statcast_aggregates (sport, season, player_key); diff --git a/tests/unit/statcastAggregate.test.js b/tests/unit/statcastAggregate.test.js index e88eb58..6cdd628 100644 --- a/tests/unit/statcastAggregate.test.js +++ b/tests/unit/statcastAggregate.test.js @@ -131,7 +131,7 @@ describe('refreshSeason — the job', () => { let opts = null; const sb = { from: () => ({ upsert: async (_b, o) => { opts = o; return { error: null }; } }) }; await svc.refreshSeason({ supabase: sb, fetchSeason: async () => feeds({ batter: [BELL] }) }); - expect(opts.onConflict).toBe('sport,season,source_id'); + expect(opts.onConflict).toBe('sport,season,source_id,role'); }); it('REFUSES to write when every feed is empty — a bad night cannot blank a good table', async () => { @@ -148,6 +148,18 @@ describe('refreshSeason — the job', () => { expect(sb.calls[0][0]._sufficient).toBeUndefined(); }); + it('keys two-way players by ROLE — one player, two real profiles', async () => { + // Ohtani appears in both the batter and the pitcher feeds. Without role in + // the key, one upsert batch hits the same row twice and Postgres refuses + // the whole chunk — found by inducing the real job, not by review. + const OHTANI_B = { 'last_name, first_name': 'Ohtani, Shohei', player_id: '660271', pa: '400' }; + const OHTANI_P = { 'last_name, first_name': 'Ohtani, Shohei', player_id: '660271', p_formatted_ip: '40' }; + const rows = svc.buildRows(2026, feeds({ batter: [OHTANI_B], pitcher: [OHTANI_P] }), {}); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((r) => r.source_id)).size).toBe(1); + expect(new Set(rows.map((r) => r.role))).toEqual(new Set(['batter', 'pitcher'])); + }); + it('reports the join rate — the honest-absent rate for the mechanism tier', async () => { const out = await svc.refreshSeason({ supabase: okClient(),