chore: commit all pending changes

This commit is contained in:
Trey t
2026-02-10 18:15:36 -06:00
parent b993ed3613
commit 53cc532ca9
51 changed files with 2583 additions and 268 deletions

View File

@@ -39,8 +39,27 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Handle CloudKit subscription notification
// TODO: Re-implement subscription handling for ItineraryItem changes
completionHandler(.noData)
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) as? CKQueryNotification,
let subscriptionID = notification.subscriptionID,
CloudKitService.canonicalSubscriptionIDs.contains(subscriptionID),
let recordType = CloudKitService.recordType(forSubscriptionID: subscriptionID) else {
completionHandler(.noData)
return
}
Task { @MainActor in
var changed = false
if notification.queryNotificationReason == .recordDeleted,
let recordID = notification.recordID {
changed = await BackgroundSyncManager.shared.applyDeletionHint(
recordType: recordType,
recordName: recordID.recordName
)
}
let updated = await BackgroundSyncManager.shared.triggerSyncFromPushNotification(subscriptionID: subscriptionID)
completionHandler((changed || updated) ? .newData : .noData)
}
}
}

View File

@@ -15,6 +15,17 @@ import os
@MainActor
final class BackgroundSyncManager {
enum SyncTriggerError: Error, LocalizedError {
case modelContainerNotConfigured
var errorDescription: String? {
switch self {
case .modelContainerNotConfigured:
return "Sync is unavailable because the model container is not configured."
}
}
}
// MARK: - Task Identifiers
/// Background app refresh task - runs periodically (system decides frequency)
@@ -163,7 +174,7 @@ final class BackgroundSyncManager {
task.setTaskCompleted(success: true) // Partial success - progress was saved
} else {
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
task.setTaskCompleted(success: !result.isEmpty)
task.setTaskCompleted(success: true)
}
} catch {
currentCancellationToken = nil
@@ -212,7 +223,7 @@ final class BackgroundSyncManager {
task.setTaskCompleted(success: true) // Partial success
} else {
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
task.setTaskCompleted(success: true)
}
} catch {
currentCancellationToken = nil
@@ -291,6 +302,191 @@ final class BackgroundSyncManager {
}
}
/// Trigger sync from a CloudKit push notification.
/// Returns true if local data changed and UI should refresh.
@MainActor
func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool {
guard let container = modelContainer else {
logger.error("Push sync: No model container configured")
return false
}
if let subscriptionID {
logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))")
} else {
logger.info("Triggering sync from CloudKit push")
}
do {
let result = try await performSync(context: container.mainContext)
return !result.isEmpty || result.wasCancelled
} catch {
logger.error("Push-triggered sync failed: \(error.localizedDescription)")
return false
}
}
/// Apply a targeted local deletion for records removed remotely.
/// This covers deletions which are not returned by date-based delta queries.
@MainActor
func applyDeletionHint(recordType: String?, recordName: String) async -> Bool {
guard let container = modelContainer else {
logger.error("Deletion hint: No model container configured")
return false
}
let context = container.mainContext
var changed = false
do {
switch recordType {
case CKRecordType.game:
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.canonicalId == recordName && game.deprecatedAt == nil
}
)
if let game = try context.fetch(descriptor).first {
game.deprecatedAt = Date()
game.deprecationReason = "Deleted in CloudKit"
changed = true
}
case CKRecordType.team:
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.canonicalId == recordName && team.deprecatedAt == nil
}
)
if let team = try context.fetch(descriptor).first {
team.deprecatedAt = Date()
team.deprecationReason = "Deleted in CloudKit"
changed = true
}
// Avoid dangling schedule entries when a referenced team disappears.
let impactedGames = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil &&
(game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName)
}
)
for game in try context.fetch(impactedGames) {
game.deprecatedAt = Date()
game.deprecationReason = "Dependent team deleted in CloudKit"
changed = true
}
case CKRecordType.stadium:
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == recordName && stadium.deprecatedAt == nil
}
)
if let stadium = try context.fetch(descriptor).first {
stadium.deprecatedAt = Date()
stadium.deprecationReason = "Deleted in CloudKit"
changed = true
}
// Avoid dangling schedule entries when a referenced stadium disappears.
let impactedGames = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil && game.stadiumCanonicalId == recordName
}
)
for game in try context.fetch(impactedGames) {
game.deprecatedAt = Date()
game.deprecationReason = "Dependent stadium deleted in CloudKit"
changed = true
}
case CKRecordType.leagueStructure:
let descriptor = FetchDescriptor<LeagueStructureModel>(
predicate: #Predicate<LeagueStructureModel> { $0.id == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.teamAlias:
let descriptor = FetchDescriptor<TeamAlias>(
predicate: #Predicate<TeamAlias> { $0.id == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.stadiumAlias:
let descriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { $0.aliasName == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.sport:
let descriptor = FetchDescriptor<CanonicalSport>(
predicate: #Predicate<CanonicalSport> { $0.id == recordName }
)
if let sport = try context.fetch(descriptor).first, sport.isActive {
sport.isActive = false
sport.lastModified = Date()
changed = true
}
default:
break
}
guard changed else { return false }
try context.save()
await AppDataProvider.shared.loadInitialData()
logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)")
return true
} catch {
logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)")
return false
}
}
/// Trigger a manual full sync from settings or other explicit user action.
@MainActor
func triggerManualSync() async throws -> CanonicalSyncService.SyncResult {
guard let container = modelContainer else {
throw SyncTriggerError.modelContainerNotConfigured
}
let context = container.mainContext
let syncService = CanonicalSyncService()
let syncState = SyncState.current(in: context)
if !syncState.syncEnabled {
syncService.resumeSync(context: context)
}
return try await performSync(context: context)
}
/// Register CloudKit subscriptions for canonical data updates.
@MainActor
func ensureCanonicalSubscriptions() async {
do {
try await CloudKitService.shared.subscribeToAllUpdates()
logger.info("CloudKit subscriptions ensured for canonical sync")
} catch {
logger.error("Failed to register CloudKit subscriptions: \(error.localizedDescription)")
}
}
/// Clean up old game data (older than 1 year).
@MainActor
private func performCleanup(context: ModelContext) async -> Bool {

View File

@@ -114,17 +114,33 @@ actor BootstrapService {
// MARK: - Public Methods
/// Bootstrap canonical data from bundled JSON if not already done.
/// Bootstrap canonical data from bundled JSON if not already done,
/// or re-bootstrap if the bundled data schema version has been bumped.
/// This is the main entry point called at app launch.
@MainActor
func bootstrapIfNeeded(context: ModelContext) async throws {
let syncState = SyncState.current(in: context)
let hasCoreCanonicalData = hasRequiredCanonicalData(context: context)
// Skip if already bootstrapped
// Re-bootstrap if bundled data version is newer (e.g., updated game schedules)
let needsRebootstrap = syncState.bootstrapCompleted && syncState.bundledSchemaVersion < SchemaVersion.current
if needsRebootstrap {
syncState.bootstrapCompleted = false
}
// Recover from corrupted/partial local stores where bootstrap flag is true but core tables are empty.
if syncState.bootstrapCompleted && !hasCoreCanonicalData {
syncState.bootstrapCompleted = false
}
// Skip if already bootstrapped with current schema
guard !syncState.bootstrapCompleted else {
return
}
// Fresh bootstrap should always force a full CloudKit sync baseline.
resetSyncProgress(syncState)
// Clear any partial bootstrap data from a previous failed attempt
try clearCanonicalData(context: context)
@@ -156,6 +172,30 @@ actor BootstrapService {
}
}
@MainActor
private func resetSyncProgress(_ syncState: SyncState) {
syncState.lastSuccessfulSync = nil
syncState.lastSyncAttempt = nil
syncState.lastSyncError = nil
syncState.syncInProgress = false
syncState.syncEnabled = true
syncState.syncPausedReason = nil
syncState.consecutiveFailures = 0
syncState.stadiumChangeToken = nil
syncState.teamChangeToken = nil
syncState.gameChangeToken = nil
syncState.leagueChangeToken = nil
syncState.lastStadiumSync = nil
syncState.lastTeamSync = nil
syncState.lastGameSync = nil
syncState.lastLeagueStructureSync = nil
syncState.lastTeamAliasSync = nil
syncState.lastStadiumAliasSync = nil
syncState.lastSportSync = nil
}
// MARK: - Bootstrap Steps
@MainActor
@@ -188,7 +228,7 @@ actor BootstrapService {
capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened,
imageURL: jsonStadium.image_url,
sport: jsonStadium.sport,
sport: jsonStadium.sport.uppercased(),
timezoneIdentifier: jsonStadium.timezone_identifier
)
context.insert(canonical)
@@ -265,7 +305,7 @@ actor BootstrapService {
let model = LeagueStructureModel(
id: structure.id,
sport: structure.sport,
sport: structure.sport.uppercased(),
structureType: structureType,
name: structure.name,
abbreviation: structure.abbreviation,
@@ -302,7 +342,7 @@ actor BootstrapService {
source: .bundled,
name: jsonTeam.name,
abbreviation: jsonTeam.abbreviation,
sport: jsonTeam.sport,
sport: jsonTeam.sport.uppercased(),
city: jsonTeam.city,
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
conferenceId: jsonTeam.conference_id,
@@ -371,6 +411,8 @@ actor BootstrapService {
}
var seenGameIds = Set<String>()
let teams = (try? context.fetch(FetchDescriptor<CanonicalTeam>())) ?? []
let stadiumByTeamId = Dictionary(uniqueKeysWithValues: teams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
for jsonGame in games {
// Deduplicate
@@ -389,6 +431,21 @@ actor BootstrapService {
guard let dateTime else { continue }
let explicitStadium = jsonGame.stadium_canonical_id?
.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedStadiumCanonicalId: String
if let explicitStadium, !explicitStadium.isEmpty {
resolvedStadiumCanonicalId = explicitStadium
} else if let homeStadium = stadiumByTeamId[jsonGame.home_team_canonical_id],
!homeStadium.isEmpty {
resolvedStadiumCanonicalId = homeStadium
} else if let awayStadium = stadiumByTeamId[jsonGame.away_team_canonical_id],
!awayStadium.isEmpty {
resolvedStadiumCanonicalId = awayStadium
} else {
resolvedStadiumCanonicalId = "stadium_placeholder_\(jsonGame.canonical_id)"
}
let game = CanonicalGame(
canonicalId: jsonGame.canonical_id,
schemaVersion: SchemaVersion.current,
@@ -396,9 +453,9 @@ actor BootstrapService {
source: .bundled,
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
stadiumCanonicalId: jsonGame.stadium_canonical_id ?? "",
stadiumCanonicalId: resolvedStadiumCanonicalId,
dateTime: dateTime,
sport: jsonGame.sport,
sport: jsonGame.sport.uppercased(),
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast_info
@@ -454,6 +511,26 @@ actor BootstrapService {
try context.delete(model: CanonicalSport.self)
}
@MainActor
private func hasRequiredCanonicalData(context: ModelContext) -> Bool {
let stadiumCount = (try? context.fetchCount(
FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
let teamCount = (try? context.fetchCount(
FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
let gameCount = (try? context.fetchCount(
FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
return stadiumCount > 0 && teamCount > 0 && gameCount > 0
}
nonisolated private func parseISO8601(_ string: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]

View File

@@ -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)")
}
}

View File

@@ -62,14 +62,55 @@ enum CloudKitError: Error, LocalizedError {
actor CloudKitService {
static let shared = CloudKitService()
nonisolated static let gameUpdatesSubscriptionID = "game-updates"
nonisolated static let teamUpdatesSubscriptionID = "team-updates"
nonisolated static let stadiumUpdatesSubscriptionID = "stadium-updates"
nonisolated static let leagueStructureUpdatesSubscriptionID = "league-structure-updates"
nonisolated static let teamAliasUpdatesSubscriptionID = "team-alias-updates"
nonisolated static let stadiumAliasUpdatesSubscriptionID = "stadium-alias-updates"
nonisolated static let sportUpdatesSubscriptionID = "sport-updates"
nonisolated static let canonicalSubscriptionIDs: Set<String> = [
gameUpdatesSubscriptionID,
teamUpdatesSubscriptionID,
stadiumUpdatesSubscriptionID,
leagueStructureUpdatesSubscriptionID,
teamAliasUpdatesSubscriptionID,
stadiumAliasUpdatesSubscriptionID,
sportUpdatesSubscriptionID
]
nonisolated static func recordType(forSubscriptionID subscriptionID: String) -> String? {
switch subscriptionID {
case gameUpdatesSubscriptionID:
return "Game"
case teamUpdatesSubscriptionID:
return "Team"
case stadiumUpdatesSubscriptionID:
return "Stadium"
case leagueStructureUpdatesSubscriptionID:
return "LeagueStructure"
case teamAliasUpdatesSubscriptionID:
return "TeamAlias"
case stadiumAliasUpdatesSubscriptionID:
return "StadiumAlias"
case sportUpdatesSubscriptionID:
return "Sport"
default:
return nil
}
}
private let container: CKContainer
private let publicDatabase: CKDatabase
/// Maximum records per CloudKit query (400 is the default limit)
private let recordsPerPage = 400
/// Re-fetch a small overlap window to avoid missing updates around sync boundaries.
private let deltaOverlapSeconds: TimeInterval = 120
private init() {
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
// Use target entitlements (debug/prod) instead of hardcoding a container ID.
self.container = CKContainer.default()
self.publicDatabase = container.publicCloudDatabase
}
@@ -83,6 +124,8 @@ actor CloudKitService {
) async throws -> [CKRecord] {
var allRecords: [CKRecord] = []
var cursor: CKQueryOperation.Cursor?
var partialFailureCount = 0
var firstPartialFailure: Error?
// First page
let (firstResults, firstCursor) = try await publicDatabase.records(
@@ -91,8 +134,14 @@ actor CloudKitService {
)
for result in firstResults {
if case .success(let record) = result.1 {
switch result.1 {
case .success(let record):
allRecords.append(record)
case .failure(let error):
partialFailureCount += 1
if firstPartialFailure == nil {
firstPartialFailure = error
}
}
}
cursor = firstCursor
@@ -110,16 +159,51 @@ actor CloudKitService {
)
for result in results {
if case .success(let record) = result.1 {
switch result.1 {
case .success(let record):
allRecords.append(record)
case .failure(let error):
partialFailureCount += 1
if firstPartialFailure == nil {
firstPartialFailure = error
}
}
}
cursor = nextCursor
}
if partialFailureCount > 0 {
SyncLogger.shared.log("⚠️ [CK] \(query.recordType) query had \(partialFailureCount) per-record failures")
if allRecords.isEmpty, let firstPartialFailure {
throw firstPartialFailure
}
}
return allRecords
}
/// Normalizes a stored sync timestamp into a safe CloudKit delta start.
/// - Returns: `nil` when a full sync should be used.
private func effectiveDeltaStartDate(_ lastSync: Date?) -> Date? {
guard let lastSync else { return nil }
let now = Date()
if lastSync > now.addingTimeInterval(60) {
SyncLogger.shared.log("⚠️ [CK] Last sync timestamp is in the future; falling back to full sync")
return nil
}
return lastSync.addingTimeInterval(-deltaOverlapSeconds)
}
/// Fails fast when CloudKit returned records but none were parseable.
private func throwIfAllDropped(recordType: String, totalRecords: Int, parsedRecords: Int) throws {
guard totalRecords > 0, parsedRecords == 0 else { return }
let message = "All \(recordType) records were unparseable (\(totalRecords) fetched)"
SyncLogger.shared.log("❌ [CK] \(message)")
throw CloudKitError.serverError(message)
}
// MARK: - Sync Types (include canonical IDs from CloudKit)
struct SyncStadium {
@@ -130,7 +214,7 @@ actor CloudKitService {
struct SyncTeam {
let team: Team
let canonicalId: String
let stadiumCanonicalId: String
let stadiumCanonicalId: String?
}
struct SyncGame {
@@ -138,23 +222,29 @@ actor CloudKitService {
let canonicalId: String
let homeTeamCanonicalId: String
let awayTeamCanonicalId: String
let stadiumCanonicalId: String
let stadiumCanonicalId: String?
}
// MARK: - Availability Check
func isAvailable() async -> Bool {
let status = await checkAccountStatus()
return status == .available
switch status {
case .available, .noAccount, .couldNotDetermine:
// Public DB reads should still be attempted without an iCloud account.
return true
case .restricted, .temporarilyUnavailable:
return false
@unknown default:
return false
}
}
func checkAvailabilityWithError() async throws {
let status = await checkAccountStatus()
switch status {
case .available:
case .available, .noAccount:
return
case .noAccount:
throw CloudKitError.notSignedIn
case .restricted:
throw CloudKitError.permissionDenied
case .couldNotDetermine:
@@ -268,23 +358,39 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching stadiums modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL stadiums (full sync)")
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) stadium records from CloudKit")
return records.compactMap { record -> SyncStadium? in
var validStadiums: [SyncStadium] = []
var skipped = 0
for record in records {
let ckStadium = CKStadium(record: record)
guard let stadium = ckStadium.stadium,
let canonicalId = ckStadium.canonicalId
else { return nil }
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
else {
skipped += 1
continue
}
validStadiums.append(SyncStadium(stadium: stadium, canonicalId: canonicalId))
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) stadium records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.stadium, totalRecords: records.count, parsedRecords: validStadiums.count)
return validStadiums
}
/// Fetch teams for sync operations
@@ -292,24 +398,47 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching teams modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL teams (full sync)")
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) team records from CloudKit")
return records.compactMap { record -> SyncTeam? in
var validTeams: [SyncTeam] = []
var skipped = 0
var missingStadiumRef = 0
for record in records {
let ckTeam = CKTeam(record: record)
guard let team = ckTeam.team,
let canonicalId = ckTeam.canonicalId,
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
else { return nil }
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
let canonicalId = ckTeam.canonicalId
else {
skipped += 1
continue
}
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
if stadiumCanonicalId == nil {
missingStadiumRef += 1
}
validTeams.append(SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId))
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) team records due to missing required fields")
}
if missingStadiumRef > 0 {
log.log("⚠️ [CK] \(missingStadiumRef) team records are missing stadium refs; merge will preserve local stadiums when possible")
}
try throwIfAllDropped(recordType: CKRecordType.team, totalRecords: records.count, parsedRecords: validTeams.count)
return validTeams
}
/// Fetch games for sync operations
@@ -319,9 +448,9 @@ actor CloudKitService {
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())")
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching games modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL games (full sync)")
predicate = NSPredicate(value: true)
@@ -334,23 +463,28 @@ actor CloudKitService {
var validGames: [SyncGame] = []
var skippedMissingIds = 0
var skippedInvalidGame = 0
var missingStadiumRef = 0
for record in records {
let ckGame = CKGame(record: record)
guard let canonicalId = ckGame.canonicalId,
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
let stadiumCanonicalId = ckGame.stadiumCanonicalId
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId
else {
skippedMissingIds += 1
continue
}
let stadiumCanonicalId = ckGame.stadiumCanonicalId
if stadiumCanonicalId == nil {
missingStadiumRef += 1
}
guard let game = ckGame.game(
homeTeamId: homeTeamCanonicalId,
awayTeamId: awayTeamCanonicalId,
stadiumId: stadiumCanonicalId
stadiumId: stadiumCanonicalId ?? "stadium_placeholder_\(canonicalId)"
) else {
skippedInvalidGame += 1
continue
@@ -366,6 +500,10 @@ actor CloudKitService {
}
log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)")
if missingStadiumRef > 0 {
log.log("⚠️ [CK] \(missingStadiumRef) games are missing stadium refs; merge will derive stadiums from team mappings when possible")
}
try throwIfAllDropped(recordType: CKRecordType.game, totalRecords: records.count, parsedRecords: validGames.count)
// Log sport breakdown
var bySport: [String: Int] = [:]
@@ -443,20 +581,37 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching league structures modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL league structures (full sync)")
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit")
return records.compactMap { record in
return CKLeagueStructure(record: record).toModel()
var parsed: [LeagueStructureModel] = []
var skipped = 0
for record in records {
if let model = CKLeagueStructure(record: record).toModel() {
parsed.append(model)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) league structure records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.leagueStructure, totalRecords: records.count, parsedRecords: parsed.count)
return parsed.sorted { lhs, rhs in
lhs.lastModified < rhs.lastModified
}
}
@@ -465,20 +620,37 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching team aliases modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL team aliases (full sync)")
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit")
return records.compactMap { record in
return CKTeamAlias(record: record).toModel()
var parsed: [TeamAlias] = []
var skipped = 0
for record in records {
if let model = CKTeamAlias(record: record).toModel() {
parsed.append(model)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) team alias records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.teamAlias, totalRecords: records.count, parsedRecords: parsed.count)
return parsed.sorted { lhs, rhs in
lhs.lastModified < rhs.lastModified
}
}
@@ -487,20 +659,37 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching stadium aliases modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL stadium aliases (full sync)")
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit")
return records.compactMap { record in
return CKStadiumAlias(record: record).toModel()
var parsed: [StadiumAlias] = []
var skipped = 0
for record in records {
if let model = CKStadiumAlias(record: record).toModel() {
parsed.append(model)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) stadium alias records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.stadiumAlias, totalRecords: records.count, parsedRecords: parsed.count)
return parsed.sorted { lhs, rhs in
lhs.lastModified < rhs.lastModified
}
}
@@ -511,19 +700,36 @@ actor CloudKitService {
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
if let deltaStart = effectiveDeltaStartDate(lastSync) {
log.log("☁️ [CK] Fetching sports modified since \(deltaStart.formatted()) (overlap window applied)")
predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate)
} else {
log.log("☁️ [CK] Fetching ALL sports (full sync)")
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) sport records from CloudKit")
return records.compactMap { record -> CanonicalSport? in
return CKSport(record: record).toCanonical()
var parsed: [CanonicalSport] = []
var skipped = 0
for record in records {
if let sport = CKSport(record: record).toCanonical() {
parsed.append(sport)
} else {
skipped += 1
}
}
if skipped > 0 {
log.log("⚠️ [CK] Skipped \(skipped) sport records due to missing required fields")
}
try throwIfAllDropped(recordType: CKRecordType.sport, totalRecords: records.count, parsedRecords: parsed.count)
return parsed
}
// MARK: - Sync Status
@@ -542,83 +748,130 @@ actor CloudKitService {
let subscription = CKQuerySubscription(
recordType: CKRecordType.game,
predicate: NSPredicate(value: true),
subscriptionID: "game-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
subscriptionID: Self.gameUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToTeamUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.team,
predicate: NSPredicate(value: true),
subscriptionID: Self.teamUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToStadiumUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.stadium,
predicate: NSPredicate(value: true),
subscriptionID: Self.stadiumUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToLeagueStructureUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.leagueStructure,
predicate: NSPredicate(value: true),
subscriptionID: "league-structure-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
subscriptionID: Self.leagueStructureUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToTeamAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.teamAlias,
predicate: NSPredicate(value: true),
subscriptionID: "team-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
subscriptionID: Self.teamAliasUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToStadiumAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.stadiumAlias,
predicate: NSPredicate(value: true),
subscriptionID: "stadium-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
subscriptionID: Self.stadiumAliasUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
try await saveSubscriptionIfNeeded(subscription)
}
func subscribeToSportUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.sport,
predicate: NSPredicate(value: true),
subscriptionID: "sport-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
subscriptionID: Self.sportUpdatesSubscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
try await saveSubscriptionIfNeeded(subscription)
}
/// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates()
try await subscribeToTeamUpdates()
try await subscribeToStadiumUpdates()
try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates()
try await subscribeToStadiumAliasUpdates()
try await subscribeToSportUpdates()
}
private func saveSubscriptionIfNeeded(_ subscription: CKQuerySubscription) async throws {
do {
try await publicDatabase.save(subscription)
} catch let error as CKError where error.code == .serverRejectedRequest {
// Existing subscriptions can be rejected as duplicates in some environments.
// Confirm it exists before treating this as non-fatal.
do {
_ = try await publicDatabase.subscription(for: subscription.subscriptionID)
SyncLogger.shared.log(" [CK] Subscription already exists: \(subscription.subscriptionID)")
} catch {
throw error
}
}
}
}

View File

@@ -221,22 +221,26 @@ final class AppDataProvider: ObservableObject {
var richGames: [RichGame] = []
var droppedGames: [(game: Game, reason: String)] = []
var stadiumFallbacksApplied = 0
for game in games {
let homeTeam = teamsById[game.homeTeamId]
let awayTeam = teamsById[game.awayTeamId]
let stadium = stadiumsById[game.stadiumId]
let resolvedStadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam)
if resolvedStadium?.id != game.stadiumId {
stadiumFallbacksApplied += 1
}
if homeTeam == nil || awayTeam == nil || stadium == nil {
if homeTeam == nil || awayTeam == nil || resolvedStadium == nil {
var reasons: [String] = []
if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") }
if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") }
if stadium == nil { reasons.append("stadium(\(game.stadiumId))") }
if resolvedStadium == nil { reasons.append("stadium(\(game.stadiumId))") }
droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))"))
continue
}
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: stadium!))
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!))
}
if !droppedGames.isEmpty {
@@ -248,6 +252,9 @@ final class AppDataProvider: ObservableObject {
print("⚠️ [DATA] ... and \(droppedGames.count - 10) more")
}
}
if stadiumFallbacksApplied > 0 {
print("⚠️ [DATA] Applied stadium fallback for \(stadiumFallbacksApplied) games")
}
print("🎮 [DATA] Returning \(richGames.count) rich games")
return richGames
@@ -260,7 +267,7 @@ final class AppDataProvider: ObservableObject {
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
@@ -270,7 +277,7 @@ final class AppDataProvider: ObservableObject {
func richGame(from game: Game) -> RichGame? {
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
@@ -299,13 +306,27 @@ final class AppDataProvider: ObservableObject {
let game = canonical.toDomain()
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
continue
}
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
}
return teamGames
}
// Resolve stadium defensively: direct game reference first, then team home-venue fallbacks.
private func resolveStadium(for game: Game, homeTeam: Team?, awayTeam: Team?) -> Stadium? {
if let stadium = stadiumsById[game.stadiumId] {
return stadium
}
if let homeTeam, let fallback = stadiumsById[homeTeam.stadiumId] {
return fallback
}
if let awayTeam, let fallback = stadiumsById[awayTeam.stadiumId] {
return fallback
}
return nil
}
}
// MARK: - Errors

View File

@@ -5,7 +5,7 @@ import CloudKit
actor ItineraryItemService {
static let shared = ItineraryItemService()
private let container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
private let container = CKContainer.default()
private var database: CKDatabase { container.privateCloudDatabase }
private let recordType = "ItineraryItem"

View File

@@ -52,7 +52,8 @@ actor PollService {
private var pollSubscriptionID: CKSubscription.ID?
private init() {
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
// Respect target entitlements so Debug and production stay isolated.
self.container = CKContainer.default()
self.publicDatabase = container.publicCloudDatabase
}

View File

@@ -10,14 +10,14 @@ import os
/// Protocol for cancellation tokens checked between sync pages
protocol SyncCancellationToken: Sendable {
var isCancelled: Bool { get }
nonisolated var isCancelled: Bool { get }
}
/// Concrete cancellation token for background tasks
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
private let lock = OSAllocatedUnfairLock(initialState: false)
var isCancelled: Bool {
nonisolated var isCancelled: Bool {
lock.withLock { $0 }
}

View File

@@ -8,7 +8,7 @@
import Foundation
final class SyncLogger {
nonisolated final class SyncLogger {
static let shared = SyncLogger()
private let fileURL: URL

View File

@@ -63,7 +63,7 @@ final class VisitPhotoService {
init(modelContext: ModelContext) {
self.modelContext = modelContext
self.container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime")
self.container = CKContainer.default()
self.privateDatabase = container.privateCloudDatabase
}