Files
Sportstime/SportsTime/Core/Services/CanonicalSyncService.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

1199 lines
48 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
@MainActor
final class 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 sportsUpdated: Int
let skippedIncompatible: Int
let skippedOlder: Int
let duration: TimeInterval
let wasCancelled: Bool
var totalUpdated: Int {
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated
}
var isEmpty: Bool { totalUpdated == 0 && !wasCancelled }
}
// 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.
/// - Parameters:
/// - context: The ModelContext to use for saving data
/// - cancellationToken: Optional token to check for cancellation between entity syncs
func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
let startTime = Date()
let syncState = SyncState.current(in: context)
// Prevent concurrent syncs, but auto-heal stale "in progress" state.
if syncState.syncInProgress {
let staleSyncTimeout: TimeInterval = 15 * 60
if let lastAttempt = syncState.lastSyncAttempt,
Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
throw SyncError.syncAlreadyInProgress
}
SyncLogger.shared.log("⚠️ [SYNC] Clearing stale syncInProgress flag")
syncState.syncInProgress = false
}
// Check if sync is enabled
guard syncState.syncEnabled else {
return SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
duration: 0, wasCancelled: false
)
}
// Check CloudKit availability
guard await cloudKitService.isAvailable() else {
throw SyncError.cloudKitUnavailable
}
#if DEBUG
SyncStatusMonitor.shared.syncStarted()
#endif
// 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 totalSports = 0
var totalSkippedIncompatible = 0
var totalSkippedOlder = 0
var nonCriticalErrors: [String] = []
/// Helper to save partial progress and check cancellation
func saveProgressAndCheckCancellation() throws -> Bool {
try context.save()
if cancellationToken?.isCancelled == true {
return true
}
return false
}
do {
// Sync in dependency order, checking cancellation between each entity type
// Sport sync (non-critical for schedule rendering)
var entityStartTime = Date()
do {
let (sports, skipIncompat1, skipOlder1) = try await syncSports(
context: context,
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalSports = sports
totalSkippedIncompatible += skipIncompat1
totalSkippedOlder += skipOlder1
syncState.lastSportSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("Sport: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (Sport): \(error.localizedDescription)")
}
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Stadium sync
entityStartTime = Date()
let (stadiums, skipIncompat2, skipOlder2) = try await syncStadiums(
context: context,
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalStadiums = stadiums
totalSkippedIncompatible += skipIncompat2
totalSkippedOlder += skipOlder2
syncState.lastStadiumSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Team sync
entityStartTime = Date()
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
context: context,
since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalTeams = teams
totalSkippedIncompatible += skipIncompat3
totalSkippedOlder += skipOlder3
syncState.lastTeamSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Game sync
entityStartTime = Date()
let (games, skipIncompat4, skipOlder4) = try await syncGames(
context: context,
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalGames = games
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
syncState.lastGameSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// League Structure sync (non-critical for schedule rendering)
entityStartTime = Date()
do {
let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure(
context: context,
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalLeagueStructures = leagueStructures
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
syncState.lastLeagueStructureSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("LeagueStructure: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (LeagueStructure): \(error.localizedDescription)")
}
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Team Alias sync (non-critical for schedule rendering)
entityStartTime = Date()
do {
let (teamAliases, skipIncompat6, skipOlder6) = try await syncTeamAliases(
context: context,
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalTeamAliases = teamAliases
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
syncState.lastTeamAliasSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("TeamAlias: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (TeamAlias): \(error.localizedDescription)")
}
if try saveProgressAndCheckCancellation() {
throw CancellationError()
}
// Stadium Alias sync (non-critical for schedule rendering)
entityStartTime = Date()
do {
let (stadiumAliases, skipIncompat7, skipOlder7) = try await syncStadiumAliases(
context: context,
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
cancellationToken: cancellationToken
)
totalStadiumAliases = stadiumAliases
totalSkippedIncompatible += skipIncompat7
totalSkippedOlder += skipOlder7
syncState.lastStadiumAliasSync = entityStartTime
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
} catch is CancellationError {
throw CancellationError()
} catch {
nonCriticalErrors.append("StadiumAlias: \(error.localizedDescription)")
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
#endif
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (StadiumAlias): \(error.localizedDescription)")
}
// Mark sync successful - clear per-entity timestamps since full sync completed
syncState.syncInProgress = false
syncState.lastSuccessfulSync = startTime
syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))"
syncState.consecutiveFailures = 0
syncState.syncPausedReason = nil
// Clear per-entity timestamps - they're only needed for partial recovery
syncState.lastStadiumSync = nil
syncState.lastTeamSync = nil
syncState.lastGameSync = nil
syncState.lastLeagueStructureSync = nil
syncState.lastTeamAliasSync = nil
syncState.lastStadiumAliasSync = nil
syncState.lastSportSync = nil
try context.save()
#if DEBUG
SyncStatusMonitor.shared.syncCompleted(totalDuration: Date().timeIntervalSince(startTime))
#endif
} catch is CancellationError {
// Graceful cancellation - progress already saved
syncState.syncInProgress = false
syncState.lastSyncError = "Sync cancelled - partial progress saved"
do {
try context.save()
} catch {
SyncLogger.shared.log("⚠️ [SYNC] Failed to save cancellation state: \(error.localizedDescription)")
}
#if DEBUG
SyncStatusMonitor.shared.syncFailed(error: CancellationError())
#endif
return SyncResult(
stadiumsUpdated: totalStadiums,
teamsUpdated: totalTeams,
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
stadiumAliasesUpdated: totalStadiumAliases,
sportsUpdated: totalSports,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime),
wasCancelled: true
)
} catch {
// Mark sync failed
syncState.syncInProgress = false
syncState.lastSyncError = error.localizedDescription
if isTransientCloudKitError(error) {
// Network/account hiccups should not permanently degrade sync health.
syncState.consecutiveFailures = 0
syncState.syncPausedReason = nil
} else {
syncState.consecutiveFailures += 1
// Pause sync after too many failures (consistent in all builds)
if syncState.consecutiveFailures >= 5 {
syncState.syncEnabled = false
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
}
}
do {
try context.save()
} catch let saveError {
SyncLogger.shared.log("⚠️ [SYNC] Failed to save error state: \(saveError.localizedDescription)")
}
SyncStatusMonitor.shared.syncFailed(error: error)
throw error
}
return SyncResult(
stadiumsUpdated: totalStadiums,
teamsUpdated: totalTeams,
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
stadiumAliasesUpdated: totalStadiumAliases,
sportsUpdated: totalSports,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime),
wasCancelled: false
)
}
/// Re-enable sync after it was paused due to failures.
func resumeSync(context: ModelContext) {
let syncState = SyncState.current(in: context)
syncState.syncEnabled = true
syncState.syncPausedReason = nil
syncState.consecutiveFailures = 0
do {
try context.save()
} catch {
SyncLogger.shared.log("⚠️ [SYNC] Failed to save resume sync state: \(error.localizedDescription)")
}
}
nonisolated private func isTransientCloudKitError(_ error: Error) -> Bool {
guard let ckError = error as? CKError else { return false }
switch ckError.code {
case .networkUnavailable,
.networkFailure,
.serviceUnavailable,
.requestRateLimited,
.zoneBusy,
.notAuthenticated,
.accountTemporarilyUnavailable:
return true
default:
return false
}
}
// MARK: - Individual Sync Methods
private func syncStadiums(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Delta sync: nil = all stadiums, Date = only modified since
let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync, cancellationToken: cancellationToken)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
// Batch-fetch all existing stadiums to avoid N+1 FetchDescriptor lookups
let allExistingStadiums = try context.fetch(FetchDescriptor<CanonicalStadium>())
let existingStadiumsByCanonicalId = Dictionary(grouping: allExistingStadiums, by: \.canonicalId).compactMapValues(\.first)
for syncStadium in syncStadiums {
// Use canonical ID directly from CloudKit - no UUID-based generation!
let result = try mergeStadium(
syncStadium.stadium,
canonicalId: syncStadium.canonicalId,
existingRecord: existingStadiumsByCanonicalId[syncStadium.canonicalId],
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
private func syncTeams(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Single call for all teams with delta sync
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
let activeStadiums = try context.fetch(
FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
// Batch-fetch all existing teams to avoid N+1 FetchDescriptor lookups
let allExistingTeams = try context.fetch(FetchDescriptor<CanonicalTeam>())
let existingTeamsByCanonicalId = Dictionary(grouping: allExistingTeams, by: \.canonicalId).compactMapValues(\.first)
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,
validStadiumIds: &validStadiumIds,
existingRecord: existingTeamsByCanonicalId[syncTeam.canonicalId],
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
if skippedIncompatible > 0 {
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records")
}
return (updated, skippedIncompatible, skippedOlder)
}
private func syncGames(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Delta sync: nil = all games, Date = only modified since
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
let activeTeams = try context.fetch(
FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)
let validTeamIds = Set(activeTeams.map(\.canonicalId))
let teamStadiumByTeamId = Dictionary(uniqueKeysWithValues: activeTeams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
let activeStadiums = try context.fetch(
FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
// Batch-fetch existing games to avoid N+1 FetchDescriptor lookups
// Build lookup only for games matching incoming sync data to reduce dictionary size
let syncCanonicalIds = Set(syncGames.map(\.canonicalId))
let allExistingGames = try context.fetch(FetchDescriptor<CanonicalGame>())
let existingGamesByCanonicalId = Dictionary(
grouping: allExistingGames.filter { syncCanonicalIds.contains($0.canonicalId) },
by: \.canonicalId
).compactMapValues(\.first)
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,
validTeamIds: validTeamIds,
teamStadiumByTeamId: teamStadiumByTeamId,
validStadiumIds: &validStadiumIds,
existingRecord: existingGamesByCanonicalId[syncGame.canonicalId],
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
if skippedIncompatible > 0 {
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records")
}
return (updated, skippedIncompatible, skippedOlder)
}
private func syncLeagueStructure(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync, cancellationToken: cancellationToken)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteStructure in remoteStructures {
let structureType = LeagueStructureType(rawValue: remoteStructure.structureTypeRaw.lowercased()) ?? .division
let model = LeagueStructureModel(
id: remoteStructure.id,
sport: remoteStructure.sport,
structureType: structureType,
name: remoteStructure.name,
abbreviation: remoteStructure.abbreviation,
parentId: remoteStructure.parentId,
displayOrder: remoteStructure.displayOrder,
schemaVersion: remoteStructure.schemaVersion,
lastModified: remoteStructure.lastModified
)
let result = try mergeLeagueStructure(model, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
private func syncTeamAliases(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync, cancellationToken: cancellationToken)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let aliasType = TeamAliasType(rawValue: remoteAlias.aliasTypeRaw.lowercased()) ?? .name
let model = TeamAlias(
id: remoteAlias.id,
teamCanonicalId: remoteAlias.teamCanonicalId,
aliasType: aliasType,
aliasValue: remoteAlias.aliasValue,
validFrom: remoteAlias.validFrom,
validUntil: remoteAlias.validUntil,
schemaVersion: remoteAlias.schemaVersion,
lastModified: remoteAlias.lastModified
)
let result = try mergeTeamAlias(model, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
private func syncStadiumAliases(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync, cancellationToken: cancellationToken)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let model = StadiumAlias(
aliasName: remoteAlias.aliasName,
stadiumCanonicalId: remoteAlias.stadiumCanonicalId,
validFrom: remoteAlias.validFrom,
validUntil: remoteAlias.validUntil,
schemaVersion: remoteAlias.schemaVersion,
lastModified: remoteAlias.lastModified
)
let result = try mergeStadiumAlias(model, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
private func syncSports(
context: ModelContext,
since lastSync: Date?,
cancellationToken: SyncCancellationToken?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync, cancellationToken: cancellationToken)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteSport in remoteSports {
let model = CanonicalSport(
id: remoteSport.id,
abbreviation: remoteSport.abbreviation,
displayName: remoteSport.displayName,
iconName: remoteSport.iconName,
colorHex: remoteSport.colorHex,
seasonStartMonth: remoteSport.seasonStartMonth,
seasonEndMonth: remoteSport.seasonEndMonth,
isActive: remoteSport.isActive,
lastModified: remoteSport.lastModified,
schemaVersion: remoteSport.schemaVersion,
source: .cloudKit
)
let result = try mergeSport(model, 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
}
private func mergeStadium(
_ remote: Stadium,
canonicalId: String,
existingRecord: CanonicalStadium? = nil,
context: ModelContext
) throws -> MergeResult {
// Use pre-fetched record if available, otherwise fall back to individual fetch
let existing: CanonicalStadium?
if let existingRecord {
existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
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.timezoneIdentifier = remote.timeZoneIdentifier
existing.source = .cloudKit
existing.lastModified = Date()
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.replacedByCanonicalId = nil
// 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,
timezoneIdentifier: remote.timeZoneIdentifier
)
context.insert(canonical)
return .applied
}
}
private func mergeTeam(
_ remote: Team,
canonicalId: String,
stadiumCanonicalId: String?,
validStadiumIds: inout Set<String>,
existingRecord: CanonicalTeam? = nil,
context: ModelContext
) throws -> MergeResult {
// Use pre-fetched record if available, otherwise fall back to individual fetch
let existing: CanonicalTeam?
if let existingRecord {
existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedStadiumCanonicalId: String
if let remoteStadiumId, !remoteStadiumId.isEmpty, validStadiumIds.contains(remoteStadiumId) {
resolvedStadiumCanonicalId = remoteStadiumId
} else if let existingStadiumId, !existingStadiumId.isEmpty, validStadiumIds.contains(existingStadiumId) {
resolvedStadiumCanonicalId = existingStadiumId
} else if let remoteStadiumId, !remoteStadiumId.isEmpty {
// Keep unresolved remote refs so teams/games can still sync while stadiums catch up.
resolvedStadiumCanonicalId = remoteStadiumId
} else if let existingStadiumId, !existingStadiumId.isEmpty {
resolvedStadiumCanonicalId = existingStadiumId
} else {
// Last-resort placeholder keeps team records usable for game rendering.
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
}
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
try ensurePlaceholderStadium(
canonicalId: resolvedStadiumCanonicalId,
sport: remote.sport,
context: context
)
validStadiumIds.insert(resolvedStadiumCanonicalId)
}
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 = resolvedStadiumCanonicalId
existing.logoURL = remote.logoURL?.absoluteString
existing.primaryColor = remote.primaryColor
existing.secondaryColor = remote.secondaryColor
existing.conferenceId = remote.conferenceId
existing.divisionId = remote.divisionId
existing.source = .cloudKit
existing.lastModified = Date()
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.relocatedToCanonicalId = nil
// 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: resolvedStadiumCanonicalId,
logoURL: remote.logoURL?.absoluteString,
primaryColor: remote.primaryColor,
secondaryColor: remote.secondaryColor,
conferenceId: remote.conferenceId,
divisionId: remote.divisionId
)
context.insert(canonical)
return .applied
}
}
private func mergeGame(
_ remote: Game,
canonicalId: String,
homeTeamCanonicalId: String,
awayTeamCanonicalId: String,
stadiumCanonicalId: String?,
validTeamIds: Set<String>,
teamStadiumByTeamId: [String: String],
validStadiumIds: inout Set<String>,
existingRecord: CanonicalGame? = nil,
context: ModelContext
) throws -> MergeResult {
// Use pre-fetched record if available, otherwise fall back to individual fetch
let existing: CanonicalGame?
if let existingRecord {
existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
func resolveTeamId(remote: String, existing: String?) -> String? {
if validTeamIds.contains(remote) {
return remote
}
if let existing, validTeamIds.contains(existing) {
return existing
}
return nil
}
guard let resolvedHomeTeamId = resolveTeamId(remote: homeTeamCanonicalId, existing: existing?.homeTeamCanonicalId),
let resolvedAwayTeamId = resolveTeamId(remote: awayTeamCanonicalId, existing: existing?.awayTeamCanonicalId)
else {
return .skippedIncompatible
}
let trimmedRemoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedExistingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackStadiumFromTeams = (
teamStadiumByTeamId[resolvedHomeTeamId] ??
teamStadiumByTeamId[resolvedAwayTeamId]
)?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedStadiumCanonicalId: String
if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty, validStadiumIds.contains(trimmedRemoteStadiumId) {
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty, validStadiumIds.contains(fallbackStadiumFromTeams) {
// Cloud record can have stale/legacy stadium refs; prefer known team home venue.
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty, validStadiumIds.contains(trimmedExistingStadiumId) {
// Keep existing local stadium if remote reference is invalid.
resolvedStadiumCanonicalId = trimmedExistingStadiumId
} else if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty {
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty {
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty {
resolvedStadiumCanonicalId = trimmedExistingStadiumId
} else {
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
}
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
try ensurePlaceholderStadium(
canonicalId: resolvedStadiumCanonicalId,
sport: remote.sport,
context: context
)
validStadiumIds.insert(resolvedStadiumCanonicalId)
}
if let existing = existing {
// Preserve user fields
let savedAttending = existing.userAttending
let savedNotes = existing.userNotes
// Update system fields
existing.homeTeamCanonicalId = resolvedHomeTeamId
existing.awayTeamCanonicalId = resolvedAwayTeamId
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
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()
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.rescheduledToCanonicalId = nil
// 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: resolvedHomeTeamId,
awayTeamCanonicalId: resolvedAwayTeamId,
stadiumCanonicalId: resolvedStadiumCanonicalId,
dateTime: remote.dateTime,
sport: remote.sport.rawValue,
season: remote.season,
isPlayoff: remote.isPlayoff,
broadcastInfo: remote.broadcastInfo
)
context.insert(canonical)
return .applied
}
}
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
}
}
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
}
}
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
}
}
private func mergeSport(
_ remote: CanonicalSport,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteId = remote.id
let descriptor = FetchDescriptor<CanonicalSport>(
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 CanonicalSport)
existing.abbreviation = remote.abbreviation
existing.displayName = remote.displayName
existing.iconName = remote.iconName
existing.colorHex = remote.colorHex
existing.seasonStartMonth = remote.seasonStartMonth
existing.seasonEndMonth = remote.seasonEndMonth
existing.isActive = remote.isActive
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
existing.sourceRaw = DataSource.cloudKit.rawValue
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
private func ensurePlaceholderStadium(
canonicalId: String,
sport: Sport,
context: ModelContext
) throws {
let trimmedCanonicalId = canonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCanonicalId.isEmpty else { return }
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == trimmedCanonicalId }
)
if let existing = try context.fetch(descriptor).first {
if existing.deprecatedAt != nil {
existing.deprecatedAt = nil
existing.deprecationReason = nil
existing.replacedByCanonicalId = nil
}
return
}
let placeholder = CanonicalStadium(
canonicalId: trimmedCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
name: "Venue TBD",
city: "Unknown",
state: "",
latitude: 0,
longitude: 0,
capacity: 0,
sport: sport.rawValue,
timezoneIdentifier: nil
)
context.insert(placeholder)
SyncLogger.shared.log("⚠️ [SYNC] Inserted placeholder stadium for unresolved reference: \(trimmedCanonicalId)")
}
}