- Rewrite BootstrapService: remove all legacy code paths (JSONStadium, JSONGame, bootstrapStadiumsLegacy, bootstrapGamesLegacy, venue aliases, createDefaultLeagueStructure), require canonical JSON files only - Add clearCanonicalData() to handle partial bootstrap recovery (prevents duplicate key crashes from interrupted first-launch) - Fix nullable stadium_canonical_id in games (4 MLS games have null) - Fix CKModels: logoUrl case, conference/division field keys - Fix CanonicalSyncService: sync conferenceCanonicalId/divisionCanonicalId - Add sports_canonical.json and DemoMode.swift - Delete legacy stadiums.json and games.json - Update all canonical resource JSON files with latest data - Fix TripWizardView horizontal scrolling with GeometryReader constraint - Update RegionMapSelector, TripDetailView, TripOptionsView UI improvements - Add DateRangePicker, PlanningModeStep, SportsStep enhancements - Update UI tests and marketing-videos config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
899 lines
32 KiB
Swift
899 lines
32 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 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
|
|
@MainActor
|
|
func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
|
|
let startTime = Date()
|
|
let syncState = SyncState.current(in: context)
|
|
|
|
// Prevent concurrent syncs
|
|
guard !syncState.syncInProgress else {
|
|
throw SyncError.syncAlreadyInProgress
|
|
}
|
|
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.syncStarted()
|
|
#endif
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 wasCancelled = false
|
|
|
|
/// 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
|
|
|
|
// Stadium sync
|
|
var entityStartTime = Date()
|
|
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
|
context: context,
|
|
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
|
cancellationToken: cancellationToken
|
|
)
|
|
totalStadiums = stadiums
|
|
totalSkippedIncompatible += skipIncompat1
|
|
totalSkippedOlder += skipOlder1
|
|
syncState.lastStadiumSync = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
if try saveProgressAndCheckCancellation() {
|
|
wasCancelled = true
|
|
throw CancellationError()
|
|
}
|
|
|
|
// League Structure sync
|
|
entityStartTime = Date()
|
|
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
|
context: context,
|
|
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
|
cancellationToken: cancellationToken
|
|
)
|
|
totalLeagueStructures = leagueStructures
|
|
totalSkippedIncompatible += skipIncompat2
|
|
totalSkippedOlder += skipOlder2
|
|
syncState.lastLeagueStructureSync = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
if try saveProgressAndCheckCancellation() {
|
|
wasCancelled = true
|
|
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 = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
if try saveProgressAndCheckCancellation() {
|
|
wasCancelled = true
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Team Alias sync
|
|
entityStartTime = Date()
|
|
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
|
context: context,
|
|
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
|
cancellationToken: cancellationToken
|
|
)
|
|
totalTeamAliases = teamAliases
|
|
totalSkippedIncompatible += skipIncompat4
|
|
totalSkippedOlder += skipOlder4
|
|
syncState.lastTeamAliasSync = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
if try saveProgressAndCheckCancellation() {
|
|
wasCancelled = true
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Stadium Alias sync
|
|
entityStartTime = Date()
|
|
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
|
context: context,
|
|
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
|
cancellationToken: cancellationToken
|
|
)
|
|
totalStadiumAliases = stadiumAliases
|
|
totalSkippedIncompatible += skipIncompat5
|
|
totalSkippedOlder += skipOlder5
|
|
syncState.lastStadiumAliasSync = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
if try saveProgressAndCheckCancellation() {
|
|
wasCancelled = true
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Game sync
|
|
entityStartTime = Date()
|
|
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
|
context: context,
|
|
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
|
cancellationToken: cancellationToken
|
|
)
|
|
totalGames = games
|
|
totalSkippedIncompatible += skipIncompat6
|
|
totalSkippedOlder += skipOlder6
|
|
syncState.lastGameSync = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
if try saveProgressAndCheckCancellation() {
|
|
wasCancelled = true
|
|
throw CancellationError()
|
|
}
|
|
|
|
// Sport sync
|
|
entityStartTime = Date()
|
|
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
|
context: context,
|
|
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
|
cancellationToken: cancellationToken
|
|
)
|
|
totalSports = sports
|
|
totalSkippedIncompatible += skipIncompat7
|
|
totalSkippedOlder += skipOlder7
|
|
syncState.lastSportSync = Date()
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
|
|
#endif
|
|
|
|
// Mark sync successful - clear per-entity timestamps since full sync completed
|
|
syncState.syncInProgress = false
|
|
syncState.lastSuccessfulSync = Date()
|
|
syncState.lastSyncError = nil
|
|
syncState.consecutiveFailures = 0
|
|
// 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"
|
|
try? context.save()
|
|
|
|
#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
|
|
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()
|
|
|
|
#if DEBUG
|
|
SyncStatusMonitor.shared.syncFailed(error: error)
|
|
#endif
|
|
|
|
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.
|
|
@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?,
|
|
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
|
|
|
|
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?,
|
|
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)
|
|
|
|
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?,
|
|
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)
|
|
|
|
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?,
|
|
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 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?,
|
|
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 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?,
|
|
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 result = try mergeStadiumAlias(remoteAlias, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
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 result = try mergeSport(remoteSport, 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.timezoneIdentifier = remote.timeZoneIdentifier
|
|
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,
|
|
timezoneIdentifier: remote.timeZoneIdentifier
|
|
)
|
|
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.conferenceId = remote.conferenceId
|
|
existing.divisionId = remote.divisionId
|
|
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,
|
|
conferenceId: remote.conferenceId,
|
|
divisionId: remote.divisionId
|
|
)
|
|
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
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
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
|
|
}
|
|
}
|
|
}
|