Files
Sportstime/SportsTime/Core/Services/CanonicalSyncService.swift
Trey t fdcecafaa3 feat: rewrite bootstrap, fix CloudKit sync, update canonical data, and UI fixes
- 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>
2026-02-06 00:06:19 -06:00

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
}
}
}