Files
Sportstime/SportsTime/Core/Services/BootstrapService.swift
Trey t 7efcea7bd4 Add canonical ID pipeline and fix UUID consistency for CloudKit sync
- Add local canonicalization pipeline (stadiums, teams, games) that generates
  deterministic canonical IDs before CloudKit upload
- Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs
  instead of random UUIDs from CloudKit records
- Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve
  canonical ID relationships during sync
- Add canonical ID field keys to CKModels for reading from CloudKit records
- Bundle canonical JSON files (stadiums_canonical, teams_canonical,
  games_canonical, stadium_aliases) for consistent bootstrap data
- Update BootstrapService to prefer canonical format files over legacy format

This ensures all entities use consistent deterministic UUIDs derived from
their canonical IDs, preventing duplicate records when syncing CloudKit
data with bootstrapped local data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 10:30:09 -06:00

755 lines
28 KiB
Swift

//
// BootstrapService.swift
// SportsTime
//
// Bootstraps canonical data from bundled JSON files into SwiftData.
// Runs once on first launch, then relies on CloudKit for updates.
//
import Foundation
import SwiftData
import CryptoKit
actor BootstrapService {
// MARK: - Errors
enum BootstrapError: Error, LocalizedError {
case bundledResourceNotFound(String)
case jsonDecodingFailed(String, Error)
case saveFailed(Error)
var errorDescription: String? {
switch self {
case .bundledResourceNotFound(let resource):
return "Bundled resource not found: \(resource)"
case .jsonDecodingFailed(let resource, let error):
return "Failed to decode \(resource): \(error.localizedDescription)"
case .saveFailed(let error):
return "Failed to save bootstrap data: \(error.localizedDescription)"
}
}
}
// MARK: - JSON Models (match bundled JSON structure)
// MARK: - Canonical JSON Models (from canonicalization pipeline)
private struct JSONCanonicalStadium: Codable {
let canonical_id: String
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let sport: String
let primary_team_abbrevs: [String]
let year_opened: Int?
}
private struct JSONCanonicalTeam: Codable {
let canonical_id: String
let name: String
let abbreviation: String
let sport: String
let city: String
let stadium_canonical_id: String
let conference_id: String?
let division_id: String?
let primary_color: String?
let secondary_color: String?
}
private struct JSONCanonicalGame: Codable {
let canonical_id: String
let sport: String
let season: String
let date: String
let time: String?
let home_team_canonical_id: String
let away_team_canonical_id: String
let stadium_canonical_id: String
let is_playoff: Bool
let broadcast: String?
}
private struct JSONStadiumAlias: Codable {
let alias_name: String
let stadium_canonical_id: String
let valid_from: String?
let valid_until: String?
}
// MARK: - Legacy JSON Models (for backward compatibility)
private struct JSONStadium: Codable {
let id: String
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let sport: String
let team_abbrevs: [String]
let source: String
let year_opened: Int?
}
private struct JSONGame: Codable {
let id: String
let sport: String
let season: String
let date: String
let time: String?
let home_team: String
let away_team: String
let home_team_abbrev: String
let away_team_abbrev: String
let venue: String
let source: String
let is_playoff: Bool
let broadcast: String?
}
private struct JSONLeagueStructure: Codable {
let id: String
let sport: String
let type: String // "conference", "division", "league"
let name: String
let abbreviation: String?
let parent_id: String?
let display_order: Int
}
private struct JSONTeamAlias: Codable {
let id: String
let team_canonical_id: String
let alias_type: String // "abbreviation", "name", "city"
let alias_value: String
let valid_from: String?
let valid_until: String?
}
// MARK: - Public Methods
/// Bootstrap canonical data from bundled JSON if not already done.
/// This is the main entry point called at app launch.
///
/// Prefers new canonical format files (*_canonical.json) from the pipeline,
/// falls back to legacy format for backward compatibility.
@MainActor
func bootstrapIfNeeded(context: ModelContext) async throws {
let syncState = SyncState.current(in: context)
// Skip if already bootstrapped
guard !syncState.bootstrapCompleted else {
return
}
// Bootstrap in dependency order:
// 1. Stadiums (no dependencies)
// 2. Stadium aliases (depends on stadiums)
// 3. League structure (no dependencies)
// 4. Teams (depends on stadiums)
// 5. Team aliases (depends on teams)
// 6. Games (depends on teams + stadiums)
try await bootstrapStadiums(context: context)
try await bootstrapStadiumAliases(context: context)
try await bootstrapLeagueStructure(context: context)
try await bootstrapTeams(context: context)
try await bootstrapTeamAliases(context: context)
try await bootstrapGames(context: context)
// Mark bootstrap complete
syncState.bootstrapCompleted = true
syncState.bundledSchemaVersion = SchemaVersion.current
syncState.lastBootstrap = Date()
do {
try context.save()
} catch {
throw BootstrapError.saveFailed(error)
}
}
// MARK: - Bootstrap Steps
@MainActor
private func bootstrapStadiums(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy
if let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") {
try await bootstrapStadiumsCanonical(url: url, context: context)
} else if let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") {
try await bootstrapStadiumsLegacy(url: url, context: context)
} else {
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json or stadiums.json")
}
}
@MainActor
private func bootstrapStadiumsCanonical(url: URL, context: ModelContext) async throws {
let data: Data
let stadiums: [JSONCanonicalStadium]
do {
data = try Data(contentsOf: url)
stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error)
}
for jsonStadium in stadiums {
let canonical = CanonicalStadium(
canonicalId: jsonStadium.canonical_id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums,
source: .bundled,
name: jsonStadium.name,
city: jsonStadium.city,
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state,
latitude: jsonStadium.latitude,
longitude: jsonStadium.longitude,
capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened,
sport: jsonStadium.sport
)
context.insert(canonical)
}
}
@MainActor
private func bootstrapStadiumsLegacy(url: URL, context: ModelContext) async throws {
let data: Data
let stadiums: [JSONStadium]
do {
data = try Data(contentsOf: url)
stadiums = try JSONDecoder().decode([JSONStadium].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
}
for jsonStadium in stadiums {
let canonical = CanonicalStadium(
canonicalId: jsonStadium.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums,
source: .bundled,
name: jsonStadium.name,
city: jsonStadium.city,
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state,
latitude: jsonStadium.latitude,
longitude: jsonStadium.longitude,
capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened,
sport: jsonStadium.sport
)
context.insert(canonical)
// Legacy format: create stadium alias for the current name
let alias = StadiumAlias(
aliasName: jsonStadium.name,
stadiumCanonicalId: jsonStadium.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums
)
alias.stadium = canonical
context.insert(alias)
}
}
@MainActor
private func bootstrapStadiumAliases(context: ModelContext) async throws {
// Stadium aliases are loaded from stadium_aliases.json (from canonical pipeline)
guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else {
// Aliases are optional - legacy format creates them inline
return
}
let data: Data
let aliases: [JSONStadiumAlias]
do {
data = try Data(contentsOf: url)
aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error)
}
// Build stadium lookup
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
let stadiums = (try? context.fetch(stadiumDescriptor)) ?? []
let stadiumsByCanonicalId = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.canonicalId, $0) })
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
for jsonAlias in aliases {
let alias = StadiumAlias(
aliasName: jsonAlias.alias_name,
stadiumCanonicalId: jsonAlias.stadium_canonical_id,
validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) },
validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) },
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums
)
// Link to stadium if found
if let stadium = stadiumsByCanonicalId[jsonAlias.stadium_canonical_id] {
alias.stadium = stadium
}
context.insert(alias)
}
}
@MainActor
private func bootstrapLeagueStructure(context: ModelContext) async throws {
// Load league structure if file exists
guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else {
// League structure is optional for MVP - create basic structure from known sports
createDefaultLeagueStructure(context: context)
return
}
let data: Data
let structures: [JSONLeagueStructure]
do {
data = try Data(contentsOf: url)
structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
}
for structure in structures {
let structureType: LeagueStructureType
switch structure.type.lowercased() {
case "conference": structureType = .conference
case "division": structureType = .division
case "league": structureType = .league
default: structureType = .division
}
let model = LeagueStructureModel(
id: structure.id,
sport: structure.sport,
structureType: structureType,
name: structure.name,
abbreviation: structure.abbreviation,
parentId: structure.parent_id,
displayOrder: structure.display_order,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.leagueStructure
)
context.insert(model)
}
}
@MainActor
private func bootstrapTeams(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy extraction from games
if let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") {
try await bootstrapTeamsCanonical(url: url, context: context)
} else {
// Legacy: Teams will be extracted from games during bootstrapGames
// This path is deprecated but maintained for backward compatibility
}
}
@MainActor
private func bootstrapTeamsCanonical(url: URL, context: ModelContext) async throws {
let data: Data
let teams: [JSONCanonicalTeam]
do {
data = try Data(contentsOf: url)
teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error)
}
for jsonTeam in teams {
let team = CanonicalTeam(
canonicalId: jsonTeam.canonical_id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: jsonTeam.name,
abbreviation: jsonTeam.abbreviation,
sport: jsonTeam.sport,
city: jsonTeam.city,
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
conferenceId: jsonTeam.conference_id,
divisionId: jsonTeam.division_id
)
context.insert(team)
}
}
@MainActor
private func bootstrapGames(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy
if let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") {
try await bootstrapGamesCanonical(url: url, context: context)
} else if let url = Bundle.main.url(forResource: "games", withExtension: "json") {
try await bootstrapGamesLegacy(url: url, context: context)
} else {
throw BootstrapError.bundledResourceNotFound("games_canonical.json or games.json")
}
}
@MainActor
private func bootstrapGamesCanonical(url: URL, context: ModelContext) async throws {
let data: Data
let games: [JSONCanonicalGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
}
var seenGameIds = Set<String>()
for jsonGame in games {
// Deduplicate
guard !seenGameIds.contains(jsonGame.canonical_id) else { continue }
seenGameIds.insert(jsonGame.canonical_id)
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
continue
}
let game = CanonicalGame(
canonicalId: jsonGame.canonical_id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
stadiumCanonicalId: jsonGame.stadium_canonical_id,
dateTime: dateTime,
sport: jsonGame.sport,
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast
)
context.insert(game)
}
}
@MainActor
private func bootstrapGamesLegacy(url: URL, context: ModelContext) async throws {
let data: Data
let games: [JSONGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONGame].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("games.json", error)
}
// Build stadium lookup for legacy venue matching
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
var stadiumsByVenue: [String: CanonicalStadium] = [:]
for stadium in canonicalStadiums {
stadiumsByVenue[stadium.name.lowercased()] = stadium
}
// Check if teams already exist (from teams_canonical.json)
let teamDescriptor = FetchDescriptor<CanonicalTeam>()
let existingTeams = (try? context.fetch(teamDescriptor)) ?? []
var teamsCreated: [String: CanonicalTeam] = Dictionary(
uniqueKeysWithValues: existingTeams.map { ($0.canonicalId, $0) }
)
let teamsAlreadyLoaded = !existingTeams.isEmpty
var seenGameIds = Set<String>()
for jsonGame in games {
let sport = jsonGame.sport.uppercased()
// Legacy team extraction (only if teams not already loaded)
if !teamsAlreadyLoaded {
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
if teamsCreated[homeTeamCanonicalId] == nil {
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
let team = CanonicalTeam(
canonicalId: homeTeamCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: extractTeamName(from: jsonGame.home_team),
abbreviation: jsonGame.home_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.home_team),
stadiumCanonicalId: stadiumCanonicalId
)
context.insert(team)
teamsCreated[homeTeamCanonicalId] = team
}
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
if teamsCreated[awayTeamCanonicalId] == nil {
let team = CanonicalTeam(
canonicalId: awayTeamCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: extractTeamName(from: jsonGame.away_team),
abbreviation: jsonGame.away_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.away_team),
stadiumCanonicalId: "unknown"
)
context.insert(team)
teamsCreated[awayTeamCanonicalId] = team
}
}
// Deduplicate games
guard !seenGameIds.contains(jsonGame.id) else { continue }
seenGameIds.insert(jsonGame.id)
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
continue
}
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
let game = CanonicalGame(
canonicalId: jsonGame.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId,
dateTime: dateTime,
sport: sport,
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast
)
context.insert(game)
}
}
@MainActor
private func bootstrapTeamAliases(context: ModelContext) async throws {
// Team aliases are optional - load if file exists
guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else {
return
}
let data: Data
let aliases: [JSONTeamAlias]
do {
data = try Data(contentsOf: url)
aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
}
let dateFormatter = ISO8601DateFormatter()
for jsonAlias in aliases {
let aliasType: TeamAliasType
switch jsonAlias.alias_type.lowercased() {
case "abbreviation": aliasType = .abbreviation
case "name": aliasType = .name
case "city": aliasType = .city
default: aliasType = .name
}
let alias = TeamAlias(
id: jsonAlias.id,
teamCanonicalId: jsonAlias.team_canonical_id,
aliasType: aliasType,
aliasValue: jsonAlias.alias_value,
validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) },
validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) },
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games
)
context.insert(alias)
}
}
// MARK: - Helpers
@MainActor
private func createDefaultLeagueStructure(context: ModelContext) {
// Create minimal league structure for supported sports
let timestamp = BundledDataTimestamp.leagueStructure
// MLB
context.insert(LeagueStructureModel(
id: "mlb_league",
sport: "MLB",
structureType: .league,
name: "Major League Baseball",
abbreviation: "MLB",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
// NBA
context.insert(LeagueStructureModel(
id: "nba_league",
sport: "NBA",
structureType: .league,
name: "National Basketball Association",
abbreviation: "NBA",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
// NHL
context.insert(LeagueStructureModel(
id: "nhl_league",
sport: "NHL",
structureType: .league,
name: "National Hockey League",
abbreviation: "NHL",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
}
// Venue name aliases for stadiums that changed names
private static let venueAliases: [String: String] = [
"daikin park": "minute maid park",
"rate field": "guaranteed rate field",
"george m. steinbrenner field": "tropicana field",
"loandepot park": "loandepot park",
]
nonisolated private func findStadiumCanonicalId(
venue: String,
sport: String,
stadiumsByVenue: [String: CanonicalStadium]
) -> String {
var venueLower = venue.lowercased()
// Check for known aliases
if let aliasedName = Self.venueAliases[venueLower] {
venueLower = aliasedName
}
// Try exact match
if let stadium = stadiumsByVenue[venueLower] {
return stadium.canonicalId
}
// Try partial match
for (name, stadium) in stadiumsByVenue {
if name.contains(venueLower) || venueLower.contains(name) {
return stadium.canonicalId
}
}
// Generate deterministic ID for unknown venues
return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))"
}
nonisolated private func parseDateTime(date: String, time: String) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
// Parse date
formatter.dateFormat = "yyyy-MM-dd"
guard let dateOnly = formatter.date(from: date) else { return nil }
// Parse time (e.g., "7:30p", "10:00p", "1:05p")
var hour = 12
var minute = 0
let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "")
let isPM = cleanTime.contains("p")
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
let components = timeWithoutAMPM.split(separator: ":")
if !components.isEmpty, let h = Int(components[0]) {
hour = h
if isPM && hour != 12 {
hour += 12
} else if !isPM && hour == 12 {
hour = 0
}
}
if components.count > 1, let m = Int(components[1]) {
minute = m
}
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
}
nonisolated private func extractTeamName(from fullName: String) -> String {
// "Boston Celtics" -> "Celtics"
let parts = fullName.split(separator: " ")
if parts.count > 1 {
return parts.dropFirst().joined(separator: " ")
}
return fullName
}
nonisolated private func extractCity(from fullName: String) -> String {
// "Boston Celtics" -> "Boston"
// "New York Knicks" -> "New York"
let knownCities = [
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
"St. Louis", "St Louis"
]
for city in knownCities {
if fullName.hasPrefix(city) {
return city
}
}
// Default: first word
return String(fullName.split(separator: " ").first ?? Substring(fullName))
}
nonisolated private func stateFromCity(_ city: String) -> String {
let cityToState: [String: String] = [
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",
"Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO",
"Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA",
"Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN",
"New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL",
"Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA",
"San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON",
"Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA",
"Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO",
"Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA",
"Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT"
]
return cityToState[city] ?? ""
}
}