2026-06-14 15:36:44 +02:00
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { sql } from 'drizzle-orm'
import { TEAM_ISO , getIso } from '../lib/iso-codes'
const DATABASE_URL = process . env . DATABASE_URL ? ? 'postgres://wc:wc@localhost:5432/worldcup'
const BASE = 'https://raw.githubusercontent.com/openfootball/worldcup.json/master'
async function fetchJson ( url : string ) : Promise < unknown > {
try {
const res = await fetch ( url )
if ( ! res . ok ) return null
return res . json ( )
} catch {
return null
}
}
type RawGoal = { name : string ; minute? : string | number ; offset? : number ; penalty? : boolean ; owngoal? : boolean }
type RawScore = { ft? : number [ ] ; ht? : number [ ] ; et? : number [ ] ; p? : number [ ] } | number [ ]
type RawMatch = {
round? : string ; date? : string ; time? : string ;
team1 : string ; team2 : string ; score? : RawScore ;
goals1? : RawGoal [ ] ; goals2? : RawGoal [ ] ;
group? : string ; ground? : string ;
}
type RawData = { matches : RawMatch [ ] }
function parseScore ( score : RawScore | undefined ) {
if ( ! score ) return { }
if ( Array . isArray ( score ) ) return { ft : score }
return { ft : score.ft , ht : score.ht , et : score.et , p : score.p }
}
async function run() {
2026-06-14 17:01:06 +02:00
const client = postgres ( DATABASE_URL , { max : 5 } )
2026-06-14 15:36:44 +02:00
const db = drizzle ( client )
2026-06-14 18:43:43 +02:00
// Safety net — seed.ts should have created these already
2026-06-14 15:36:44 +02:00
await db . execute ( sql `
CREATE TABLE IF NOT EXISTS tournaments (
year INTEGER PRIMARY KEY,
host TEXT NOT NULL,
winner TEXT,
runner_up TEXT,
third_place TEXT,
fourth_place TEXT,
teams_count INTEGER,
matches_count INTEGER,
total_goals INTEGER,
avg_goals_per_game NUMERIC(4,2)
);
CREATE TABLE IF NOT EXISTS teams (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
iso2 TEXT,
fifa_code TEXT,
continent TEXT,
confederation TEXT
);
CREATE TABLE IF NOT EXISTS stadiums (
id SERIAL PRIMARY KEY,
tournament_year INTEGER,
name TEXT NOT NULL,
city TEXT,
country_code TEXT,
capacity INTEGER,
timezone TEXT,
coordinates TEXT
);
CREATE TABLE IF NOT EXISTS matches (
id SERIAL PRIMARY KEY,
tournament_year INTEGER NOT NULL,
round TEXT NOT NULL,
group_name TEXT,
date DATE,
time_local TEXT,
stadium_id INTEGER,
team1_id INTEGER NOT NULL,
team2_id INTEGER NOT NULL,
score_ft_home INTEGER,
score_ft_away INTEGER,
score_ht_home INTEGER,
score_ht_away INTEGER,
score_et_home INTEGER,
score_et_away INTEGER,
score_p_home INTEGER,
score_p_away INTEGER,
is_quali_playoff BOOLEAN DEFAULT false
);
2026-06-14 18:43:43 +02:00
CREATE UNIQUE INDEX IF NOT EXISTS matches_unique
ON matches (tournament_year, team1_id, team2_id, date, is_quali_playoff);
2026-06-14 15:36:44 +02:00
CREATE TABLE IF NOT EXISTS goals (
id SERIAL PRIMARY KEY,
match_id INTEGER NOT NULL,
team_id INTEGER NOT NULL,
player_name TEXT NOT NULL,
minute INTEGER,
minute_offset INTEGER DEFAULT 0,
is_penalty BOOLEAN DEFAULT false,
is_own_goal BOOLEAN DEFAULT false
);
CREATE TABLE IF NOT EXISTS group_standings (
tournament_year INTEGER NOT NULL,
group_name TEXT NOT NULL,
team_id INTEGER NOT NULL,
pos INTEGER,
played INTEGER DEFAULT 0,
won INTEGER DEFAULT 0,
drawn INTEGER DEFAULT 0,
lost INTEGER DEFAULT 0,
goals_for INTEGER DEFAULT 0,
goals_against INTEGER DEFAULT 0,
goal_diff INTEGER DEFAULT 0,
pts INTEGER DEFAULT 0,
PRIMARY KEY (tournament_year, group_name, team_id)
);
CREATE TABLE IF NOT EXISTS squads (
id SERIAL PRIMARY KEY,
tournament_year INTEGER NOT NULL,
team_id INTEGER NOT NULL,
player_name TEXT NOT NULL,
shirt_number INTEGER,
position TEXT,
date_of_birth DATE
);
2026-06-14 18:43:43 +02:00
CREATE UNIQUE INDEX IF NOT EXISTS squads_unique
ON squads (tournament_year, team_id, shirt_number);
2026-06-14 15:36:44 +02:00
` )
const teamCache = new Map < string , number > ( )
2026-06-14 19:21:38 +02:00
async function upsertTeam ( rawName : string , extra ? : { iso2? : string | null ; fifaCode? : string ; continent? : string ; confederation? : string } ) {
2026-06-14 18:43:43 +02:00
if ( teamCache . has ( rawName ) ) return teamCache . get ( rawName ) !
2026-06-14 19:21:38 +02:00
const iso2 = ( extra && 'iso2' in extra ) ? extra.iso2 : getIso ( rawName )
2026-06-14 15:36:44 +02:00
const [ row ] = await db . execute ( sql `
INSERT INTO teams (name, iso2, fifa_code, continent, confederation)
2026-06-14 18:43:43 +02:00
VALUES ( ${ rawName } , ${ iso2 ? ? null } , ${ extra ? . fifaCode ? ? null } , ${ extra ? . continent ? ? null } , ${ extra ? . confederation ? ? null } )
2026-06-14 15:36:44 +02:00
ON CONFLICT (name) DO UPDATE SET
2026-06-14 18:43:43 +02:00
iso2 = COALESCE(EXCLUDED.iso2, teams.iso2),
fifa_code = COALESCE(EXCLUDED.fifa_code, teams.fifa_code),
continent = COALESCE(EXCLUDED.continent, teams.continent),
2026-06-14 15:36:44 +02:00
confederation = COALESCE(EXCLUDED.confederation, teams.confederation)
RETURNING id
` )
const id = ( row as { id : number } ) . id
2026-06-14 18:43:43 +02:00
teamCache . set ( rawName , id )
2026-06-14 15:36:44 +02:00
return id
}
async function upsertMatch (
year : number , round : string , group : string | null , dateStr : string | null ,
timeStr : string | null , team1Id : number , team2Id : number , score : ReturnType < typeof parseScore > ,
isQuali : boolean
) {
const rows = await db . execute ( sql `
INSERT INTO matches (tournament_year, round, group_name, date, time_local, team1_id, team2_id,
score_ft_home, score_ft_away, score_ht_home, score_ht_away,
score_et_home, score_et_away, score_p_home, score_p_away, is_quali_playoff)
VALUES (
${ year } , ${ round } , ${ group } , ${ dateStr ? ? null } , ${ timeStr ? ? null } ,
${ team1Id } , ${ team2Id } ,
${ score . ft ? . [ 0 ] ? ? null } , ${ score . ft ? . [ 1 ] ? ? null } ,
${ score . ht ? . [ 0 ] ? ? null } , ${ score . ht ? . [ 1 ] ? ? null } ,
${ score . et ? . [ 0 ] ? ? null } , ${ score . et ? . [ 1 ] ? ? null } ,
${ score . p ? . [ 0 ] ? ? null } , ${ score . p ? . [ 1 ] ? ? null } ,
${ isQuali }
)
ON CONFLICT (tournament_year, team1_id, team2_id, date, is_quali_playoff) DO UPDATE SET
2026-06-14 18:43:43 +02:00
round = EXCLUDED.round,
time_local = COALESCE(EXCLUDED.time_local, matches.time_local),
2026-06-14 15:36:44 +02:00
score_ft_home = COALESCE(EXCLUDED.score_ft_home, matches.score_ft_home),
score_ft_away = COALESCE(EXCLUDED.score_ft_away, matches.score_ft_away),
score_ht_home = COALESCE(EXCLUDED.score_ht_home, matches.score_ht_home),
score_ht_away = COALESCE(EXCLUDED.score_ht_away, matches.score_ht_away),
score_et_home = COALESCE(EXCLUDED.score_et_home, matches.score_et_home),
score_et_away = COALESCE(EXCLUDED.score_et_away, matches.score_et_away),
2026-06-14 18:43:43 +02:00
score_p_home = COALESCE(EXCLUDED.score_p_home, matches.score_p_home),
score_p_away = COALESCE(EXCLUDED.score_p_away, matches.score_p_away)
2026-06-14 15:36:44 +02:00
RETURNING id
` )
return ( rows [ 0 ] as { id : number } ) . id
}
async function syncGoals ( matchId : number , teamId : number , rawGoals : RawGoal [ ] , isOwnGoalTeamId : number ) {
for ( const g of rawGoals ) {
if ( ! g . name ) continue
const minute = g . minute != null ? parseInt ( String ( g . minute ) ) : null
const scoringTeamId = g . owngoal ? isOwnGoalTeamId : teamId
await db . execute ( sql `
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
VALUES ( ${ matchId } , ${ scoringTeamId } , ${ g . name } , ${ isNaN ( minute ! ) ? null : minute } ,
${ g . offset ? ? 0 } , ${ g . penalty ? ? false } , ${ g . owngoal ? ? false } )
` )
}
}
2026-06-14 18:43:43 +02:00
console . log ( '\nSyncing 2026...' )
2026-06-14 15:36:44 +02:00
2026-06-14 18:43:43 +02:00
// Upsert 2026 tournament row (no winner yet)
await db . execute ( sql `
INSERT INTO tournaments (year, host)
VALUES (2026, 'USA / Canada / Mexico')
ON CONFLICT (year) DO NOTHING
` )
2026-06-14 15:36:44 +02:00
2026-06-14 18:43:43 +02:00
// Teams enrichment
const teamsData = await fetchJson ( ` ${ BASE } /2026/worldcup.teams.json ` ) as Record < string , unknown > [ ] | null
if ( teamsData && Array . isArray ( teamsData ) ) {
for ( const t of teamsData ) {
const name = ( t . name ? ? t . name_normalised ) as string
await upsertTeam ( name , {
iso2 : TEAM_ISO [ name ] ? ? getIso ( name ) ,
fifaCode : t.fifa_code as string ,
continent : t.continent as string ,
confederation : t.confed as string ,
} )
2026-06-14 15:36:44 +02:00
}
2026-06-14 18:43:43 +02:00
}
2026-06-14 15:36:44 +02:00
2026-06-14 18:43:43 +02:00
// Stadiums
const stadiumsData = await fetchJson ( ` ${ BASE } /2026/worldcup.stadiums.json ` ) as { stadiums? : Record < string , unknown > [ ] } | null
if ( stadiumsData ? . stadiums ) {
for ( const s of stadiumsData . stadiums ) {
await db . execute ( sql `
INSERT INTO stadiums (tournament_year, name, city, country_code, capacity, timezone, coordinates)
VALUES (2026, ${ s . name as string } , ${ s . city as string } , ${ ( s . cc as string | undefined ) ? ? null } ,
${ ( s . capacity as number | undefined ) ? ? null } , ${ ( s . timezone as string | undefined ) ? ? null } , ${ ( s . coords as string | undefined ) ? ? null } )
ON CONFLICT DO NOTHING
` )
2026-06-14 15:36:44 +02:00
}
2026-06-14 18:43:43 +02:00
}
2026-06-14 15:36:44 +02:00
2026-06-14 18:43:43 +02:00
// Main matches
const mainData = await fetchJson ( ` ${ BASE } /2026/worldcup.json ` ) as RawData | null
let matchCount = 0 , goalCount = 0
if ( mainData ? . matches ) {
2026-06-14 15:36:44 +02:00
for ( const m of mainData . matches ) {
const t1Id = await upsertTeam ( m . team1 )
const t2Id = await upsertTeam ( m . team2 )
const score = parseScore ( m . score )
2026-06-14 18:43:43 +02:00
const matchId = await upsertMatch ( 2026 , m . round ? ? 'Unknown' , m . group ? ? null , m . date ? ? null , m . time ? ? null , t1Id , t2Id , score , false )
2026-06-14 17:38:35 +02:00
if ( m . goals1 ? . length || m . goals2 ? . length ) {
await db . execute ( sql ` DELETE FROM goals WHERE match_id = ${ matchId } ` )
if ( m . goals1 ? . length ) await syncGoals ( matchId , t1Id , m . goals1 , t2Id )
if ( m . goals2 ? . length ) await syncGoals ( matchId , t2Id , m . goals2 , t1Id )
}
2026-06-14 15:36:44 +02:00
matchCount ++
goalCount += ( m . goals1 ? . length ? ? 0 ) + ( m . goals2 ? . length ? ? 0 )
}
2026-06-14 18:43:43 +02:00
}
2026-06-14 15:36:44 +02:00
2026-06-14 18:43:43 +02:00
// Squads
const squadsData = await fetchJson ( ` ${ BASE } /2026/worldcup.squads.json ` ) as Record < string , unknown > [ ] | null
if ( squadsData && Array . isArray ( squadsData ) ) {
for ( const sq of squadsData ) {
const teamId = await upsertTeam ( sq . name as string )
for ( const p of ( sq . players as Record < string , unknown > [ ] ) ) {
await db . execute ( sql `
INSERT INTO squads (tournament_year, team_id, player_name, shirt_number, position, date_of_birth)
VALUES (2026, ${ teamId } , ${ p . name as string } , ${ p . number as number ? ? null } ,
${ p . pos as string ? ? null } , ${ p . date_of_birth as string ? ? null } )
ON CONFLICT (tournament_year, team_id, shirt_number) DO UPDATE SET
player_name = EXCLUDED.player_name, position = EXCLUDED.position, date_of_birth = EXCLUDED.date_of_birth
` )
2026-06-14 15:36:44 +02:00
}
}
2026-06-14 18:43:43 +02:00
console . log ( ' Squads loaded for 2026' )
}
2026-06-14 15:36:44 +02:00
2026-06-14 18:43:43 +02:00
// Quali playoffs
const qualiData = await fetchJson ( ` ${ BASE } /2026/worldcup.quali_playoffs.json ` ) as RawData | null
if ( qualiData ? . matches ) {
for ( const m of qualiData . matches ) {
const t1Id = await upsertTeam ( m . team1 )
const t2Id = await upsertTeam ( m . team2 )
const score = parseScore ( m . score )
const matchId = await upsertMatch ( 2026 , m . round ? ? 'Qualifier' , null , m . date ? ? null , m . time ? ? null , t1Id , t2Id , score , true )
if ( m . goals1 ? . length ) await syncGoals ( matchId , t1Id , m . goals1 , t2Id )
if ( m . goals2 ? . length ) await syncGoals ( matchId , t2Id , m . goals2 , t1Id )
2026-06-14 15:36:44 +02:00
}
2026-06-14 18:43:43 +02:00
console . log ( ` Quali playoffs: ${ qualiData . matches . length } matches ` )
2026-06-14 15:36:44 +02:00
}
2026-06-14 18:43:43 +02:00
// Group standings from match results
2026-06-14 15:36:44 +02:00
await db . execute ( sql `
WITH match_results AS (
SELECT tournament_year, group_name, team1_id AS team_id, score_ft_home AS gf, score_ft_away AS ga
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
UNION ALL
SELECT tournament_year, group_name, team2_id, score_ft_away, score_ft_home
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
)
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
SELECT tournament_year, group_name, team_id,
2026-06-14 18:43:43 +02:00
COUNT(*)::int,
SUM(CASE WHEN gf > ga THEN 1 ELSE 0 END)::int,
2026-06-14 15:36:44 +02:00
SUM(CASE WHEN gf = ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf < ga THEN 1 ELSE 0 END)::int,
SUM(gf)::int, SUM(ga)::int, SUM(gf - ga)::int,
SUM(CASE WHEN gf > ga THEN 3 WHEN gf = ga THEN 1 ELSE 0 END)::int
FROM match_results
GROUP BY tournament_year, group_name, team_id
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
played = EXCLUDED.played, won = EXCLUDED.won, drawn = EXCLUDED.drawn,
lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for, goals_against = EXCLUDED.goals_against,
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
` )
2026-06-14 18:43:43 +02:00
// Tournament aggregates
await db . execute ( sql `
UPDATE tournaments SET
matches_count = (SELECT COUNT(*)::int FROM matches WHERE tournament_year = 2026 AND is_quali_playoff = false),
total_goals = (SELECT COALESCE(SUM(score_ft_home + score_ft_away), 0)::int FROM matches WHERE tournament_year = 2026 AND is_quali_playoff = false AND score_ft_home IS NOT NULL),
avg_goals_per_game = (
SELECT ROUND(COALESCE(SUM(score_ft_home + score_ft_away), 0)::numeric / NULLIF(COUNT(*), 0), 2)
FROM matches WHERE tournament_year = 2026 AND is_quali_playoff = false AND score_ft_home IS NOT NULL
)
WHERE year = 2026
` )
console . log ( ` ✓ ${ matchCount } matches, ${ goalCount } goals ` )
2026-06-14 15:36:44 +02:00
console . log ( '\n✅ Sync complete!' )
await client . end ( )
}
run ( ) . catch ( e = > { console . error ( e ) ; process . exit ( 1 ) } )