Files
Sportstime/docs/plans/2026-01-13-sync-reliability-implementation.md
Trey t 5686af262f feat(sync): add pagination, cancellation, and network restoration
- 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 <noreply@anthropic.com>
2026-01-13 19:18:55 -06:00

35 KiB

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

//
//  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

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):

    // 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

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:

        let wasCancelled: Bool

Step 2: Update isEmpty computed property

Change the isEmpty property to account for cancellation:

        var isEmpty: Bool { totalUpdated == 0 && !wasCancelled }

Step 3: Commit

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):

    // MARK: - Pagination Helpers

    /// Fetch a single page of records using CKQueryOperation
    private func fetchPage<T>(
        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:

    /// 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:

    /// 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:

    /// 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:

    /// 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:

    /// 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:

    /// 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:

    /// 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

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):

    @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:

        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:

    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:

    @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:

        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

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

//
//  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<Void, Never>?

    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

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):

    private var currentCancellationToken: BackgroundTaskCancellationToken?
    private var networkObserver: NSObjectProtocol?

Step 2: Update configure method to subscribe to network notifications

Replace the configure method:

    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:

    /// 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:

    @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:

    @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:

    @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:

    deinit {
        if let observer = networkObserver {
            NotificationCenter.default.removeObserver(observer)
        }
    }

Step 8: Commit

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:

            // 3b. Initialize network monitoring
            _ = NetworkMonitor.shared

Step 2: Commit

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

xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Step 2: Run tests

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