chore: commit all pending changes
This commit is contained in:
@@ -78,15 +78,17 @@ actor CanonicalSyncService {
|
||||
let startTime = Date()
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
// Prevent concurrent syncs
|
||||
guard !syncState.syncInProgress else {
|
||||
throw SyncError.syncAlreadyInProgress
|
||||
// Prevent concurrent syncs, but auto-heal stale "in progress" state.
|
||||
if syncState.syncInProgress {
|
||||
let staleSyncTimeout: TimeInterval = 15 * 60
|
||||
if let lastAttempt = syncState.lastSyncAttempt,
|
||||
Date().timeIntervalSince(lastAttempt) < staleSyncTimeout {
|
||||
throw SyncError.syncAlreadyInProgress
|
||||
}
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Clearing stale syncInProgress flag")
|
||||
syncState.syncInProgress = false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.syncStarted()
|
||||
#endif
|
||||
|
||||
// Check if sync is enabled
|
||||
guard syncState.syncEnabled else {
|
||||
return SyncResult(
|
||||
@@ -102,6 +104,10 @@ actor CanonicalSyncService {
|
||||
throw SyncError.cloudKitUnavailable
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.syncStarted()
|
||||
#endif
|
||||
|
||||
// Mark sync in progress
|
||||
syncState.syncInProgress = true
|
||||
syncState.lastSyncAttempt = Date()
|
||||
@@ -115,7 +121,7 @@ actor CanonicalSyncService {
|
||||
var totalSports = 0
|
||||
var totalSkippedIncompatible = 0
|
||||
var totalSkippedOlder = 0
|
||||
var wasCancelled = false
|
||||
var nonCriticalErrors: [String] = []
|
||||
|
||||
/// Helper to save partial progress and check cancellation
|
||||
func saveProgressAndCheckCancellation() throws -> Bool {
|
||||
@@ -129,41 +135,49 @@ actor CanonicalSyncService {
|
||||
do {
|
||||
// Sync in dependency order, checking cancellation between each entity type
|
||||
|
||||
// Stadium sync
|
||||
// Sport sync (non-critical for schedule rendering)
|
||||
var entityStartTime = Date()
|
||||
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
||||
do {
|
||||
let (sports, skipIncompat1, skipOlder1) = try await syncSports(
|
||||
context: context,
|
||||
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalSports = sports
|
||||
totalSkippedIncompatible += skipIncompat1
|
||||
totalSkippedOlder += skipOlder1
|
||||
syncState.lastSportSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("Sport: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (Sport): \(error.localizedDescription)")
|
||||
}
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Stadium sync
|
||||
entityStartTime = Date()
|
||||
let (stadiums, skipIncompat2, skipOlder2) = try await syncStadiums(
|
||||
context: context,
|
||||
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiums = stadiums
|
||||
totalSkippedIncompatible += skipIncompat1
|
||||
totalSkippedOlder += skipOlder1
|
||||
syncState.lastStadiumSync = Date()
|
||||
totalSkippedIncompatible += skipIncompat2
|
||||
totalSkippedOlder += skipOlder2
|
||||
syncState.lastStadiumSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -177,92 +191,119 @@ actor CanonicalSyncService {
|
||||
totalTeams = teams
|
||||
totalSkippedIncompatible += skipIncompat3
|
||||
totalSkippedOlder += skipOlder3
|
||||
syncState.lastTeamSync = Date()
|
||||
syncState.lastTeamSync = entityStartTime
|
||||
#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(
|
||||
let (games, skipIncompat4, skipOlder4) = try await syncGames(
|
||||
context: context,
|
||||
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalGames = games
|
||||
totalSkippedIncompatible += skipIncompat6
|
||||
totalSkippedOlder += skipOlder6
|
||||
syncState.lastGameSync = Date()
|
||||
totalSkippedIncompatible += skipIncompat4
|
||||
totalSkippedOlder += skipOlder4
|
||||
syncState.lastGameSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Sport sync
|
||||
// League Structure sync (non-critical for schedule rendering)
|
||||
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
|
||||
do {
|
||||
let (leagueStructures, skipIncompat5, skipOlder5) = try await syncLeagueStructure(
|
||||
context: context,
|
||||
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalLeagueStructures = leagueStructures
|
||||
totalSkippedIncompatible += skipIncompat5
|
||||
totalSkippedOlder += skipOlder5
|
||||
syncState.lastLeagueStructureSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("LeagueStructure: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (LeagueStructure): \(error.localizedDescription)")
|
||||
}
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Team Alias sync (non-critical for schedule rendering)
|
||||
entityStartTime = Date()
|
||||
do {
|
||||
let (teamAliases, skipIncompat6, skipOlder6) = try await syncTeamAliases(
|
||||
context: context,
|
||||
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalTeamAliases = teamAliases
|
||||
totalSkippedIncompatible += skipIncompat6
|
||||
totalSkippedOlder += skipOlder6
|
||||
syncState.lastTeamAliasSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("TeamAlias: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (TeamAlias): \(error.localizedDescription)")
|
||||
}
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
// Stadium Alias sync (non-critical for schedule rendering)
|
||||
entityStartTime = Date()
|
||||
do {
|
||||
let (stadiumAliases, skipIncompat7, skipOlder7) = try await syncStadiumAliases(
|
||||
context: context,
|
||||
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiumAliases = stadiumAliases
|
||||
totalSkippedIncompatible += skipIncompat7
|
||||
totalSkippedOlder += skipOlder7
|
||||
syncState.lastStadiumAliasSync = entityStartTime
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
|
||||
#endif
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
nonCriticalErrors.append("StadiumAlias: \(error.localizedDescription)")
|
||||
#if DEBUG
|
||||
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: false, recordCount: 0, duration: Date().timeIntervalSince(entityStartTime), errorMessage: error.localizedDescription)
|
||||
#endif
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Non-critical sync error (StadiumAlias): \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Mark sync successful - clear per-entity timestamps since full sync completed
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSuccessfulSync = Date()
|
||||
syncState.lastSyncError = nil
|
||||
syncState.lastSuccessfulSync = startTime
|
||||
syncState.lastSyncError = nonCriticalErrors.isEmpty ? nil : "Non-critical sync warnings: \(nonCriticalErrors.joined(separator: " | "))"
|
||||
syncState.consecutiveFailures = 0
|
||||
syncState.syncPausedReason = nil
|
||||
// Clear per-entity timestamps - they're only needed for partial recovery
|
||||
syncState.lastStadiumSync = nil
|
||||
syncState.lastTeamSync = nil
|
||||
@@ -305,12 +346,24 @@ actor CanonicalSyncService {
|
||||
// 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."
|
||||
if isTransientCloudKitError(error) {
|
||||
// Network/account hiccups should not permanently degrade sync health.
|
||||
syncState.consecutiveFailures = 0
|
||||
syncState.syncPausedReason = nil
|
||||
} else {
|
||||
syncState.consecutiveFailures += 1
|
||||
|
||||
// Pause sync after too many failures
|
||||
if syncState.consecutiveFailures >= 5 {
|
||||
#if DEBUG
|
||||
syncState.syncEnabled = false
|
||||
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
||||
#else
|
||||
syncState.consecutiveFailures = 5
|
||||
syncState.syncPausedReason = nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
@@ -347,6 +400,22 @@ actor CanonicalSyncService {
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
nonisolated private func isTransientCloudKitError(_ error: Error) -> Bool {
|
||||
guard let ckError = error as? CKError else { return false }
|
||||
switch ckError.code {
|
||||
case .networkUnavailable,
|
||||
.networkFailure,
|
||||
.serviceUnavailable,
|
||||
.requestRateLimited,
|
||||
.zoneBusy,
|
||||
.notAuthenticated,
|
||||
.accountTemporarilyUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Individual Sync Methods
|
||||
|
||||
@MainActor
|
||||
@@ -388,6 +457,12 @@ actor CanonicalSyncService {
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Single call for all teams with delta sync
|
||||
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
let activeStadiums = try context.fetch(
|
||||
FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.deprecatedAt == nil }
|
||||
)
|
||||
)
|
||||
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -399,6 +474,7 @@ actor CanonicalSyncService {
|
||||
syncTeam.team,
|
||||
canonicalId: syncTeam.canonicalId,
|
||||
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
||||
validStadiumIds: &validStadiumIds,
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -409,6 +485,10 @@ actor CanonicalSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if skippedIncompatible > 0 {
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible team records")
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@@ -420,6 +500,19 @@ actor CanonicalSyncService {
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Delta sync: nil = all games, Date = only modified since
|
||||
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
let activeTeams = try context.fetch(
|
||||
FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.deprecatedAt == nil }
|
||||
)
|
||||
)
|
||||
let validTeamIds = Set(activeTeams.map(\.canonicalId))
|
||||
let teamStadiumByTeamId = Dictionary(uniqueKeysWithValues: activeTeams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
|
||||
let activeStadiums = try context.fetch(
|
||||
FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.deprecatedAt == nil }
|
||||
)
|
||||
)
|
||||
var validStadiumIds = Set(activeStadiums.map(\.canonicalId))
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -433,6 +526,9 @@ actor CanonicalSyncService {
|
||||
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
|
||||
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
|
||||
stadiumCanonicalId: syncGame.stadiumCanonicalId,
|
||||
validTeamIds: validTeamIds,
|
||||
teamStadiumByTeamId: teamStadiumByTeamId,
|
||||
validStadiumIds: &validStadiumIds,
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -443,6 +539,10 @@ actor CanonicalSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if skippedIncompatible > 0 {
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Skipped \(skippedIncompatible) incompatible game records")
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@@ -585,6 +685,9 @@ actor CanonicalSyncService {
|
||||
existing.timezoneIdentifier = remote.timeZoneIdentifier
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.replacedByCanonicalId = nil
|
||||
|
||||
// Restore user fields
|
||||
existing.userNickname = savedNickname
|
||||
@@ -620,7 +723,8 @@ actor CanonicalSyncService {
|
||||
private func mergeTeam(
|
||||
_ remote: Team,
|
||||
canonicalId: String,
|
||||
stadiumCanonicalId: String,
|
||||
stadiumCanonicalId: String?,
|
||||
validStadiumIds: inout Set<String>,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
@@ -628,7 +732,32 @@ actor CanonicalSyncService {
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
|
||||
let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let resolvedStadiumCanonicalId: String
|
||||
if let remoteStadiumId, !remoteStadiumId.isEmpty, validStadiumIds.contains(remoteStadiumId) {
|
||||
resolvedStadiumCanonicalId = remoteStadiumId
|
||||
} else if let existingStadiumId, !existingStadiumId.isEmpty, validStadiumIds.contains(existingStadiumId) {
|
||||
resolvedStadiumCanonicalId = existingStadiumId
|
||||
} else if let remoteStadiumId, !remoteStadiumId.isEmpty {
|
||||
// Keep unresolved remote refs so teams/games can still sync while stadiums catch up.
|
||||
resolvedStadiumCanonicalId = remoteStadiumId
|
||||
} else if let existingStadiumId, !existingStadiumId.isEmpty {
|
||||
resolvedStadiumCanonicalId = existingStadiumId
|
||||
} else {
|
||||
// Last-resort placeholder keeps team records usable for game rendering.
|
||||
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
|
||||
}
|
||||
|
||||
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
|
||||
try ensurePlaceholderStadium(
|
||||
canonicalId: resolvedStadiumCanonicalId,
|
||||
sport: remote.sport,
|
||||
context: context
|
||||
)
|
||||
validStadiumIds.insert(resolvedStadiumCanonicalId)
|
||||
}
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
@@ -640,7 +769,7 @@ actor CanonicalSyncService {
|
||||
existing.abbreviation = remote.abbreviation
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.city = remote.city
|
||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
||||
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
|
||||
existing.logoURL = remote.logoURL?.absoluteString
|
||||
existing.primaryColor = remote.primaryColor
|
||||
existing.secondaryColor = remote.secondaryColor
|
||||
@@ -648,6 +777,9 @@ actor CanonicalSyncService {
|
||||
existing.divisionId = remote.divisionId
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.relocatedToCanonicalId = nil
|
||||
|
||||
// Restore user fields
|
||||
existing.userNickname = savedNickname
|
||||
@@ -666,7 +798,7 @@ actor CanonicalSyncService {
|
||||
abbreviation: remote.abbreviation,
|
||||
sport: remote.sport.rawValue,
|
||||
city: remote.city,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||
logoURL: remote.logoURL?.absoluteString,
|
||||
primaryColor: remote.primaryColor,
|
||||
secondaryColor: remote.secondaryColor,
|
||||
@@ -684,7 +816,10 @@ actor CanonicalSyncService {
|
||||
canonicalId: String,
|
||||
homeTeamCanonicalId: String,
|
||||
awayTeamCanonicalId: String,
|
||||
stadiumCanonicalId: String,
|
||||
stadiumCanonicalId: String?,
|
||||
validTeamIds: Set<String>,
|
||||
teamStadiumByTeamId: [String: String],
|
||||
validStadiumIds: inout Set<String>,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
@@ -692,7 +827,56 @@ actor CanonicalSyncService {
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
|
||||
func resolveTeamId(remote: String, existing: String?) -> String? {
|
||||
if validTeamIds.contains(remote) {
|
||||
return remote
|
||||
}
|
||||
if let existing, validTeamIds.contains(existing) {
|
||||
return existing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let resolvedHomeTeamId = resolveTeamId(remote: homeTeamCanonicalId, existing: existing?.homeTeamCanonicalId),
|
||||
let resolvedAwayTeamId = resolveTeamId(remote: awayTeamCanonicalId, existing: existing?.awayTeamCanonicalId)
|
||||
else {
|
||||
return .skippedIncompatible
|
||||
}
|
||||
|
||||
let trimmedRemoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedExistingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackStadiumFromTeams = (
|
||||
teamStadiumByTeamId[resolvedHomeTeamId] ??
|
||||
teamStadiumByTeamId[resolvedAwayTeamId]
|
||||
)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let resolvedStadiumCanonicalId: String
|
||||
if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty, validStadiumIds.contains(trimmedRemoteStadiumId) {
|
||||
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
|
||||
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty, validStadiumIds.contains(fallbackStadiumFromTeams) {
|
||||
// Cloud record can have stale/legacy stadium refs; prefer known team home venue.
|
||||
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
|
||||
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty, validStadiumIds.contains(trimmedExistingStadiumId) {
|
||||
// Keep existing local stadium if remote reference is invalid.
|
||||
resolvedStadiumCanonicalId = trimmedExistingStadiumId
|
||||
} else if let trimmedRemoteStadiumId, !trimmedRemoteStadiumId.isEmpty {
|
||||
resolvedStadiumCanonicalId = trimmedRemoteStadiumId
|
||||
} else if let fallbackStadiumFromTeams, !fallbackStadiumFromTeams.isEmpty {
|
||||
resolvedStadiumCanonicalId = fallbackStadiumFromTeams
|
||||
} else if let trimmedExistingStadiumId, !trimmedExistingStadiumId.isEmpty {
|
||||
resolvedStadiumCanonicalId = trimmedExistingStadiumId
|
||||
} else {
|
||||
resolvedStadiumCanonicalId = "stadium_placeholder_\(canonicalId)"
|
||||
}
|
||||
|
||||
if !validStadiumIds.contains(resolvedStadiumCanonicalId) {
|
||||
try ensurePlaceholderStadium(
|
||||
canonicalId: resolvedStadiumCanonicalId,
|
||||
sport: remote.sport,
|
||||
context: context
|
||||
)
|
||||
validStadiumIds.insert(resolvedStadiumCanonicalId)
|
||||
}
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
@@ -700,9 +884,9 @@ actor CanonicalSyncService {
|
||||
let savedNotes = existing.userNotes
|
||||
|
||||
// Update system fields
|
||||
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
||||
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
||||
existing.homeTeamCanonicalId = resolvedHomeTeamId
|
||||
existing.awayTeamCanonicalId = resolvedAwayTeamId
|
||||
existing.stadiumCanonicalId = resolvedStadiumCanonicalId
|
||||
existing.dateTime = remote.dateTime
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.season = remote.season
|
||||
@@ -710,6 +894,9 @@ actor CanonicalSyncService {
|
||||
existing.broadcastInfo = remote.broadcastInfo
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.rescheduledToCanonicalId = nil
|
||||
|
||||
// Restore user fields
|
||||
existing.userAttending = savedAttending
|
||||
@@ -724,9 +911,9 @@ actor CanonicalSyncService {
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
homeTeamCanonicalId: homeTeamCanonicalId,
|
||||
awayTeamCanonicalId: awayTeamCanonicalId,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
homeTeamCanonicalId: resolvedHomeTeamId,
|
||||
awayTeamCanonicalId: resolvedAwayTeamId,
|
||||
stadiumCanonicalId: resolvedStadiumCanonicalId,
|
||||
dateTime: remote.dateTime,
|
||||
sport: remote.sport.rawValue,
|
||||
season: remote.season,
|
||||
@@ -895,4 +1082,44 @@ actor CanonicalSyncService {
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func ensurePlaceholderStadium(
|
||||
canonicalId: String,
|
||||
sport: Sport,
|
||||
context: ModelContext
|
||||
) throws {
|
||||
let trimmedCanonicalId = canonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedCanonicalId.isEmpty else { return }
|
||||
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.canonicalId == trimmedCanonicalId }
|
||||
)
|
||||
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
if existing.deprecatedAt != nil {
|
||||
existing.deprecatedAt = nil
|
||||
existing.deprecationReason = nil
|
||||
existing.replacedByCanonicalId = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let placeholder = CanonicalStadium(
|
||||
canonicalId: trimmedCanonicalId,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
name: "Venue TBD",
|
||||
city: "Unknown",
|
||||
state: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
capacity: 0,
|
||||
sport: sport.rawValue,
|
||||
timezoneIdentifier: nil
|
||||
)
|
||||
context.insert(placeholder)
|
||||
SyncLogger.shared.log("⚠️ [SYNC] Inserted placeholder stadium for unresolved reference: \(trimmedCanonicalId)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user