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

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