From 5686af262f66123eacb7268ec8ed2d5fc4416fee Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 19:18:55 -0600 Subject: [PATCH 1/2] feat(sync): add pagination, cancellation, and network restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CloudKit pagination: fetchAllRecords() handles >400 record batches with cursor-based pagination (400 records per page) - Cancellation support: SyncCancellationToken protocol enables graceful sync termination when background tasks expire - Per-entity progress: SyncState now tracks timestamps per entity type so interrupted syncs resume where they left off - NetworkMonitor: NWPathMonitor integration triggers sync on network restoration with 2.5s debounce to handle WiFi↔cellular flapping - wasCancelled flag in SyncResult distinguishes partial from full syncs This addresses critical data sync issues: - CloudKit queries were limited to ~400 records but bundled data has ~5000 games - Background tasks could be killed mid-sync without saving progress - App had no awareness of network state changes Co-Authored-By: Claude Opus 4.5 --- .../Core/Models/Local/CanonicalModels.swift | 9 + .../Core/Services/BackgroundSyncManager.swift | 133 +- .../Core/Services/CanonicalSyncService.swift | 141 +- .../Core/Services/CloudKitService.swift | 124 +- SportsTime/Core/Services/NetworkMonitor.swift | 123 ++ .../Core/Services/SyncCancellationToken.swift | 27 + SportsTime/SportsTimeApp.swift | 12 +- ...6-01-13-sync-reliability-implementation.md | 1132 +++++++++++++++++ 8 files changed, 1607 insertions(+), 94 deletions(-) create mode 100644 SportsTime/Core/Services/NetworkMonitor.swift create mode 100644 SportsTime/Core/Services/SyncCancellationToken.swift create mode 100644 docs/plans/2026-01-13-sync-reliability-implementation.md diff --git a/SportsTime/Core/Models/Local/CanonicalModels.swift b/SportsTime/Core/Models/Local/CanonicalModels.swift index bde61a5..9ec4fa8 100644 --- a/SportsTime/Core/Models/Local/CanonicalModels.swift +++ b/SportsTime/Core/Models/Local/CanonicalModels.swift @@ -69,6 +69,15 @@ final class SyncState { var gameChangeToken: Data? var leagueChangeToken: Data? + // Per-entity sync timestamps for partial progress + var lastStadiumSync: Date? + var lastTeamSync: Date? + var lastGameSync: Date? + var lastLeagueStructureSync: Date? + var lastTeamAliasSync: Date? + var lastStadiumAliasSync: Date? + var lastSportSync: Date? + init() {} static func current(in context: ModelContext) -> SyncState { diff --git a/SportsTime/Core/Services/BackgroundSyncManager.swift b/SportsTime/Core/Services/BackgroundSyncManager.swift index acf7d50..3451249 100644 --- a/SportsTime/Core/Services/BackgroundSyncManager.swift +++ b/SportsTime/Core/Services/BackgroundSyncManager.swift @@ -9,6 +9,7 @@ import Foundation import BackgroundTasks import SwiftData +import os /// Manages background refresh and processing tasks for CloudKit sync. @MainActor @@ -29,6 +30,8 @@ final class BackgroundSyncManager { // MARK: - Properties private var modelContainer: ModelContainer? + private var currentCancellationToken: BackgroundTaskCancellationToken? + private let logger = Logger(subsystem: "com.sportstime.app", category: "BackgroundSyncManager") private init() {} @@ -130,29 +133,41 @@ final class BackgroundSyncManager { /// Handle the background refresh task. @MainActor private func handleRefreshTask(_ task: BGAppRefreshTask) async { - print("Background refresh task started") + logger.info("Background refresh task started") // Schedule the next refresh before we start (in case we get terminated) scheduleRefresh() - // Set up expiration handler - task.expirationHandler = { - print("Background refresh task expired") - task.setTaskCompleted(success: false) + // Create cancellation token for this task + let cancellationToken = BackgroundTaskCancellationToken() + currentCancellationToken = cancellationToken + + // Set up expiration handler - cancel sync gracefully + task.expirationHandler = { [weak self] in + self?.logger.warning("Background refresh task expiring - cancelling sync") + cancellationToken.cancel() } guard let container = modelContainer else { - print("Background refresh: No model container configured") + logger.error("Background refresh: No model container configured") task.setTaskCompleted(success: false) return } do { - let success = try await performSync(context: container.mainContext) - task.setTaskCompleted(success: success) - print("Background refresh completed: \(success ? "success" : "no updates")") + let result = try await performSync(context: container.mainContext, cancellationToken: cancellationToken) + currentCancellationToken = nil + + if result.wasCancelled { + logger.info("Background refresh cancelled with partial progress: \(result.totalUpdated) items synced") + task.setTaskCompleted(success: true) // Partial success - progress was saved + } else { + logger.info("Background refresh completed: \(result.totalUpdated) items updated") + task.setTaskCompleted(success: !result.isEmpty) + } } catch { - print("Background refresh failed: \(error.localizedDescription)") + currentCancellationToken = nil + logger.error("Background refresh failed: \(error.localizedDescription)") task.setTaskCompleted(success: false) } } @@ -160,34 +175,48 @@ final class BackgroundSyncManager { /// Handle the background processing task (overnight heavy sync). @MainActor private func handleProcessingTask(_ task: BGProcessingTask) async { - print("Background processing task started") + logger.info("Background processing task started") // Schedule the next processing task scheduleProcessingTask() - // Set up expiration handler - task.expirationHandler = { - print("Background processing task expired") - task.setTaskCompleted(success: false) + // Create cancellation token for this task + let cancellationToken = BackgroundTaskCancellationToken() + currentCancellationToken = cancellationToken + + // Set up expiration handler - cancel sync gracefully + task.expirationHandler = { [weak self] in + self?.logger.warning("Background processing task expiring - cancelling sync") + cancellationToken.cancel() } guard let container = modelContainer else { - print("Background processing: No model container configured") + logger.error("Background processing: No model container configured") task.setTaskCompleted(success: false) return } do { // 1. Full sync from CloudKit - let syncSuccess = try await performSync(context: container.mainContext) + let syncResult = try await performSync(context: container.mainContext, cancellationToken: cancellationToken) + currentCancellationToken = nil - // 2. Clean up old data (games older than 1 year) - let cleanupSuccess = await performCleanup(context: container.mainContext) + // 2. Clean up old data (games older than 1 year) - only if sync wasn't cancelled + var cleanupSuccess = false + if !syncResult.wasCancelled { + cleanupSuccess = await performCleanup(context: container.mainContext) + } - task.setTaskCompleted(success: syncSuccess || cleanupSuccess) - print("Background processing completed: sync=\(syncSuccess), cleanup=\(cleanupSuccess)") + if syncResult.wasCancelled { + logger.info("Background processing cancelled with partial progress: \(syncResult.totalUpdated) items synced") + task.setTaskCompleted(success: true) // Partial success + } else { + logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)") + task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess) + } } catch { - print("Background processing failed: \(error.localizedDescription)") + currentCancellationToken = nil + logger.error("Background processing failed: \(error.localizedDescription)") task.setTaskCompleted(success: false) } } @@ -195,30 +224,70 @@ final class BackgroundSyncManager { // MARK: - Sync Operations /// Perform CloudKit sync operation. + /// - Parameters: + /// - context: The ModelContext to use + /// - cancellationToken: Optional token for graceful cancellation + /// - Returns: The sync result with counts and cancellation status @MainActor - private func performSync(context: ModelContext) async throws -> Bool { + private func performSync( + context: ModelContext, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> CanonicalSyncService.SyncResult { let syncService = CanonicalSyncService() do { - let result = try await syncService.syncAll(context: context) + let result = try await syncService.syncAll(context: context, cancellationToken: cancellationToken) // Reload DataProvider if data changed - if !result.isEmpty { + if !result.isEmpty || result.wasCancelled { await AppDataProvider.shared.loadInitialData() - print("Background sync: \(result.totalUpdated) items updated") - return true + logger.info("Sync completed: \(result.totalUpdated) items updated, cancelled: \(result.wasCancelled)") } - return false + return result } catch CanonicalSyncService.SyncError.cloudKitUnavailable { // No network - this is expected, not a failure - print("Background sync: CloudKit unavailable (offline)") - return false + logger.info("CloudKit unavailable (offline)") + return CanonicalSyncService.SyncResult( + stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, + leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, + sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, + duration: 0, wasCancelled: false + ) } catch CanonicalSyncService.SyncError.syncAlreadyInProgress { // Another sync is running - that's fine - print("Background sync: Already in progress") - return false + logger.info("Sync already in progress") + return CanonicalSyncService.SyncResult( + stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, + leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, + sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, + duration: 0, wasCancelled: false + ) + } + } + + /// Trigger sync from network restoration (called by NetworkMonitor). + /// This is a non-background task sync, so no cancellation token is needed. + @MainActor + func triggerSyncFromNetworkRestoration() async { + guard let container = modelContainer else { + logger.error("Network sync: No model container configured") + return + } + + logger.info("Triggering sync from network restoration") + + do { + let result = try await performSync(context: container.mainContext) + + if !result.isEmpty { + logger.info("Network restoration sync completed: \(result.totalUpdated) items updated") + } else { + logger.info("Network restoration sync: no updates needed") + } + } catch { + logger.error("Network restoration sync failed: \(error.localizedDescription)") } } diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift index ccd889d..c27175a 100644 --- a/SportsTime/Core/Services/CanonicalSyncService.swift +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -47,12 +47,13 @@ actor CanonicalSyncService { let skippedIncompatible: Int let skippedOlder: Int let duration: TimeInterval + let wasCancelled: Bool var totalUpdated: Int { stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated } - var isEmpty: Bool { totalUpdated == 0 } + var isEmpty: Bool { totalUpdated == 0 && !wasCancelled } } // MARK: - Properties @@ -69,8 +70,11 @@ actor CanonicalSyncService { /// Perform a full sync of all canonical data types. /// This is the main entry point for background sync. + /// - Parameters: + /// - context: The ModelContext to use for saving data + /// - cancellationToken: Optional token to check for cancellation between entity syncs @MainActor - func syncAll(context: ModelContext) async throws -> SyncResult { + func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult { let startTime = Date() let syncState = SyncState.current(in: context) @@ -85,7 +89,7 @@ actor CanonicalSyncService { stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0, sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0, - duration: 0 + duration: 0, wasCancelled: false ) } @@ -107,73 +111,148 @@ actor CanonicalSyncService { var totalSports = 0 var totalSkippedIncompatible = 0 var totalSkippedOlder = 0 + var wasCancelled = false + + /// Helper to save partial progress and check cancellation + func saveProgressAndCheckCancellation() throws -> Bool { + try context.save() + if cancellationToken?.isCancelled == true { + return true + } + return false + } do { - // Sync in dependency order + // Sync in dependency order, checking cancellation between each entity type let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalStadiums = stadiums totalSkippedIncompatible += skipIncompat1 totalSkippedOlder += skipOlder1 + syncState.lastStadiumSync = Date() + if try saveProgressAndCheckCancellation() { + wasCancelled = true + throw CancellationError() + } let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalLeagueStructures = leagueStructures totalSkippedIncompatible += skipIncompat2 totalSkippedOlder += skipOlder2 + syncState.lastLeagueStructureSync = Date() + if try saveProgressAndCheckCancellation() { + wasCancelled = true + throw CancellationError() + } let (teams, skipIncompat3, skipOlder3) = try await syncTeams( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalTeams = teams totalSkippedIncompatible += skipIncompat3 totalSkippedOlder += skipOlder3 + syncState.lastTeamSync = Date() + if try saveProgressAndCheckCancellation() { + wasCancelled = true + throw CancellationError() + } let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalTeamAliases = teamAliases totalSkippedIncompatible += skipIncompat4 totalSkippedOlder += skipOlder4 + syncState.lastTeamAliasSync = Date() + if try saveProgressAndCheckCancellation() { + wasCancelled = true + throw CancellationError() + } let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalStadiumAliases = stadiumAliases totalSkippedIncompatible += skipIncompat5 totalSkippedOlder += skipOlder5 + syncState.lastStadiumAliasSync = Date() + if try saveProgressAndCheckCancellation() { + wasCancelled = true + throw CancellationError() + } let (games, skipIncompat6, skipOlder6) = try await syncGames( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastGameSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalGames = games totalSkippedIncompatible += skipIncompat6 totalSkippedOlder += skipOlder6 + syncState.lastGameSync = Date() + if try saveProgressAndCheckCancellation() { + wasCancelled = true + throw CancellationError() + } let (sports, skipIncompat7, skipOlder7) = try await syncSports( context: context, - since: syncState.lastSuccessfulSync + since: syncState.lastSportSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken ) totalSports = sports totalSkippedIncompatible += skipIncompat7 totalSkippedOlder += skipOlder7 + syncState.lastSportSync = Date() - // Mark sync successful + // Mark sync successful - clear per-entity timestamps since full sync completed syncState.syncInProgress = false syncState.lastSuccessfulSync = Date() syncState.lastSyncError = nil syncState.consecutiveFailures = 0 + // Clear per-entity timestamps - they're only needed for partial recovery + syncState.lastStadiumSync = nil + syncState.lastTeamSync = nil + syncState.lastGameSync = nil + syncState.lastLeagueStructureSync = nil + syncState.lastTeamAliasSync = nil + syncState.lastStadiumAliasSync = nil + syncState.lastSportSync = nil try context.save() + } catch is CancellationError { + // Graceful cancellation - progress already saved + syncState.syncInProgress = false + syncState.lastSyncError = "Sync cancelled - partial progress saved" + try? context.save() + + return SyncResult( + stadiumsUpdated: totalStadiums, + teamsUpdated: totalTeams, + gamesUpdated: totalGames, + leagueStructuresUpdated: totalLeagueStructures, + teamAliasesUpdated: totalTeamAliases, + stadiumAliasesUpdated: totalStadiumAliases, + sportsUpdated: totalSports, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + duration: Date().timeIntervalSince(startTime), + wasCancelled: true + ) } catch { // Mark sync failed syncState.syncInProgress = false @@ -200,7 +279,8 @@ actor CanonicalSyncService { sportsUpdated: totalSports, skippedIncompatible: totalSkippedIncompatible, skippedOlder: totalSkippedOlder, - duration: Date().timeIntervalSince(startTime) + duration: Date().timeIntervalSince(startTime), + wasCancelled: false ) } @@ -219,10 +299,11 @@ actor CanonicalSyncService { @MainActor private func syncStadiums( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Delta sync: nil = all stadiums, Date = only modified since - let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync) + let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 @@ -249,10 +330,11 @@ actor CanonicalSyncService { @MainActor private func syncTeams( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Single call for all teams with delta sync - let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync) + let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 @@ -280,10 +362,11 @@ actor CanonicalSyncService { @MainActor private func syncGames( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { // Delta sync: nil = all games, Date = only modified since - let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync) + let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 @@ -313,9 +396,10 @@ actor CanonicalSyncService { @MainActor private func syncLeagueStructure( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { - let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync) + let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 @@ -337,9 +421,10 @@ actor CanonicalSyncService { @MainActor private func syncTeamAliases( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { - let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync) + let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 @@ -361,9 +446,10 @@ actor CanonicalSyncService { @MainActor private func syncStadiumAliases( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { - let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync) + let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 @@ -385,9 +471,10 @@ actor CanonicalSyncService { @MainActor private func syncSports( context: ModelContext, - since lastSync: Date? + since lastSync: Date?, + cancellationToken: SyncCancellationToken? ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { - let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync) + let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync, cancellationToken: cancellationToken) var updated = 0 var skippedIncompatible = 0 diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index 2e86e0b..df23847 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -65,11 +65,61 @@ actor CloudKitService { private let container: CKContainer private let publicDatabase: CKDatabase + /// Maximum records per CloudKit query (400 is the default limit) + private let recordsPerPage = 400 + private init() { self.container = CKContainer(identifier: "iCloud.com.sportstime.app") self.publicDatabase = container.publicCloudDatabase } + // MARK: - Pagination Helper + + /// Fetches all records matching a query using cursor-based pagination. + /// Checks cancellation token between pages to allow graceful interruption. + private func fetchAllRecords( + matching query: CKQuery, + cancellationToken: SyncCancellationToken? + ) async throws -> [CKRecord] { + var allRecords: [CKRecord] = [] + var cursor: CKQueryOperation.Cursor? + + // First page + let (firstResults, firstCursor) = try await publicDatabase.records( + matching: query, + resultsLimit: recordsPerPage + ) + + for result in firstResults { + if case .success(let record) = result.1 { + allRecords.append(record) + } + } + cursor = firstCursor + + // Continue fetching pages while cursor exists + while let currentCursor = cursor { + // Check cancellation between pages + if cancellationToken?.isCancelled == true { + throw CancellationError() + } + + let (results, nextCursor) = try await publicDatabase.records( + continuingMatchFrom: currentCursor, + resultsLimit: recordsPerPage + ) + + for result in results { + if case .success(let record) = result.1 { + allRecords.append(record) + } + } + cursor = nextCursor + } + + return allRecords + } + // MARK: - Sync Types (include canonical IDs from CloudKit) struct SyncStadium { @@ -214,8 +264,10 @@ actor CloudKitService { // MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit) /// Fetch stadiums for sync operations - /// - Parameter lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date. - func fetchStadiumsForSync(since lastSync: Date?) async throws -> [SyncStadium] { + /// - Parameters: + /// - 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 predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) @@ -224,10 +276,9 @@ actor CloudKitService { } let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result -> SyncStadium? in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record -> SyncStadium? in let ckStadium = CKStadium(record: record) guard let stadium = ckStadium.stadium, let canonicalId = ckStadium.canonicalId @@ -237,8 +288,10 @@ actor CloudKitService { } /// Fetch teams for sync operations - /// - Parameter lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date. - func fetchTeamsForSync(since lastSync: Date?) async throws -> [SyncTeam] { + /// - Parameters: + /// - 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 predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) @@ -247,10 +300,9 @@ actor CloudKitService { } let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result -> SyncTeam? in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record -> SyncTeam? in let ckTeam = CKTeam(record: record) guard let team = ckTeam.team, let canonicalId = ckTeam.canonicalId, @@ -261,8 +313,10 @@ actor CloudKitService { } /// Fetch games for sync operations - /// - Parameter lastSync: If nil, fetches all games. If provided, fetches only games modified since that date. - func fetchGamesForSync(since lastSync: Date?) async throws -> [SyncGame] { + /// - Parameters: + /// - lastSync: If nil, fetches all games. If provided, fetches only games modified since that date. + /// - cancellationToken: Optional token to check for cancellation between pages + func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] { let predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) @@ -271,10 +325,9 @@ actor CloudKitService { } let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result -> SyncGame? in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record -> SyncGame? in let ckGame = CKGame(record: record) guard let canonicalId = ckGame.canonicalId, @@ -359,7 +412,10 @@ actor CloudKitService { // MARK: - Delta Sync (Date-Based for Public Database) /// Fetch league structure records modified after the given date - func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] { + /// - Parameters: + /// - 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 predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) @@ -370,16 +426,18 @@ actor CloudKitService { let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)] - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record in return CKLeagueStructure(record: record).toModel() } } /// Fetch team alias records modified after the given date - func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] { + /// - Parameters: + /// - 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 predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) @@ -390,16 +448,18 @@ actor CloudKitService { let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)] - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record in return CKTeamAlias(record: record).toModel() } } /// Fetch stadium alias records modified after the given date - func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] { + /// - Parameters: + /// - 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 predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) @@ -410,10 +470,9 @@ actor CloudKitService { let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)] - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record in return CKStadiumAlias(record: record).toModel() } } @@ -421,8 +480,10 @@ actor CloudKitService { // MARK: - Sport Sync /// Fetch sports for sync operations - /// - Parameter lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date. - func fetchSportsForSync(since lastSync: Date?) async throws -> [CanonicalSport] { + /// - Parameters: + /// - 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 predicate: NSPredicate if let lastSync = lastSync { predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) @@ -431,10 +492,9 @@ actor CloudKitService { } let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate) - let (results, _) = try await publicDatabase.records(matching: query) + let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) - return results.compactMap { result -> CanonicalSport? in - guard case .success(let record) = result.1 else { return nil } + return records.compactMap { record -> CanonicalSport? in return CKSport(record: record).toCanonical() } } diff --git a/SportsTime/Core/Services/NetworkMonitor.swift b/SportsTime/Core/Services/NetworkMonitor.swift new file mode 100644 index 0000000..4f9aa2e --- /dev/null +++ b/SportsTime/Core/Services/NetworkMonitor.swift @@ -0,0 +1,123 @@ +// +// NetworkMonitor.swift +// SportsTime +// +// Monitors network connectivity using NWPathMonitor and triggers +// sync when connectivity is restored after being offline. +// + +import Foundation +import Network +import os + +/// Monitors network state and triggers sync on connectivity restoration. +@MainActor +final class NetworkMonitor { + + // MARK: - Properties + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.sportstime.networkmonitor") + private let logger = Logger(subsystem: "com.sportstime.app", category: "NetworkMonitor") + + /// Current network status + private(set) var isConnected: Bool = false + + /// Whether the network has ever been in a disconnected state during this session + private var hasBeenOffline: Bool = false + + /// Debounce timer to avoid rapid sync triggers during network flapping + private var debounceTask: Task? + + /// Debounce delay in seconds (2.5s to handle WiFi↔cellular handoffs) + private let debounceDelay: TimeInterval = 2.5 + + /// Callback for when sync should be triggered + var onSyncNeeded: (() async -> Void)? + + // MARK: - Singleton + + static let shared = NetworkMonitor() + + private init() {} + + // MARK: - Lifecycle + + /// Start monitoring network state. + /// Call this during app initialization. + func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + await self?.handlePathUpdate(path) + } + } + monitor.start(queue: queue) + logger.info("Network monitoring started") + } + + /// Stop monitoring network state. + /// Call this during app termination. + func stopMonitoring() { + monitor.cancel() + debounceTask?.cancel() + debounceTask = nil + logger.info("Network monitoring stopped") + } + + // MARK: - Path Update Handler + + private func handlePathUpdate(_ path: NWPath) async { + let wasConnected = isConnected + isConnected = path.status == .satisfied + + logger.debug("Network path update: \(path.status == .satisfied ? "connected" : "disconnected")") + + if !isConnected { + // Mark that we've been offline + hasBeenOffline = true + // Cancel any pending sync trigger + debounceTask?.cancel() + debounceTask = nil + logger.info("Network disconnected - sync will trigger on reconnection") + } else if !wasConnected && hasBeenOffline { + // We just reconnected after being offline + logger.info("Network restored - scheduling debounced sync") + scheduleDebouncedSync() + } + } + + // MARK: - Debounced Sync + + private func scheduleDebouncedSync() { + // Cancel any existing debounce task + debounceTask?.cancel() + + debounceTask = Task { @MainActor [weak self] in + guard let self = self else { return } + + // Wait for debounce delay + do { + try await Task.sleep(for: .seconds(self.debounceDelay)) + } catch { + // Task was cancelled (network went offline again or app terminating) + return + } + + // Double-check we're still connected + guard self.isConnected else { + self.logger.debug("Network disconnected during debounce - cancelling sync") + return + } + + self.logger.info("Triggering sync after network restoration") + + // Reset the offline flag since we're now syncing + self.hasBeenOffline = false + + // Trigger the sync callback + if let onSyncNeeded = self.onSyncNeeded { + await onSyncNeeded() + } + } + } +} diff --git a/SportsTime/Core/Services/SyncCancellationToken.swift b/SportsTime/Core/Services/SyncCancellationToken.swift new file mode 100644 index 0000000..7a383d7 --- /dev/null +++ b/SportsTime/Core/Services/SyncCancellationToken.swift @@ -0,0 +1,27 @@ +// +// SyncCancellationToken.swift +// SportsTime +// +// Cancellation support for long-running sync operations. +// + +import Foundation +import os + +/// Protocol for cancellation tokens checked between sync pages +protocol SyncCancellationToken: Sendable { + 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 { + lock.withLock { $0 } + } + + func cancel() { + lock.withLock { $0 = true } + } +} diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 0dcb844..d1c37ae 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -152,13 +152,19 @@ struct BootstrappedContentView: View { await StoreManager.shared.loadProducts() await StoreManager.shared.updateEntitlements() - // 6. App is now usable + // 6. Start network monitoring and wire up sync callback + NetworkMonitor.shared.onSyncNeeded = { + await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration() + } + NetworkMonitor.shared.startMonitoring() + + // 7. App is now usable isBootstrapping = false - // 7. Schedule background tasks for future syncs + // 8. Schedule background tasks for future syncs BackgroundSyncManager.shared.scheduleAllTasks() - // 8. Background: Try to refresh from CloudKit (non-blocking) + // 9. Background: Try to refresh from CloudKit (non-blocking) Task.detached(priority: .background) { await self.performBackgroundSync(context: context) await MainActor.run { diff --git a/docs/plans/2026-01-13-sync-reliability-implementation.md b/docs/plans/2026-01-13-sync-reliability-implementation.md new file mode 100644 index 0000000..d292d23 --- /dev/null +++ b/docs/plans/2026-01-13-sync-reliability-implementation.md @@ -0,0 +1,1132 @@ +# Sync Reliability Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add pagination, cancellation support, and network monitoring to CloudKit sync for complete data reliability. + +**Architecture:** Paginated CloudKit fetches using CKQueryOperation with cursor handling, cancellation tokens checked between pages, per-entity progress saved to SyncState, and NWPathMonitor wrapper with debounced sync triggers. + +**Tech Stack:** CloudKit, SwiftData, Network.framework (NWPathMonitor), Swift Concurrency + +--- + +### Task 1: Add SyncCancellationToken Protocol + +**Files:** +- Create: `SportsTime/Core/Services/SyncCancellationToken.swift` + +**Step 1: Create the cancellation token protocol and implementation** + +```swift +// +// SyncCancellationToken.swift +// SportsTime +// +// Cancellation support for long-running sync operations. +// + +import Foundation +import os + +/// Protocol for cancellation tokens checked between sync pages +protocol SyncCancellationToken: Sendable { + 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 { + lock.withLock { $0 } + } + + func cancel() { + lock.withLock { $0 = true } + } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/SyncCancellationToken.swift +git commit -m "feat(sync): add SyncCancellationToken protocol for graceful cancellation" +``` + +--- + +### Task 2: Add Per-Entity Sync Timestamps to SyncState + +**Files:** +- Modify: `SportsTime/Core/Models/Local/CanonicalModels.swift` + +**Step 1: Add per-entity sync timestamp properties to SyncState** + +Add these properties after the existing `gameChangeToken` property (around line 70): + +```swift + // Per-entity sync timestamps for partial progress + var lastStadiumSync: Date? + var lastTeamSync: Date? + var lastGameSync: Date? + var lastLeagueStructureSync: Date? + var lastTeamAliasSync: Date? + var lastStadiumAliasSync: Date? + var lastSportSync: Date? +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Models/Local/CanonicalModels.swift +git commit -m "feat(sync): add per-entity sync timestamps to SyncState for partial progress" +``` + +--- + +### Task 3: Add wasCancelled to SyncResult + +**Files:** +- Modify: `SportsTime/Core/Services/CanonicalSyncService.swift` + +**Step 1: Add wasCancelled property to SyncResult struct** + +In the SyncResult struct (around line 39), add: + +```swift + let wasCancelled: Bool +``` + +**Step 2: Update isEmpty computed property** + +Change the isEmpty property to account for cancellation: + +```swift + var isEmpty: Bool { totalUpdated == 0 && !wasCancelled } +``` + +**Step 3: Commit** + +```bash +git add SportsTime/Core/Services/CanonicalSyncService.swift +git commit -m "feat(sync): add wasCancelled to SyncResult for cancellation tracking" +``` + +--- + +### Task 4: Add Paginated Fetch to CloudKitService + +**Files:** +- Modify: `SportsTime/Core/Services/CloudKitService.swift` + +**Step 1: Add pagination helper method** + +Add this private method after the existing fetch methods (around line 300): + +```swift + // MARK: - Pagination Helpers + + /// Fetch a single page of records using CKQueryOperation + private func fetchPage( + recordType: String, + predicate: NSPredicate, + cursor: CKQueryOperation.Cursor?, + transform: @escaping (CKRecord) -> T? + ) async throws -> (results: [T], cursor: CKQueryOperation.Cursor?) { + return try await withCheckedThrowingContinuation { continuation in + let operation: CKQueryOperation + + if let cursor = cursor { + operation = CKQueryOperation(cursor: cursor) + } else { + let query = CKQuery(recordType: recordType, predicate: predicate) + operation = CKQueryOperation(query: query) + } + + operation.resultsLimit = 400 + + var pageResults: [T] = [] + + operation.recordMatchedBlock = { _, result in + if case .success(let record) = result, + let transformed = transform(record) { + pageResults.append(transformed) + } + } + + operation.queryResultBlock = { result in + switch result { + case .success(let cursor): + continuation.resume(returning: (pageResults, cursor)) + case .failure(let error): + continuation.resume(throwing: CloudKitError.from(error)) + } + } + + self.publicDatabase.add(operation) + } + } +``` + +**Step 2: Refactor fetchStadiumsForSync to use pagination** + +Replace the existing `fetchStadiumsForSync` method: + +```swift + /// Fetch stadiums for sync operations with pagination + /// - Parameters: + /// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date. + /// - cancellationToken: Optional token to stop pagination early + func fetchStadiumsForSync( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [SyncStadium] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [SyncStadium] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.stadium, + predicate: predicate, + cursor: cursor + ) { record -> SyncStadium? in + let ckStadium = CKStadium(record: record) + guard let stadium = ckStadium.stadium, + let canonicalId = ckStadium.canonicalId + else { return nil } + return SyncStadium(stadium: stadium, canonicalId: canonicalId) + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults + } +``` + +**Step 3: Refactor fetchTeamsForSync to use pagination** + +Replace the existing `fetchTeamsForSync` method: + +```swift + /// Fetch teams for sync operations with pagination + func fetchTeamsForSync( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [SyncTeam] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [SyncTeam] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.team, + predicate: predicate, + cursor: cursor + ) { record -> SyncTeam? in + 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) + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults + } +``` + +**Step 4: Refactor fetchGamesForSync to use pagination** + +Replace the existing `fetchGamesForSync` method: + +```swift + /// Fetch games for sync operations with pagination + func fetchGamesForSync( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [SyncGame] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [SyncGame] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.game, + predicate: predicate, + cursor: cursor + ) { record -> SyncGame? in + let ckGame = CKGame(record: record) + + guard let canonicalId = ckGame.canonicalId, + let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, + let awayTeamCanonicalId = ckGame.awayTeamCanonicalId, + let stadiumCanonicalId = ckGame.stadiumCanonicalId + else { return nil } + + guard let game = ckGame.game( + homeTeamId: homeTeamCanonicalId, + awayTeamId: awayTeamCanonicalId, + stadiumId: stadiumCanonicalId + ) else { return nil } + + return SyncGame( + game: game, + canonicalId: canonicalId, + homeTeamCanonicalId: homeTeamCanonicalId, + awayTeamCanonicalId: awayTeamCanonicalId, + stadiumCanonicalId: stadiumCanonicalId + ) + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults.sorted { $0.game.dateTime < $1.game.dateTime } + } +``` + +**Step 5: Add pagination to fetchLeagueStructureChanges** + +Replace the existing method: + +```swift + /// Fetch league structure records modified after the given date with pagination + func fetchLeagueStructureChanges( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [LeagueStructureModel] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [LeagueStructureModel] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.leagueStructure, + predicate: predicate, + cursor: cursor + ) { record -> LeagueStructureModel? in + CKLeagueStructure(record: record).toModel() + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults + } +``` + +**Step 6: Add pagination to fetchTeamAliasChanges** + +Replace the existing method: + +```swift + /// Fetch team alias records modified after the given date with pagination + func fetchTeamAliasChanges( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [TeamAlias] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [TeamAlias] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.teamAlias, + predicate: predicate, + cursor: cursor + ) { record -> TeamAlias? in + CKTeamAlias(record: record).toModel() + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults + } +``` + +**Step 7: Add pagination to fetchStadiumAliasChanges** + +Replace the existing method: + +```swift + /// Fetch stadium alias records modified after the given date with pagination + func fetchStadiumAliasChanges( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [StadiumAlias] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [StadiumAlias] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.stadiumAlias, + predicate: predicate, + cursor: cursor + ) { record -> StadiumAlias? in + CKStadiumAlias(record: record).toModel() + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults + } +``` + +**Step 8: Add pagination to fetchSportsForSync** + +Replace the existing method: + +```swift + /// Fetch sports for sync operations with pagination + func fetchSportsForSync( + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> [CanonicalSport] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + var allResults: [CanonicalSport] = [] + var cursor: CKQueryOperation.Cursor? = nil + + repeat { + if cancellationToken?.isCancelled == true { + break + } + + let (pageResults, nextCursor) = try await fetchPage( + recordType: CKRecordType.sport, + predicate: predicate, + cursor: cursor + ) { record -> CanonicalSport? in + CKSport(record: record).toCanonical() + } + + allResults.append(contentsOf: pageResults) + cursor = nextCursor + } while cursor != nil + + return allResults + } +``` + +**Step 9: Commit** + +```bash +git add SportsTime/Core/Services/CloudKitService.swift +git commit -m "feat(sync): add pagination to all CloudKit fetch methods with cancellation support" +``` + +--- + +### Task 5: Update CanonicalSyncService for Per-Entity Progress + +**Files:** +- Modify: `SportsTime/Core/Services/CanonicalSyncService.swift` + +**Step 1: Update syncAll signature and add cancellation support** + +Modify the `syncAll` method signature (around line 72): + +```swift + @MainActor + func syncAll( + context: ModelContext, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> SyncResult { +``` + +**Step 2: Update syncStadiums call and save per-entity progress** + +Replace the sync calls section (lines 111-167) with per-entity progress saving: + +```swift + do { + // Sync in dependency order, saving progress after each entity type + let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums( + context: context, + since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken + ) + totalStadiums = stadiums + totalSkippedIncompatible += skipIncompat1 + totalSkippedOlder += skipOlder1 + syncState.lastStadiumSync = Date() + try context.save() + + if cancellationToken?.isCancelled == true { + return buildPartialResult( + stadiums: totalStadiums, teams: 0, games: 0, + leagueStructures: 0, teamAliases: 0, stadiumAliases: 0, sports: 0, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + startTime: startTime, + wasCancelled: true + ) + } + + 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() + try context.save() + + if cancellationToken?.isCancelled == true { + return buildPartialResult( + stadiums: totalStadiums, teams: 0, games: 0, + leagueStructures: totalLeagueStructures, teamAliases: 0, stadiumAliases: 0, sports: 0, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + startTime: startTime, + wasCancelled: true + ) + } + + let (teams, skipIncompat3, skipOlder3) = try await syncTeams( + context: context, + since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken + ) + totalTeams = teams + totalSkippedIncompatible += skipIncompat3 + totalSkippedOlder += skipOlder3 + syncState.lastTeamSync = Date() + try context.save() + + if cancellationToken?.isCancelled == true { + return buildPartialResult( + stadiums: totalStadiums, teams: totalTeams, games: 0, + leagueStructures: totalLeagueStructures, teamAliases: 0, stadiumAliases: 0, sports: 0, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + startTime: startTime, + wasCancelled: true + ) + } + + 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() + try context.save() + + if cancellationToken?.isCancelled == true { + return buildPartialResult( + stadiums: totalStadiums, teams: totalTeams, games: 0, + leagueStructures: totalLeagueStructures, teamAliases: totalTeamAliases, stadiumAliases: 0, sports: 0, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + startTime: startTime, + wasCancelled: true + ) + } + + 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() + try context.save() + + if cancellationToken?.isCancelled == true { + return buildPartialResult( + stadiums: totalStadiums, teams: totalTeams, games: 0, + leagueStructures: totalLeagueStructures, teamAliases: totalTeamAliases, stadiumAliases: totalStadiumAliases, sports: 0, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + startTime: startTime, + wasCancelled: true + ) + } + + let (games, skipIncompat6, skipOlder6) = try await syncGames( + context: context, + since: syncState.lastGameSync ?? syncState.lastSuccessfulSync, + cancellationToken: cancellationToken + ) + totalGames = games + totalSkippedIncompatible += skipIncompat6 + totalSkippedOlder += skipOlder6 + syncState.lastGameSync = Date() + try context.save() + + if cancellationToken?.isCancelled == true { + return buildPartialResult( + stadiums: totalStadiums, teams: totalTeams, games: totalGames, + leagueStructures: totalLeagueStructures, teamAliases: totalTeamAliases, stadiumAliases: totalStadiumAliases, sports: 0, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + startTime: startTime, + wasCancelled: true + ) + } + + 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() + + // Mark sync successful + syncState.syncInProgress = false + syncState.lastSuccessfulSync = Date() + syncState.lastSyncError = nil + syncState.consecutiveFailures = 0 + + try context.save() + + } catch { +``` + +**Step 3: Add helper method for building partial results** + +Add this method after `syncAll`: + +```swift + private func buildPartialResult( + stadiums: Int, teams: Int, games: Int, + leagueStructures: Int, teamAliases: Int, stadiumAliases: Int, sports: Int, + skippedIncompatible: Int, skippedOlder: Int, + startTime: Date, + wasCancelled: Bool + ) -> SyncResult { + SyncResult( + stadiumsUpdated: stadiums, + teamsUpdated: teams, + gamesUpdated: games, + leagueStructuresUpdated: leagueStructures, + teamAliasesUpdated: teamAliases, + stadiumAliasesUpdated: stadiumAliases, + sportsUpdated: sports, + skippedIncompatible: skippedIncompatible, + skippedOlder: skippedOlder, + duration: Date().timeIntervalSince(startTime), + wasCancelled: wasCancelled + ) + } +``` + +**Step 4: Update individual sync methods to accept cancellation token** + +Update each sync method signature to include cancellationToken and pass it to cloudKitService: + +For `syncStadiums`: +```swift + @MainActor + private func syncStadiums( + context: ModelContext, + since lastSync: Date?, + cancellationToken: SyncCancellationToken? = nil + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + let syncStadiums = try await cloudKitService.fetchStadiumsForSync( + since: lastSync, + cancellationToken: cancellationToken + ) +``` + +Apply same pattern to: `syncTeams`, `syncGames`, `syncLeagueStructure`, `syncTeamAliases`, `syncStadiumAliases`, `syncSports` + +**Step 5: Update final return statement** + +Update the return at the end of syncAll: + +```swift + return SyncResult( + stadiumsUpdated: totalStadiums, + teamsUpdated: totalTeams, + gamesUpdated: totalGames, + leagueStructuresUpdated: totalLeagueStructures, + teamAliasesUpdated: totalTeamAliases, + stadiumAliasesUpdated: totalStadiumAliases, + sportsUpdated: totalSports, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + duration: Date().timeIntervalSince(startTime), + wasCancelled: false + ) +``` + +**Step 6: Commit** + +```bash +git add SportsTime/Core/Services/CanonicalSyncService.swift +git commit -m "feat(sync): add per-entity progress saving and cancellation support to sync service" +``` + +--- + +### Task 6: Create NetworkMonitor + +**Files:** +- Create: `SportsTime/Core/Services/NetworkMonitor.swift` + +**Step 1: Create NetworkMonitor singleton** + +```swift +// +// NetworkMonitor.swift +// SportsTime +// +// Monitors network connectivity and triggers sync when connection is restored. +// + +import Foundation +import Network + +extension Notification.Name { + static let networkRestored = Notification.Name("com.sportstime.networkRestored") +} + +@MainActor +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published private(set) var isConnected: Bool = true + @Published private(set) var connectionType: ConnectionType = .unknown + + enum ConnectionType: String { + case wifi + case cellular + case wired + case unknown + } + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.sportstime.networkmonitor") + private var debounceTask: Task? + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor in + self?.handlePathUpdate(path) + } + } + monitor.start(queue: queue) + } + + private func handlePathUpdate(_ path: NWPath) { + let wasConnected = isConnected + isConnected = (path.status == .satisfied) + connectionType = detectConnectionType(path) + + // Only trigger sync if we GAINED connection (was offline, now online) + if !wasConnected && isConnected { + // Cancel any pending debounce + debounceTask?.cancel() + + // Debounce to handle network flapping + debounceTask = Task { + do { + try await Task.sleep(for: .seconds(2.5)) + guard !Task.isCancelled else { return } + + // Post notification for sync trigger + NotificationCenter.default.post(name: .networkRestored, object: nil) + print("NetworkMonitor: Connection restored, sync triggered") + } catch { + // Task was cancelled (network dropped again during debounce) + print("NetworkMonitor: Debounce cancelled (network unstable)") + } + } + } else if wasConnected && !isConnected { + // Lost connection - cancel any pending sync trigger + debounceTask?.cancel() + debounceTask = nil + print("NetworkMonitor: Connection lost") + } + } + + private func detectConnectionType(_ path: NWPath) -> ConnectionType { + if path.usesInterfaceType(.wifi) { + return .wifi + } else if path.usesInterfaceType(.cellular) { + return .cellular + } else if path.usesInterfaceType(.wiredEthernet) { + return .wired + } + return .unknown + } + + deinit { + monitor.cancel() + } +} +``` + +**Step 2: Commit** + +```bash +git add SportsTime/Core/Services/NetworkMonitor.swift +git commit -m "feat(sync): add NetworkMonitor for debounced sync on network restoration" +``` + +--- + +### Task 7: Integrate Cancellation into BackgroundSyncManager + +**Files:** +- Modify: `SportsTime/Core/Services/BackgroundSyncManager.swift` + +**Step 1: Add cancellation token property** + +Add after the modelContainer property (around line 31): + +```swift + private var currentCancellationToken: BackgroundTaskCancellationToken? + private var networkObserver: NSObjectProtocol? +``` + +**Step 2: Update configure method to subscribe to network notifications** + +Replace the `configure` method: + +```swift + func configure(with container: ModelContainer) { + self.modelContainer = container + + // Subscribe to network restoration notifications + networkObserver = NotificationCenter.default.addObserver( + forName: .networkRestored, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + await self?.performNetworkRestoredSync() + } + } + + // Initialize network monitor + _ = NetworkMonitor.shared + } +``` + +**Step 3: Add network restored sync method** + +Add after `scheduleAllTasks`: + +```swift + /// Perform sync when network is restored (debounced by NetworkMonitor) + @MainActor + private func performNetworkRestoredSync() async { + guard let container = modelContainer else { + print("Network sync: No model container configured") + return + } + + do { + let success = try await performSync(context: container.mainContext, cancellationToken: nil) + print("Network restored sync completed: \(success ? "updates applied" : "no updates")") + } catch { + print("Network restored sync failed: \(error.localizedDescription)") + } + } +``` + +**Step 4: Update handleRefreshTask to use cancellation** + +Replace the method: + +```swift + @MainActor + private func handleRefreshTask(_ task: BGAppRefreshTask) async { + print("Background refresh task started") + + // Schedule the next refresh before we start + scheduleRefresh() + + // Create cancellation token + let cancellationToken = BackgroundTaskCancellationToken() + currentCancellationToken = cancellationToken + + // Set up expiration handler + task.expirationHandler = { [weak self] in + print("Background refresh task expired - cancelling sync") + self?.currentCancellationToken?.cancel() + } + + guard let container = modelContainer else { + print("Background refresh: No model container configured") + task.setTaskCompleted(success: false) + return + } + + do { + let success = try await performSync( + context: container.mainContext, + cancellationToken: cancellationToken + ) + task.setTaskCompleted(success: success) + print("Background refresh completed: \(success ? "success" : "no updates")") + } catch { + print("Background refresh failed: \(error.localizedDescription)") + task.setTaskCompleted(success: false) + } + + currentCancellationToken = nil + } +``` + +**Step 5: Update handleProcessingTask to use cancellation** + +Replace the method: + +```swift + @MainActor + private func handleProcessingTask(_ task: BGProcessingTask) async { + print("Background processing task started") + + // Schedule the next processing task + scheduleProcessingTask() + + // Create cancellation token + let cancellationToken = BackgroundTaskCancellationToken() + currentCancellationToken = cancellationToken + + // Set up expiration handler + task.expirationHandler = { [weak self] in + print("Background processing task expired - cancelling sync") + self?.currentCancellationToken?.cancel() + } + + guard let container = modelContainer else { + print("Background processing: No model container configured") + task.setTaskCompleted(success: false) + return + } + + do { + // 1. Full sync from CloudKit + let syncSuccess = try await performSync( + context: container.mainContext, + cancellationToken: cancellationToken + ) + + // 2. Clean up old data (only if not cancelled) + var cleanupSuccess = false + if !cancellationToken.isCancelled { + cleanupSuccess = await performCleanup(context: container.mainContext) + } + + task.setTaskCompleted(success: syncSuccess || cleanupSuccess) + print("Background processing completed: sync=\(syncSuccess), cleanup=\(cleanupSuccess)") + } catch { + print("Background processing failed: \(error.localizedDescription)") + task.setTaskCompleted(success: false) + } + + currentCancellationToken = nil + } +``` + +**Step 6: Update performSync to accept cancellation token** + +Replace the method: + +```swift + @MainActor + private func performSync( + context: ModelContext, + cancellationToken: SyncCancellationToken? + ) async throws -> Bool { + let syncService = CanonicalSyncService() + + do { + let result = try await syncService.syncAll( + context: context, + cancellationToken: cancellationToken + ) + + // Reload DataProvider if data changed + if !result.isEmpty { + await AppDataProvider.shared.loadInitialData() + print("Background sync: \(result.totalUpdated) items updated") + return true + } + + if result.wasCancelled { + print("Background sync: Cancelled with partial progress saved") + } + + return false + + } catch CanonicalSyncService.SyncError.cloudKitUnavailable { + print("Background sync: CloudKit unavailable (offline)") + return false + } catch CanonicalSyncService.SyncError.syncAlreadyInProgress { + print("Background sync: Already in progress") + return false + } + } +``` + +**Step 7: Add deinit to clean up observer** + +Add at the end of the class: + +```swift + deinit { + if let observer = networkObserver { + NotificationCenter.default.removeObserver(observer) + } + } +``` + +**Step 8: Commit** + +```bash +git add SportsTime/Core/Services/BackgroundSyncManager.swift +git commit -m "feat(sync): integrate cancellation and network monitoring into BackgroundSyncManager" +``` + +--- + +### Task 8: Update SportsTimeApp to Initialize NetworkMonitor + +**Files:** +- Modify: `SportsTime/SportsTimeApp.swift` + +**Step 1: Initialize NetworkMonitor early** + +In `performBootstrap()`, after configuring BackgroundSyncManager (around line 146), add: + +```swift + // 3b. Initialize network monitoring + _ = NetworkMonitor.shared +``` + +**Step 2: Commit** + +```bash +git add SportsTime/SportsTimeApp.swift +git commit -m "feat(sync): initialize NetworkMonitor during app bootstrap" +``` + +--- + +### Task 9: Build and Test + +**Step 1: Build the project** + +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +**Step 2: Run tests** + +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test +``` + +**Step 3: Final commit if needed** + +Fix any build/test issues and commit. + +--- + +## Summary + +This implementation adds: +1. **Pagination** - All CloudKit fetches now use cursor-based pagination (400 records/page) +2. **Cancellation** - Background tasks can be gracefully cancelled with progress saved +3. **Per-entity progress** - Each entity type has its own sync timestamp for resumable syncs +4. **Network monitoring** - NWPathMonitor triggers debounced sync on connection restore From d377d03b10ac0700409042dd29f6af1447a68e41 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 19:24:04 -0600 Subject: [PATCH 2/2] docs: add comprehensive sync reliability documentation Adds SYNC_RELIABILITY.md covering: - Architecture overview with diagrams - Component documentation (CloudKitService, CanonicalSyncService, BackgroundSyncManager, NetworkMonitor, SyncCancellationToken) - Data flow diagrams for all sync triggers - Error handling strategies - Debugging guide with LLDB commands - Testing checklist - Performance considerations - Info.plist configuration requirements Co-Authored-By: Claude Opus 4.5 --- docs/SYNC_RELIABILITY.md | 759 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 docs/SYNC_RELIABILITY.md diff --git a/docs/SYNC_RELIABILITY.md b/docs/SYNC_RELIABILITY.md new file mode 100644 index 0000000..c1dc41e --- /dev/null +++ b/docs/SYNC_RELIABILITY.md @@ -0,0 +1,759 @@ +# Sync Reliability Architecture + +This document describes the CloudKit-to-SwiftData synchronization system in SportsTime, including pagination, cancellation support, network monitoring, and background task integration. + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Components](#components) + - [CloudKitService](#cloudkitservice) + - [CanonicalSyncService](#canonicalsyncservice) + - [BackgroundSyncManager](#backgroundsyncmanager) + - [NetworkMonitor](#networkmonitor) + - [SyncCancellationToken](#synccancellationtoken) +- [Data Flow](#data-flow) +- [Sync Triggers](#sync-triggers) +- [Error Handling](#error-handling) +- [Debugging](#debugging) +- [Testing](#testing) + +--- + +## Overview + +SportsTime uses an offline-first architecture where: + +1. **Bundled JSON** provides initial data on first launch +2. **SwiftData** stores all canonical data locally +3. **CloudKit** (public database) is the authoritative source for schedule updates +4. **Background sync** keeps local data fresh without user intervention + +The sync system is designed to be: + +- **Resilient**: Handles network failures, partial syncs, and interrupted background tasks +- **Efficient**: Uses cursor-based pagination to handle large datasets (5000+ games) +- **Non-blocking**: Never prevents the user from using the app +- **Battery-conscious**: Debounces network changes and schedules heavy work overnight + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Sync Triggers │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ App Launch │ Foreground │ Network Restore │ Background Task │ +│ (bootstrap) │ (resume) │ (NetworkMonitor) │ (BGTaskScheduler) │ +└───────┬────────┴──────┬───────┴─────────┬─────────┴───────────┬─────────────┘ + │ │ │ │ + └───────────────┴─────────────────┴─────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────┐ + │ CanonicalSyncService │ + │ │ + │ • Orchestrates sync across all entity types │ + │ • Tracks per-entity progress in SyncState │ + │ • Handles cancellation for graceful termination │ + │ • Returns SyncResult with counts and status │ + └─────────────────────────┬───────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────┐ + │ CloudKitService │ + │ │ + │ • Fetches records from CloudKit public database │ + │ • Uses cursor-based pagination (400 records/page) │ + │ • Supports cancellation between pages │ + │ • Parses CKRecord → Domain models │ + └─────────────────────────┬───────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────┐ + │ SwiftData │ + │ │ + │ • Canonical models: Stadium, Team, Game, etc. │ + │ • SyncState: tracks last sync times per entity │ + │ • Persists across app launches │ + └─────────────────────────────────────────────────────────┘ +``` + +--- + +## Components + +### CloudKitService + +**File:** `SportsTime/Core/Services/CloudKitService.swift` + +The CloudKitService handles all communication with CloudKit's public database. It provides type-safe fetch methods for each entity type. + +#### Pagination + +CloudKit queries are limited to returning approximately 400 records per request. With 5000+ games in the database, pagination is essential. + +```swift +private let recordsPerPage = 400 + +private func fetchAllRecords( + matching query: CKQuery, + cancellationToken: SyncCancellationToken? +) async throws -> [CKRecord] { + var allRecords: [CKRecord] = [] + var cursor: CKQueryOperation.Cursor? + + // Fetch first page + let (firstResults, firstCursor) = try await publicDatabase.records( + matching: query, + resultsLimit: recordsPerPage + ) + // Process results... + cursor = firstCursor + + // Continue fetching while cursor exists + while let currentCursor = cursor { + // Check for cancellation between pages + if cancellationToken?.isCancelled == true { + throw CancellationError() + } + + let (results, nextCursor) = try await publicDatabase.records( + continuingMatchFrom: currentCursor, + resultsLimit: recordsPerPage + ) + // Process results... + cursor = nextCursor + } + + return allRecords +} +``` + +#### Fetch Methods + +Each entity type has a dedicated fetch method: + +| Method | Entity | Record Type | +|--------|--------|-------------| +| `fetchStadiumsForSync(since:cancellationToken:)` | Stadium | `Stadium` | +| `fetchTeamsForSync(since:cancellationToken:)` | Team | `Team` | +| `fetchGamesForSync(since:cancellationToken:)` | Game | `Game` | +| `fetchLeagueStructureChanges(since:cancellationToken:)` | LeagueStructure | `LeagueStructure` | +| `fetchTeamAliasChanges(since:cancellationToken:)` | TeamAlias | `TeamAlias` | +| `fetchStadiumAliasChanges(since:cancellationToken:)` | StadiumAlias | `StadiumAlias` | +| `fetchSportsForSync(since:cancellationToken:)` | DynamicSport | `Sport` | + +All methods accept a `since` date for incremental sync and a `cancellationToken` for graceful termination. + +--- + +### CanonicalSyncService + +**File:** `SportsTime/Core/Services/CanonicalSyncService.swift` + +The CanonicalSyncService orchestrates the sync process across all entity types, managing progress tracking and cancellation. + +#### SyncResult + +```swift +struct SyncResult { + let stadiumsUpdated: Int + let teamsUpdated: Int + let gamesUpdated: Int + let leagueStructuresUpdated: Int + let teamAliasesUpdated: Int + let stadiumAliasesUpdated: Int + let sportsUpdated: Int + let skippedIncompatible: Int + let skippedOlder: Int + let duration: TimeInterval + let wasCancelled: Bool // True if sync was interrupted + + var isEmpty: Bool { totalUpdated == 0 && !wasCancelled } + var totalUpdated: Int { /* sum of all updated counts */ } +} +``` + +#### Sync Order + +Entities are synced in dependency order: + +1. **Sports** - Must exist before teams reference them +2. **Stadiums** - Must exist before teams reference them +3. **Teams** - Must exist before games reference them +4. **Games** - References teams and stadiums +5. **League Structures** - References teams +6. **Team Aliases** - References teams +7. **Stadium Aliases** - References stadiums + +#### Per-Entity Progress + +Progress is saved after each entity type completes: + +```swift +func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult { + // 1. Sync sports + let sportsResult = try await syncSports(context: context, cancellationToken: cancellationToken) + syncState.lastSportSync = Date() + try context.save() // Save progress + + // Check for cancellation + if cancellationToken?.isCancelled == true { + return SyncResult(/* ... */, wasCancelled: true) + } + + // 2. Sync stadiums + let stadiumsResult = try await syncStadiums(context: context, cancellationToken: cancellationToken) + syncState.lastStadiumSync = Date() + try context.save() // Save progress + + // ... continue for each entity type +} +``` + +This ensures that if a background task is terminated mid-sync, the next sync will only fetch entities that weren't completed. + +#### Sync Mutex + +Only one sync can run at a time: + +```swift +private var isSyncing = false + +func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult { + guard !isSyncing else { + throw SyncError.syncAlreadyInProgress + } + isSyncing = true + defer { isSyncing = false } + // ... sync logic +} +``` + +--- + +### BackgroundSyncManager + +**File:** `SportsTime/Core/Services/BackgroundSyncManager.swift` + +The BackgroundSyncManager integrates with iOS's BGTaskScheduler to run sync operations when the app is not active. + +#### Task Types + +| Task | Identifier | Purpose | Schedule | +|------|------------|---------|----------| +| Refresh | `com.sportstime.app.refresh` | Periodic sync | Every ~1 hour (system decides) | +| Processing | `com.sportstime.app.db-cleanup` | Heavy sync + cleanup | Overnight (~2 AM) | + +#### Registration + +Tasks must be registered before `applicationDidFinishLaunching` returns: + +```swift +// In SportsTimeApp.init() +BackgroundSyncManager.shared.registerTasks() +``` + +```swift +func registerTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.refreshTaskIdentifier, + using: nil + ) { task in + guard let task = task as? BGAppRefreshTask else { return } + Task { @MainActor in + await self.handleRefreshTask(task) + } + } + // ... register processing task +} +``` + +#### Cancellation Integration + +When the system needs to terminate a background task, the expiration handler fires: + +```swift +private func handleRefreshTask(_ task: BGAppRefreshTask) async { + // Create cancellation token + let cancellationToken = BackgroundTaskCancellationToken() + currentCancellationToken = cancellationToken + + // Set up expiration handler + task.expirationHandler = { [weak self] in + self?.logger.warning("Background refresh task expiring - cancelling sync") + cancellationToken.cancel() + } + + // Perform sync with cancellation support + let result = try await performSync( + context: container.mainContext, + cancellationToken: cancellationToken + ) + + // Handle result + if result.wasCancelled { + // Partial success - progress was saved + task.setTaskCompleted(success: true) + } else { + task.setTaskCompleted(success: !result.isEmpty) + } +} +``` + +#### Scheduling + +Background tasks are scheduled when: +- App finishes bootstrapping +- App enters background + +```swift +func scheduleAllTasks() { + scheduleRefresh() + scheduleProcessingTask() +} + +func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour + try? BGTaskScheduler.shared.submit(request) +} + +func scheduleProcessingTask() { + let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier) + request.earliestBeginDate = /* next 2 AM */ + request.requiresNetworkConnectivity = true + try? BGTaskScheduler.shared.submit(request) +} +``` + +--- + +### NetworkMonitor + +**File:** `SportsTime/Core/Services/NetworkMonitor.swift` + +The NetworkMonitor uses NWPathMonitor to detect network state changes and trigger sync when connectivity is restored. + +#### Why Network Monitoring? + +Without network monitoring, the app would only sync: +- On app launch +- On background task execution (system-controlled) + +With network monitoring, the app syncs immediately when: +- User exits airplane mode +- WiFi reconnects after being out of range +- Cellular restores after being in a dead zone + +#### Debouncing + +Network state can flap rapidly during WiFi↔cellular handoffs. A 2.5-second debounce prevents multiple sync triggers: + +```swift +private let debounceDelay: TimeInterval = 2.5 + +private func scheduleDebouncedSync() { + debounceTask?.cancel() + + debounceTask = Task { @MainActor [weak self] in + guard let self = self else { return } + + // Wait for debounce delay + try await Task.sleep(for: .seconds(debounceDelay)) + + // Verify still connected + guard isConnected else { return } + + // Reset offline flag and trigger sync + hasBeenOffline = false + await onSyncNeeded?() + } +} +``` + +#### State Machine + +``` + ┌─────────────────┐ + │ App Starts │ + │ hasBeenOffline │ + │ = false │ + └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + ┌────────│ Monitoring │────────┐ + │ └─────────────────┘ │ + │ │ + Disconnected Connected + │ │ + ▼ │ +┌─────────────────┐ │ +│ hasBeenOffline │ │ +│ = true │ │ +│ Cancel debounce │ │ +└────────┬────────┘ │ + │ │ + │ Reconnected │ + └──────────────────┬─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Start debounce │ + │ (2.5 sec) │ + └────────┬────────┘ + │ + ┌──────────────┴──────────────┐ + │ │ + Still connected Disconnected again + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Trigger sync │ │ Cancel debounce │ + │ hasBeenOffline │ │ Wait for next │ + │ = false │ │ reconnection │ + └─────────────────┘ └─────────────────┘ +``` + +#### Integration + +The NetworkMonitor is wired up during app bootstrap: + +```swift +// In BootstrappedContentView.performBootstrap() +NetworkMonitor.shared.onSyncNeeded = { + await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration() +} +NetworkMonitor.shared.startMonitoring() +``` + +--- + +### SyncCancellationToken + +**File:** `SportsTime/Core/Services/SyncCancellationToken.swift` + +The SyncCancellationToken protocol enables graceful cancellation of sync operations. + +#### Protocol + +```swift +protocol SyncCancellationToken: Sendable { + var isCancelled: Bool { get } +} +``` + +#### Implementation + +```swift +final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable { + private let lock = OSAllocatedUnfairLock(initialState: false) + + var isCancelled: Bool { + lock.withLock { $0 } + } + + func cancel() { + lock.withLock { $0 = true } + } +} +``` + +The implementation uses `OSAllocatedUnfairLock` for thread-safe access: +- The expiration handler runs on an arbitrary thread +- The sync operation runs on the MainActor +- The lock ensures both can safely access `isCancelled` + +#### Usage in Sync + +Cancellation is checked at strategic points: + +1. **Between entity types** - After completing stadiums, check before starting teams +2. **Between pagination pages** - After fetching 400 records, check before fetching the next page +3. **Never mid-entity** - A single entity's sync always completes atomically + +--- + +## Data Flow + +### App Launch Sync + +``` +SportsTimeApp.init() + │ + └─► BackgroundSyncManager.registerTasks() + +BootstrappedContentView.task + │ + └─► performBootstrap() + │ + ├─► BootstrapService.bootstrapIfNeeded() // First launch only + │ + ├─► AppDataProvider.configure() + │ + ├─► BackgroundSyncManager.configure() + │ + ├─► AppDataProvider.loadInitialData() + │ + ├─► NetworkMonitor.startMonitoring() + │ + ├─► BackgroundSyncManager.scheduleAllTasks() + │ + └─► Task.detached { + performBackgroundSync() // Non-blocking + } +``` + +### Foreground Sync + +``` +scenePhase changes to .active (and hasCompletedInitialSync) + │ + └─► performBackgroundSync() + │ + └─► CanonicalSyncService.syncAll() + │ + ├─► CloudKitService.fetchSportsForSync() + ├─► CloudKitService.fetchStadiumsForSync() + ├─► CloudKitService.fetchTeamsForSync() + ├─► CloudKitService.fetchGamesForSync() + ├─► CloudKitService.fetchLeagueStructureChanges() + ├─► CloudKitService.fetchTeamAliasChanges() + └─► CloudKitService.fetchStadiumAliasChanges() +``` + +### Background Task Sync + +``` +BGTaskScheduler triggers task + │ + └─► BackgroundSyncManager.handleRefreshTask() + │ + ├─► Create cancellationToken + │ + ├─► Set expirationHandler → cancellationToken.cancel() + │ + ├─► performSync(cancellationToken: cancellationToken) + │ │ + │ └─► CanonicalSyncService.syncAll(cancellationToken: token) + │ │ + │ ├─► Sync sports → save progress + │ ├─► Check cancellation + │ ├─► Sync stadiums → save progress + │ ├─► Check cancellation + │ └─► ... (continues until complete or cancelled) + │ + └─► task.setTaskCompleted(success: result.wasCancelled || !result.isEmpty) +``` + +### Network Restoration Sync + +``` +NWPathMonitor detects connectivity + │ + └─► NetworkMonitor.handlePathUpdate() + │ + └─► scheduleDebouncedSync() + │ + └─► (after 2.5s delay) + │ + └─► onSyncNeeded() + │ + └─► BackgroundSyncManager.triggerSyncFromNetworkRestoration() + │ + └─► performSync() +``` + +--- + +## Sync Triggers + +| Trigger | When | Cancellable | Notes | +|---------|------|-------------|-------| +| App Launch | `performBootstrap()` | No | Non-blocking, runs in background | +| Foreground Resume | `scenePhase == .active` | No | Only after initial sync completes | +| Network Restoration | `NWPath.status == .satisfied` | No | 2.5s debounce | +| Background Refresh | System decides (~hourly) | Yes | Expiration handler cancels | +| Background Processing | Overnight (~2 AM) | Yes | Includes cleanup task | + +--- + +## Error Handling + +### CloudKit Errors + +| Error | Handling | +|-------|----------| +| `CKError.networkUnavailable` | Silently continue with local data | +| `CKError.networkFailure` | Silently continue with local data | +| `CKError.serviceUnavailable` | Silently continue with local data | +| `CKError.quotaExceeded` | Log and continue | +| Other errors | Log and propagate | + +### Sync Errors + +```swift +enum SyncError: LocalizedError { + case cloudKitUnavailable // No network - not a failure + case syncAlreadyInProgress // Another sync running - skip + case dataInconsistency(String) // Log but continue +} +``` + +### Recovery Strategy + +1. **Network unavailable**: Use existing local data, sync when network returns +2. **Sync interrupted**: Progress saved per-entity, resume from last checkpoint +3. **Data corruption**: Individual records skipped, sync continues +4. **Background task expired**: Partial progress saved, next task resumes + +--- + +## Debugging + +### Console Logging + +The sync system uses structured logging: + +```swift +private let logger = Logger(subsystem: "com.sportstime.app", category: "BackgroundSyncManager") + +logger.info("Background refresh task started") +logger.warning("Background refresh task expiring - cancelling sync") +logger.error("Background refresh failed: \(error.localizedDescription)") +``` + +Filter in Console.app: +- Subsystem: `com.sportstime.app` +- Categories: `BackgroundSyncManager`, `NetworkMonitor` + +### Simulating Background Tasks + +In Xcode debugger (LLDB): + +```lldb +# Trigger refresh task +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.refresh"] + +# Trigger processing task +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.db-cleanup"] + +# Simulate expiration +e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.sportstime.app.refresh"] +``` + +### Checking Sync State + +Query SwiftData for sync timestamps: + +```swift +let descriptor = FetchDescriptor() +if let syncState = try? context.fetch(descriptor).first { + print("Last stadium sync: \(syncState.lastStadiumSync ?? "never")") + print("Last team sync: \(syncState.lastTeamSync ?? "never")") + print("Last game sync: \(syncState.lastGameSync ?? "never")") + // ... +} +``` + +### Network Monitor State + +```swift +print("Connected: \(NetworkMonitor.shared.isConnected)") +``` + +--- + +## Testing + +### Unit Testing Sync + +```swift +func testSyncCancellation() async throws { + let token = BackgroundTaskCancellationToken() + + // Start sync in background + let syncTask = Task { + try await syncService.syncAll(context: context, cancellationToken: token) + } + + // Cancel after short delay + try await Task.sleep(for: .milliseconds(100)) + token.cancel() + + // Verify partial completion + let result = try await syncTask.value + XCTAssertTrue(result.wasCancelled) + XCTAssertGreaterThan(result.totalUpdated, 0) // Some progress made +} +``` + +### Integration Testing + +1. **Pagination**: Ensure all 5000+ games are fetched +2. **Cancellation**: Verify progress is saved when cancelled +3. **Network restoration**: Test airplane mode toggle triggers sync +4. **Background tasks**: Use simulator LLDB commands + +### Manual Testing Checklist + +- [ ] Fresh install syncs all data +- [ ] App works offline after initial sync +- [ ] Foreground resume triggers sync +- [ ] Airplane mode → disable triggers sync on restore +- [ ] Background refresh runs (check Console.app) +- [ ] Long sync can be interrupted and resumed + +--- + +## Info.plist Configuration + +The following keys must be present in `Info.plist`: + +```xml +BGTaskSchedulerPermittedIdentifiers + + com.sportstime.app.refresh + com.sportstime.app.db-cleanup + +UIBackgroundModes + + fetch + processing + +``` + +--- + +## Performance Considerations + +### Memory + +- Pagination limits memory usage to ~400 records at a time +- Records are processed and saved incrementally +- Old games (>1 year) are soft-deleted during overnight processing + +### Battery + +- Network monitoring is passive (no polling) +- Debounce prevents rapid sync triggers +- Heavy processing scheduled for overnight (plugged in) + +### Network + +- Incremental sync fetches only changes since last sync +- Pagination respects CloudKit rate limits +- Offline state handled gracefully + +--- + +## Future Improvements + +1. **Exponential backoff**: Retry failed syncs with increasing delays +2. **Conflict resolution**: Handle concurrent edits from multiple devices +3. **Sync priority**: Prioritize user-relevant data (upcoming games) +4. **Delta sync**: Use CloudKit subscriptions for push-based updates +5. **Metrics**: Track sync success rates, durations, and data freshness