Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
634
SportsTime/Core/Services/CanonicalSyncService.swift
Normal file
634
SportsTime/Core/Services/CanonicalSyncService.swift
Normal file
@@ -0,0 +1,634 @@
|
||||
//
|
||||
// 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 skippedIncompatible: Int
|
||||
let skippedOlder: Int
|
||||
let duration: TimeInterval
|
||||
|
||||
var totalUpdated: Int {
|
||||
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
|
||||
}
|
||||
|
||||
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,
|
||||
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 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 (games, skipIncompat5, skipOlder5) = try await syncGames(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalGames = games
|
||||
totalSkippedIncompatible += skipIncompat5
|
||||
totalSkippedOlder += skipOlder5
|
||||
|
||||
// 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,
|
||||
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) {
|
||||
let remoteStadiums = try await cloudKitService.fetchStadiums()
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteStadium in remoteStadiums {
|
||||
// For now, fetch full list and merge - CloudKit public DB doesn't have delta sync
|
||||
// In future, could add lastModified filtering on CloudKit query
|
||||
|
||||
let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))"
|
||||
|
||||
let result = try mergeStadium(
|
||||
remoteStadium,
|
||||
canonicalId: 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) {
|
||||
// Fetch teams for all sports
|
||||
var allTeams: [Team] = []
|
||||
for sport in Sport.allCases {
|
||||
let teams = try await cloudKitService.fetchTeams(for: sport)
|
||||
allTeams.append(contentsOf: teams)
|
||||
}
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteTeam in allTeams {
|
||||
let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())"
|
||||
|
||||
let result = try mergeTeam(
|
||||
remoteTeam,
|
||||
canonicalId: canonicalId,
|
||||
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) {
|
||||
// Fetch games for the next 6 months from all sports
|
||||
let startDate = lastSync ?? Date()
|
||||
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
|
||||
|
||||
let remoteGames = try await cloudKitService.fetchGames(
|
||||
sports: Set(Sport.allCases),
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteGame in remoteGames {
|
||||
let result = try mergeGame(
|
||||
remoteGame,
|
||||
canonicalId: remoteGame.id.uuidString,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 canonical = CanonicalStadium(
|
||||
canonicalId: canonicalId,
|
||||
uuid: remote.id,
|
||||
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,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// Find stadium canonical ID
|
||||
let remoteStadiumId = remote.stadiumId
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.uuid == remoteStadiumId }
|
||||
)
|
||||
let stadium = try context.fetch(stadiumDescriptor).first
|
||||
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
|
||||
|
||||
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 {
|
||||
let canonical = CanonicalTeam(
|
||||
canonicalId: canonicalId,
|
||||
uuid: remote.id,
|
||||
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,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// Look up canonical IDs for teams and stadium
|
||||
let remoteHomeTeamId = remote.homeTeamId
|
||||
let remoteAwayTeamId = remote.awayTeamId
|
||||
let remoteStadiumId = remote.stadiumId
|
||||
|
||||
let homeTeamDescriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
|
||||
)
|
||||
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
|
||||
)
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.uuid == remoteStadiumId }
|
||||
)
|
||||
|
||||
let homeTeam = try context.fetch(homeTeamDescriptor).first
|
||||
let awayTeam = try context.fetch(awayTeamDescriptor).first
|
||||
let stadium = try context.fetch(stadiumDescriptor).first
|
||||
|
||||
let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown"
|
||||
let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown"
|
||||
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
|
||||
|
||||
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 {
|
||||
let canonical = CanonicalGame(
|
||||
canonicalId: canonicalId,
|
||||
uuid: remote.id,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user