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>
1199 lines
48 KiB
Swift
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)")
|
|
}
|
|
}
|