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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user