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>
This commit is contained in:
Trey t
2026-01-13 19:18:55 -06:00
parent 00b33202f5
commit 5686af262f
8 changed files with 1607 additions and 94 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Void, Never>?
/// Debounce delay in seconds (2.5s to handle WiFicellular 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()
}
}
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff