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:
Trey t
2026-01-09 10:30:09 -06:00
parent 1ee47df53e
commit 7efcea7bd4
31 changed files with 128868 additions and 282 deletions

View File

@@ -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,