# 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