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>
This commit is contained in:
@@ -33,6 +33,56 @@ actor BootstrapService {
|
||||
|
||||
// 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
|
||||
@@ -86,6 +136,9 @@ actor BootstrapService {
|
||||
|
||||
/// 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)
|
||||
@@ -95,11 +148,20 @@ actor BootstrapService {
|
||||
return
|
||||
}
|
||||
|
||||
// Bootstrap in dependency order
|
||||
// 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 bootstrapTeamsAndGames(context: context)
|
||||
try await bootstrapTeams(context: context)
|
||||
try await bootstrapTeamAliases(context: context)
|
||||
try await bootstrapGames(context: context)
|
||||
|
||||
// Mark bootstrap complete
|
||||
syncState.bootstrapCompleted = true
|
||||
@@ -117,10 +179,49 @@ actor BootstrapService {
|
||||
|
||||
@MainActor
|
||||
private func bootstrapStadiums(context: ModelContext) async throws {
|
||||
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
|
||||
throw BootstrapError.bundledResourceNotFound("stadiums.json")
|
||||
// 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]
|
||||
|
||||
@@ -131,7 +232,6 @@ actor BootstrapService {
|
||||
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
|
||||
}
|
||||
|
||||
// Convert and insert
|
||||
for jsonStadium in stadiums {
|
||||
let canonical = CanonicalStadium(
|
||||
canonicalId: jsonStadium.id,
|
||||
@@ -149,7 +249,7 @@ actor BootstrapService {
|
||||
)
|
||||
context.insert(canonical)
|
||||
|
||||
// Create stadium alias for the current name (lowercase for matching)
|
||||
// Legacy format: create stadium alias for the current name
|
||||
let alias = StadiumAlias(
|
||||
aliasName: jsonStadium.name,
|
||||
stadiumCanonicalId: jsonStadium.id,
|
||||
@@ -161,6 +261,51 @@ actor BootstrapService {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -205,11 +350,101 @@ actor BootstrapService {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrapTeamsAndGames(context: ModelContext) async throws {
|
||||
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
|
||||
throw BootstrapError.bundledResourceNotFound("games.json")
|
||||
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]
|
||||
|
||||
@@ -220,7 +455,7 @@ actor BootstrapService {
|
||||
throw BootstrapError.jsonDecodingFailed("games.json", error)
|
||||
}
|
||||
|
||||
// Build stadium lookup by venue name for game → stadium matching
|
||||
// Build stadium lookup for legacy venue matching
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
|
||||
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
|
||||
var stadiumsByVenue: [String: CanonicalStadium] = [:]
|
||||
@@ -228,65 +463,72 @@ actor BootstrapService {
|
||||
stadiumsByVenue[stadium.name.lowercased()] = stadium
|
||||
}
|
||||
|
||||
// Extract unique teams from games and create CanonicalTeam entries
|
||||
var teamsCreated: [String: CanonicalTeam] = [:]
|
||||
// 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()
|
||||
|
||||
// Process home team
|
||||
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
|
||||
if teamsCreated[homeTeamCanonicalId] == nil {
|
||||
let stadiumCanonicalId = findStadiumCanonicalId(
|
||||
venue: jsonGame.venue,
|
||||
sport: sport,
|
||||
stadiumsByVenue: stadiumsByVenue
|
||||
)
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
|
||||
// Process away team
|
||||
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
|
||||
if teamsCreated[awayTeamCanonicalId] == nil {
|
||||
// Away teams might not have a known stadium yet
|
||||
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" // Will be filled in when they're home team
|
||||
)
|
||||
context.insert(team)
|
||||
teamsCreated[awayTeamCanonicalId] = team
|
||||
}
|
||||
|
||||
// Deduplicate games by ID
|
||||
// Deduplicate games
|
||||
guard !seenGameIds.contains(jsonGame.id) else { continue }
|
||||
seenGameIds.insert(jsonGame.id)
|
||||
|
||||
// Create game
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user