- Add CKSport model to parse CloudKit Sport records - Add fetchSportsForSync() to CloudKitService for delta fetching - Add syncSports() and mergeSport() to CanonicalSyncService - Update DataProvider with dynamicSports support and allSports computed property - Update MockAppDataProvider with matching dynamic sports support - Add comprehensive documentation for adding new sports The app can now sync sport definitions from CloudKit, enabling new sports to be added without app updates. Sports are fetched, merged into SwiftData, and exposed via AppDataProvider.allSports alongside built-in Sport enum cases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
753 lines
26 KiB
Swift
753 lines
26 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
|
|
|
|
var totalUpdated: Int {
|
|
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated
|
|
}
|
|
|
|
var isEmpty: Bool { totalUpdated == 0 }
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
private let cloudKitService: CloudKitService
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(cloudKitService: CloudKitService = .shared) {
|
|
self.cloudKitService = cloudKitService
|
|
}
|
|
|
|
// MARK: - Public Sync Methods
|
|
|
|
/// Perform a full sync of all canonical data types.
|
|
/// This is the main entry point for background sync.
|
|
@MainActor
|
|
func syncAll(context: ModelContext) async throws -> SyncResult {
|
|
let startTime = Date()
|
|
let syncState = SyncState.current(in: context)
|
|
|
|
// Prevent concurrent syncs
|
|
guard !syncState.syncInProgress else {
|
|
throw SyncError.syncAlreadyInProgress
|
|
}
|
|
|
|
// Check if sync is enabled
|
|
guard syncState.syncEnabled else {
|
|
return SyncResult(
|
|
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
|
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
|
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
|
|
duration: 0
|
|
)
|
|
}
|
|
|
|
// Check CloudKit availability
|
|
guard await cloudKitService.isAvailable() else {
|
|
throw SyncError.cloudKitUnavailable
|
|
}
|
|
|
|
// Mark sync in progress
|
|
syncState.syncInProgress = true
|
|
syncState.lastSyncAttempt = Date()
|
|
|
|
var totalStadiums = 0
|
|
var totalTeams = 0
|
|
var totalGames = 0
|
|
var totalLeagueStructures = 0
|
|
var totalTeamAliases = 0
|
|
var totalStadiumAliases = 0
|
|
var totalSports = 0
|
|
var totalSkippedIncompatible = 0
|
|
var totalSkippedOlder = 0
|
|
|
|
do {
|
|
// Sync in dependency order
|
|
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalStadiums = stadiums
|
|
totalSkippedIncompatible += skipIncompat1
|
|
totalSkippedOlder += skipOlder1
|
|
|
|
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalLeagueStructures = leagueStructures
|
|
totalSkippedIncompatible += skipIncompat2
|
|
totalSkippedOlder += skipOlder2
|
|
|
|
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalTeams = teams
|
|
totalSkippedIncompatible += skipIncompat3
|
|
totalSkippedOlder += skipOlder3
|
|
|
|
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalTeamAliases = teamAliases
|
|
totalSkippedIncompatible += skipIncompat4
|
|
totalSkippedOlder += skipOlder4
|
|
|
|
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalStadiumAliases = stadiumAliases
|
|
totalSkippedIncompatible += skipIncompat5
|
|
totalSkippedOlder += skipOlder5
|
|
|
|
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalGames = games
|
|
totalSkippedIncompatible += skipIncompat6
|
|
totalSkippedOlder += skipOlder6
|
|
|
|
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalSports = sports
|
|
totalSkippedIncompatible += skipIncompat7
|
|
totalSkippedOlder += skipOlder7
|
|
|
|
// Mark sync successful
|
|
syncState.syncInProgress = false
|
|
syncState.lastSuccessfulSync = Date()
|
|
syncState.lastSyncError = nil
|
|
syncState.consecutiveFailures = 0
|
|
|
|
try context.save()
|
|
|
|
} catch {
|
|
// Mark sync failed
|
|
syncState.syncInProgress = false
|
|
syncState.lastSyncError = error.localizedDescription
|
|
syncState.consecutiveFailures += 1
|
|
|
|
// Pause sync after too many failures
|
|
if syncState.consecutiveFailures >= 5 {
|
|
syncState.syncEnabled = false
|
|
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
|
}
|
|
|
|
try? context.save()
|
|
throw error
|
|
}
|
|
|
|
return SyncResult(
|
|
stadiumsUpdated: totalStadiums,
|
|
teamsUpdated: totalTeams,
|
|
gamesUpdated: totalGames,
|
|
leagueStructuresUpdated: totalLeagueStructures,
|
|
teamAliasesUpdated: totalTeamAliases,
|
|
stadiumAliasesUpdated: totalStadiumAliases,
|
|
sportsUpdated: totalSports,
|
|
skippedIncompatible: totalSkippedIncompatible,
|
|
skippedOlder: totalSkippedOlder,
|
|
duration: Date().timeIntervalSince(startTime)
|
|
)
|
|
}
|
|
|
|
/// Re-enable sync after it was paused due to failures.
|
|
@MainActor
|
|
func resumeSync(context: ModelContext) {
|
|
let syncState = SyncState.current(in: context)
|
|
syncState.syncEnabled = true
|
|
syncState.syncPausedReason = nil
|
|
syncState.consecutiveFailures = 0
|
|
try? context.save()
|
|
}
|
|
|
|
// MARK: - Individual Sync Methods
|
|
|
|
@MainActor
|
|
private func syncStadiums(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Delta sync: nil = all stadiums, Date = only modified since
|
|
let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for syncStadium in syncStadiums {
|
|
// Use canonical ID directly from CloudKit - no UUID-based generation!
|
|
let result = try mergeStadium(
|
|
syncStadium.stadium,
|
|
canonicalId: syncStadium.canonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncTeams(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Single call for all teams with delta sync
|
|
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for syncTeam in allSyncTeams {
|
|
// Use canonical IDs directly from CloudKit - no UUID lookups!
|
|
let result = try mergeTeam(
|
|
syncTeam.team,
|
|
canonicalId: syncTeam.canonicalId,
|
|
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncGames(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Delta sync: nil = all games, Date = only modified since
|
|
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for syncGame in syncGames {
|
|
// Use canonical IDs directly from CloudKit - no UUID lookups!
|
|
let result = try mergeGame(
|
|
syncGame.game,
|
|
canonicalId: syncGame.canonicalId,
|
|
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
|
|
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
|
|
stadiumCanonicalId: syncGame.stadiumCanonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncLeagueStructure(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteStructure in remoteStructures {
|
|
let result = try mergeLeagueStructure(remoteStructure, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncTeamAliases(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteAlias in remoteAliases {
|
|
let result = try mergeTeamAlias(remoteAlias, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncStadiumAliases(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteAlias in remoteAliases {
|
|
let result = try mergeStadiumAlias(remoteAlias, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncSports(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync)
|
|
|
|
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.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userNickname = savedNickname
|
|
existing.userNotes = savedNotes
|
|
existing.isFavorite = savedFavorite
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new - let init() generate deterministic UUID from canonicalId
|
|
let canonical = CanonicalStadium(
|
|
canonicalId: canonicalId,
|
|
// uuid: omitted - will be generated deterministically from canonicalId
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
name: remote.name,
|
|
city: remote.city,
|
|
state: remote.state,
|
|
latitude: remote.latitude,
|
|
longitude: remote.longitude,
|
|
capacity: remote.capacity,
|
|
yearOpened: remote.yearOpened,
|
|
imageURL: remote.imageURL?.absoluteString,
|
|
sport: remote.sport.rawValue
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeTeam(
|
|
_ remote: Team,
|
|
canonicalId: String,
|
|
stadiumCanonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
let descriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedNickname = existing.userNickname
|
|
let savedFavorite = existing.isFavorite
|
|
|
|
// Update system fields
|
|
existing.name = remote.name
|
|
existing.abbreviation = remote.abbreviation
|
|
existing.sport = remote.sport.rawValue
|
|
existing.city = remote.city
|
|
existing.stadiumCanonicalId = stadiumCanonicalId
|
|
existing.logoURL = remote.logoURL?.absoluteString
|
|
existing.primaryColor = remote.primaryColor
|
|
existing.secondaryColor = remote.secondaryColor
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userNickname = savedNickname
|
|
existing.isFavorite = savedFavorite
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new - let init() generate deterministic UUID from canonicalId
|
|
let canonical = CanonicalTeam(
|
|
canonicalId: canonicalId,
|
|
// uuid: omitted - will be generated deterministically from canonicalId
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
name: remote.name,
|
|
abbreviation: remote.abbreviation,
|
|
sport: remote.sport.rawValue,
|
|
city: remote.city,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
logoURL: remote.logoURL?.absoluteString,
|
|
primaryColor: remote.primaryColor,
|
|
secondaryColor: remote.secondaryColor
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeGame(
|
|
_ remote: Game,
|
|
canonicalId: String,
|
|
homeTeamCanonicalId: String,
|
|
awayTeamCanonicalId: String,
|
|
stadiumCanonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedAttending = existing.userAttending
|
|
let savedNotes = existing.userNotes
|
|
|
|
// Update system fields
|
|
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
|
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
|
existing.stadiumCanonicalId = stadiumCanonicalId
|
|
existing.dateTime = remote.dateTime
|
|
existing.sport = remote.sport.rawValue
|
|
existing.season = remote.season
|
|
existing.isPlayoff = remote.isPlayoff
|
|
existing.broadcastInfo = remote.broadcastInfo
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userAttending = savedAttending
|
|
existing.userNotes = savedNotes
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new - let init() generate deterministic UUID from canonicalId
|
|
let canonical = CanonicalGame(
|
|
canonicalId: canonicalId,
|
|
// uuid: omitted - will be generated deterministically from canonicalId
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
dateTime: remote.dateTime,
|
|
sport: remote.sport.rawValue,
|
|
season: remote.season,
|
|
isPlayoff: remote.isPlayoff,
|
|
broadcastInfo: remote.broadcastInfo
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeLeagueStructure(
|
|
_ remote: LeagueStructureModel,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteId = remote.id
|
|
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
|
predicate: #Predicate { $0.id == remoteId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// lastModified check
|
|
guard remote.lastModified > existing.lastModified else {
|
|
return .skippedOlder
|
|
}
|
|
|
|
// Update all fields (no user fields on LeagueStructure)
|
|
existing.sport = remote.sport
|
|
existing.structureTypeRaw = remote.structureTypeRaw
|
|
existing.name = remote.name
|
|
existing.abbreviation = remote.abbreviation
|
|
existing.parentId = remote.parentId
|
|
existing.displayOrder = remote.displayOrder
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeTeamAlias(
|
|
_ remote: TeamAlias,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteId = remote.id
|
|
let descriptor = FetchDescriptor<TeamAlias>(
|
|
predicate: #Predicate { $0.id == remoteId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// lastModified check
|
|
guard remote.lastModified > existing.lastModified else {
|
|
return .skippedOlder
|
|
}
|
|
|
|
// Update all fields (no user fields on TeamAlias)
|
|
existing.teamCanonicalId = remote.teamCanonicalId
|
|
existing.aliasTypeRaw = remote.aliasTypeRaw
|
|
existing.aliasValue = remote.aliasValue
|
|
existing.validFrom = remote.validFrom
|
|
existing.validUntil = remote.validUntil
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeStadiumAlias(
|
|
_ remote: StadiumAlias,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteAliasName = remote.aliasName
|
|
let descriptor = FetchDescriptor<StadiumAlias>(
|
|
predicate: #Predicate { $0.aliasName == remoteAliasName }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// lastModified check
|
|
guard remote.lastModified > existing.lastModified else {
|
|
return .skippedOlder
|
|
}
|
|
|
|
// Update all fields (no user fields on StadiumAlias)
|
|
existing.stadiumCanonicalId = remote.stadiumCanonicalId
|
|
existing.validFrom = remote.validFrom
|
|
existing.validUntil = remote.validUntil
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@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
|
|
}
|
|
}
|
|
}
|