- 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>
1133 lines
35 KiB
Markdown
1133 lines
35 KiB
Markdown
# 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<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:
|
|
|
|
```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<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**
|
|
|
|
```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
|