Files
Sportstime/SportsTime/Core/Services/CanonicalSyncService.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

687 lines
23 KiB
Swift

//
// CanonicalSyncService.swift
// SportsTime
//
// Orchestrates syncing canonical data from CloudKit into SwiftData.
// Uses date-based delta sync for public database efficiency.
//
import Foundation
import SwiftData
import CloudKit
actor CanonicalSyncService {
// MARK: - Errors
enum SyncError: Error, LocalizedError {
case cloudKitUnavailable
case syncAlreadyInProgress
case saveFailed(Error)
case schemaVersionTooNew(Int)
var errorDescription: String? {
switch self {
case .cloudKitUnavailable:
return "CloudKit is not available. Check your internet connection and iCloud settings."
case .syncAlreadyInProgress:
return "A sync operation is already in progress."
case .saveFailed(let error):
return "Failed to save synced data: \(error.localizedDescription)"
case .schemaVersionTooNew(let version):
return "Data requires app version supporting schema \(version). Please update the app."
}
}
}
// MARK: - Sync Result
struct SyncResult {
let stadiumsUpdated: Int
let teamsUpdated: Int
let gamesUpdated: Int
let leagueStructuresUpdated: Int
let teamAliasesUpdated: Int
let stadiumAliasesUpdated: Int
let skippedIncompatible: Int
let skippedOlder: Int
let duration: TimeInterval
var totalUpdated: Int {
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
}
var isEmpty: Bool { totalUpdated == 0 }
}
// MARK: - Properties
private let cloudKitService: CloudKitService
// MARK: - Initialization
init(cloudKitService: CloudKitService = .shared) {
self.cloudKitService = cloudKitService
}
// MARK: - Public Sync Methods
/// Perform a full sync of all canonical data types.
/// This is the main entry point for background sync.
@MainActor
func syncAll(context: ModelContext) async throws -> SyncResult {
let startTime = Date()
let syncState = SyncState.current(in: context)
// Prevent concurrent syncs
guard !syncState.syncInProgress else {
throw SyncError.syncAlreadyInProgress
}
// Check if sync is enabled
guard syncState.syncEnabled else {
return SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
skippedIncompatible: 0, skippedOlder: 0,
duration: 0
)
}
// Check CloudKit availability
guard await cloudKitService.isAvailable() else {
throw SyncError.cloudKitUnavailable
}
// Mark sync in progress
syncState.syncInProgress = true
syncState.lastSyncAttempt = Date()
var totalStadiums = 0
var totalTeams = 0
var totalGames = 0
var totalLeagueStructures = 0
var totalTeamAliases = 0
var totalStadiumAliases = 0
var totalSkippedIncompatible = 0
var totalSkippedOlder = 0
do {
// Sync in dependency order
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
context: context,
since: syncState.lastSuccessfulSync
)
totalStadiums = stadiums
totalSkippedIncompatible += skipIncompat1
totalSkippedOlder += skipOlder1
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
context: context,
since: syncState.lastSuccessfulSync
)
totalLeagueStructures = leagueStructures
totalSkippedIncompatible += skipIncompat2
totalSkippedOlder += skipOlder2
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
context: context,
since: syncState.lastSuccessfulSync
)
totalTeams = teams
totalSkippedIncompatible += skipIncompat3
totalSkippedOlder += skipOlder3
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
context: context,
since: syncState.lastSuccessfulSync
)
totalTeamAliases = teamAliases
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
context: context,
since: syncState.lastSuccessfulSync
)
totalStadiumAliases = stadiumAliases
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
let (games, skipIncompat6, skipOlder6) = try await syncGames(
context: context,
since: syncState.lastSuccessfulSync
)
totalGames = games
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
// Mark sync successful
syncState.syncInProgress = false
syncState.lastSuccessfulSync = Date()
syncState.lastSyncError = nil
syncState.consecutiveFailures = 0
try context.save()
} catch {
// Mark sync failed
syncState.syncInProgress = false
syncState.lastSyncError = error.localizedDescription
syncState.consecutiveFailures += 1
// Pause sync after too many failures
if syncState.consecutiveFailures >= 5 {
syncState.syncEnabled = false
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
}
try? context.save()
throw error
}
return SyncResult(
stadiumsUpdated: totalStadiums,
teamsUpdated: totalTeams,
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
stadiumAliasesUpdated: totalStadiumAliases,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime)
)
}
/// Re-enable sync after it was paused due to failures.
@MainActor
func resumeSync(context: ModelContext) {
let syncState = SyncState.current(in: context)
syncState.syncEnabled = true
syncState.syncPausedReason = nil
syncState.consecutiveFailures = 0
try? context.save()
}
// MARK: - Individual Sync Methods
@MainActor
private func syncStadiums(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Use sync method that returns canonical IDs directly from CloudKit
let syncStadiums = try await cloudKitService.fetchStadiumsForSync()
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for syncStadium in syncStadiums {
// Use canonical ID directly from CloudKit - no UUID-based generation!
let result = try mergeStadium(
syncStadium.stadium,
canonicalId: syncStadium.canonicalId,
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncTeams(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Use sync method that returns canonical IDs directly from CloudKit
var allSyncTeams: [CloudKitService.SyncTeam] = []
for sport in Sport.allCases {
let syncTeams = try await cloudKitService.fetchTeamsForSync(for: sport)
allSyncTeams.append(contentsOf: syncTeams)
}
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for syncTeam in allSyncTeams {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeTeam(
syncTeam.team,
canonicalId: syncTeam.canonicalId,
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncGames(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Use sync method that returns canonical IDs directly from CloudKit
let startDate = lastSync ?? Date()
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
let syncGames = try await cloudKitService.fetchGamesForSync(
sports: Set(Sport.allCases),
startDate: startDate,
endDate: endDate
)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for syncGame in syncGames {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeGame(
syncGame.game,
canonicalId: syncGame.canonicalId,
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
stadiumCanonicalId: syncGame.stadiumCanonicalId,
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncLeagueStructure(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteStructure in remoteStructures {
let result = try mergeLeagueStructure(remoteStructure, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncTeamAliases(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeTeamAlias(remoteAlias, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncStadiumAliases(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeStadiumAlias(remoteAlias, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
// MARK: - Merge Logic
private enum MergeResult {
case applied
case skippedIncompatible
case skippedOlder
}
@MainActor
private func mergeStadium(
_ remote: Stadium,
canonicalId: String,
context: ModelContext
) throws -> MergeResult {
// Look up existing
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// Preserve user fields
let savedNickname = existing.userNickname
let savedNotes = existing.userNotes
let savedFavorite = existing.isFavorite
// Update system fields
existing.name = remote.name
existing.city = remote.city
existing.state = remote.state
existing.latitude = remote.latitude
existing.longitude = remote.longitude
existing.capacity = remote.capacity
existing.yearOpened = remote.yearOpened
existing.imageURL = remote.imageURL?.absoluteString
existing.sport = remote.sport.rawValue
existing.source = .cloudKit
existing.lastModified = Date()
// Restore user fields
existing.userNickname = savedNickname
existing.userNotes = savedNotes
existing.isFavorite = savedFavorite
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalStadium(
canonicalId: canonicalId,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
name: remote.name,
city: remote.city,
state: remote.state,
latitude: remote.latitude,
longitude: remote.longitude,
capacity: remote.capacity,
yearOpened: remote.yearOpened,
imageURL: remote.imageURL?.absoluteString,
sport: remote.sport.rawValue
)
context.insert(canonical)
return .applied
}
}
@MainActor
private func mergeTeam(
_ remote: Team,
canonicalId: String,
stadiumCanonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
if let existing = existing {
// Preserve user fields
let savedNickname = existing.userNickname
let savedFavorite = existing.isFavorite
// Update system fields
existing.name = remote.name
existing.abbreviation = remote.abbreviation
existing.sport = remote.sport.rawValue
existing.city = remote.city
existing.stadiumCanonicalId = stadiumCanonicalId
existing.logoURL = remote.logoURL?.absoluteString
existing.primaryColor = remote.primaryColor
existing.secondaryColor = remote.secondaryColor
existing.source = .cloudKit
existing.lastModified = Date()
// Restore user fields
existing.userNickname = savedNickname
existing.isFavorite = savedFavorite
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalTeam(
canonicalId: canonicalId,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
name: remote.name,
abbreviation: remote.abbreviation,
sport: remote.sport.rawValue,
city: remote.city,
stadiumCanonicalId: stadiumCanonicalId,
logoURL: remote.logoURL?.absoluteString,
primaryColor: remote.primaryColor,
secondaryColor: remote.secondaryColor
)
context.insert(canonical)
return .applied
}
}
@MainActor
private func mergeGame(
_ remote: Game,
canonicalId: String,
homeTeamCanonicalId: String,
awayTeamCanonicalId: String,
stadiumCanonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
if let existing = existing {
// Preserve user fields
let savedAttending = existing.userAttending
let savedNotes = existing.userNotes
// Update system fields
existing.homeTeamCanonicalId = homeTeamCanonicalId
existing.awayTeamCanonicalId = awayTeamCanonicalId
existing.stadiumCanonicalId = stadiumCanonicalId
existing.dateTime = remote.dateTime
existing.sport = remote.sport.rawValue
existing.season = remote.season
existing.isPlayoff = remote.isPlayoff
existing.broadcastInfo = remote.broadcastInfo
existing.source = .cloudKit
existing.lastModified = Date()
// Restore user fields
existing.userAttending = savedAttending
existing.userNotes = savedNotes
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalGame(
canonicalId: canonicalId,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId,
dateTime: remote.dateTime,
sport: remote.sport.rawValue,
season: remote.season,
isPlayoff: remote.isPlayoff,
broadcastInfo: remote.broadcastInfo
)
context.insert(canonical)
return .applied
}
}
@MainActor
private func mergeLeagueStructure(
_ remote: LeagueStructureModel,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteId = remote.id
let descriptor = FetchDescriptor<LeagueStructureModel>(
predicate: #Predicate { $0.id == remoteId }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on LeagueStructure)
existing.sport = remote.sport
existing.structureTypeRaw = remote.structureTypeRaw
existing.name = remote.name
existing.abbreviation = remote.abbreviation
existing.parentId = remote.parentId
existing.displayOrder = remote.displayOrder
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
@MainActor
private func mergeTeamAlias(
_ remote: TeamAlias,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteId = remote.id
let descriptor = FetchDescriptor<TeamAlias>(
predicate: #Predicate { $0.id == remoteId }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on TeamAlias)
existing.teamCanonicalId = remote.teamCanonicalId
existing.aliasTypeRaw = remote.aliasTypeRaw
existing.aliasValue = remote.aliasValue
existing.validFrom = remote.validFrom
existing.validUntil = remote.validUntil
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
@MainActor
private func mergeStadiumAlias(
_ remote: StadiumAlias,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteAliasName = remote.aliasName
let descriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate { $0.aliasName == remoteAliasName }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on StadiumAlias)
existing.stadiumCanonicalId = remote.stadiumCanonicalId
existing.validFrom = remote.validFrom
existing.validUntil = remote.validUntil
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
}