// // CloudKitService.swift // SportsTime // import Foundation import CloudKit // MARK: - CloudKit Errors enum CloudKitError: Error, LocalizedError { case notSignedIn case networkUnavailable case serverError(String) case quotaExceeded case permissionDenied case recordNotFound case unknown(Error) var errorDescription: String? { switch self { case .notSignedIn: return "Please sign in to iCloud in Settings to sync data." case .networkUnavailable: return "Unable to connect to the server. Check your internet connection." case .serverError(let message): return "Server error: \(message)" case .quotaExceeded: return "iCloud storage quota exceeded." case .permissionDenied: return "Permission denied. Check your iCloud settings." case .recordNotFound: return "Data not found." case .unknown(let error): return "An unexpected error occurred: \(error.localizedDescription)" } } static func from(_ error: Error) -> CloudKitError { if let ckError = error as? CKError { switch ckError.code { case .notAuthenticated: return .notSignedIn case .networkUnavailable, .networkFailure: return .networkUnavailable case .serverResponseLost: return .serverError("Connection lost") case .quotaExceeded: return .quotaExceeded case .permissionFailure: return .permissionDenied case .unknownItem: return .recordNotFound default: return .serverError(ckError.localizedDescription) } } return .unknown(error) } } actor CloudKitService { static let shared = CloudKitService() nonisolated static let gameUpdatesSubscriptionID = "game-updates" nonisolated static let teamUpdatesSubscriptionID = "team-updates" nonisolated static let stadiumUpdatesSubscriptionID = "stadium-updates" nonisolated static let leagueStructureUpdatesSubscriptionID = "league-structure-updates" nonisolated static let teamAliasUpdatesSubscriptionID = "team-alias-updates" nonisolated static let stadiumAliasUpdatesSubscriptionID = "stadium-alias-updates" nonisolated static let sportUpdatesSubscriptionID = "sport-updates" nonisolated static let canonicalSubscriptionIDs: Set = [ gameUpdatesSubscriptionID, teamUpdatesSubscriptionID, stadiumUpdatesSubscriptionID, leagueStructureUpdatesSubscriptionID, teamAliasUpdatesSubscriptionID, stadiumAliasUpdatesSubscriptionID, sportUpdatesSubscriptionID ] nonisolated static func recordType(forSubscriptionID subscriptionID: String) -> String? { switch subscriptionID { case gameUpdatesSubscriptionID: return "Game" case teamUpdatesSubscriptionID: return "Team" case stadiumUpdatesSubscriptionID: return "Stadium" case leagueStructureUpdatesSubscriptionID: return "LeagueStructure" case teamAliasUpdatesSubscriptionID: return "TeamAlias" case stadiumAliasUpdatesSubscriptionID: return "StadiumAlias" case sportUpdatesSubscriptionID: return "Sport" default: return nil } } private let container: CKContainer private let publicDatabase: CKDatabase /// Maximum records per CloudKit query (400 is the default limit) private let recordsPerPage = 400 /// Re-fetch a small overlap window to avoid missing updates around sync boundaries. private let deltaOverlapSeconds: TimeInterval = 120 private init() { self.container = CloudKitContainerConfig.makeContainer() 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? var partialFailureCount = 0 var firstPartialFailure: Error? // First page let (firstResults, firstCursor) = try await publicDatabase.records( matching: query, resultsLimit: recordsPerPage ) for result in firstResults { switch result.1 { case .success(let record): allRecords.append(record) case .failure(let error): partialFailureCount += 1 if firstPartialFailure == nil { firstPartialFailure = error } } } 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 { switch result.1 { case .success(let record): allRecords.append(record) case .failure(let error): partialFailureCount += 1 if firstPartialFailure == nil { firstPartialFailure = error } } } cursor = nextCursor } if partialFailureCount > 0 { SyncLogger.shared.log("⚠️ [CK] \(query.recordType) query had \(partialFailureCount) per-record failures") if allRecords.isEmpty, let firstPartialFailure { throw firstPartialFailure } } return allRecords } /// Normalizes a stored sync timestamp into a safe CloudKit delta start. /// - Returns: `nil` when a full sync should be used. private func effectiveDeltaStartDate(_ lastSync: Date?) -> Date? { guard let lastSync else { return nil } let now = Date() if lastSync > now.addingTimeInterval(60) { SyncLogger.shared.log("⚠️ [CK] Last sync timestamp is in the future; falling back to full sync") return nil } return lastSync.addingTimeInterval(-deltaOverlapSeconds) } /// Fails fast when CloudKit returned records but none were parseable. private func throwIfAllDropped(recordType: String, totalRecords: Int, parsedRecords: Int) throws { guard totalRecords > 0, parsedRecords == 0 else { return } let message = "All \(recordType) records were unparseable (\(totalRecords) fetched)" SyncLogger.shared.log("❌ [CK] \(message)") throw CloudKitError.serverError(message) } // MARK: - Sync Types (include canonical IDs from CloudKit) struct SyncStadium { let stadium: Stadium let canonicalId: String } struct SyncTeam { let team: Team let canonicalId: String let stadiumCanonicalId: String? } struct SyncGame { let game: Game let canonicalId: String let homeTeamCanonicalId: String let awayTeamCanonicalId: String let stadiumCanonicalId: String? } struct SyncLeagueStructure: Sendable { let id: String let sport: String let structureTypeRaw: String let name: String let abbreviation: String? let parentId: String? let displayOrder: Int let schemaVersion: Int let lastModified: Date } struct SyncTeamAlias: Sendable { let id: String let teamCanonicalId: String let aliasTypeRaw: String let aliasValue: String let validFrom: Date? let validUntil: Date? let schemaVersion: Int let lastModified: Date } struct SyncStadiumAlias: Sendable { let aliasName: String let stadiumCanonicalId: String let validFrom: Date? let validUntil: Date? let schemaVersion: Int let lastModified: Date } struct SyncSport: Sendable { let id: String let abbreviation: String let displayName: String let iconName: String let colorHex: String let seasonStartMonth: Int let seasonEndMonth: Int let isActive: Bool let schemaVersion: Int let lastModified: Date } // MARK: - Availability Check func isAvailable() async -> Bool { let status = await checkAccountStatus() switch status { case .available, .noAccount, .couldNotDetermine: // Public DB reads should still be attempted without an iCloud account. return true case .restricted, .temporarilyUnavailable: return false @unknown default: return false } } func checkAvailabilityWithError() async throws { guard await isAvailable() else { let status = await checkAccountStatus() switch status { case .restricted: throw CloudKitError.permissionDenied case .temporarilyUnavailable: throw CloudKitError.networkUnavailable default: throw CloudKitError.networkUnavailable } } } // MARK: - Fetch Operations func fetchTeams(for sport: Sport) async throws -> [Team] { let predicate = NSPredicate(format: "sport == %@", sport.rawValue) let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKTeam(record: record).team } } func fetchStadiums() async throws -> [Stadium] { let predicate = NSPredicate(value: true) let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKStadium(record: record).stadium } } func fetchGames( sports: Set, startDate: Date, endDate: Date ) async throws -> [Game] { var allGames: [Game] = [] for sport in sports { let predicate = NSPredicate( format: "sport == %@ AND dateTime >= %@ AND dateTime <= %@", sport.rawValue, startDate as NSDate, endDate as NSDate ) let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) let games = results.compactMap { result -> Game? in guard case .success(let record) = result.1 else { return nil } let ckGame = CKGame(record: record) guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference, let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference else { return nil } let homeId = homeRef.recordID.recordName let awayId = awayRef.recordID.recordName // Stadium ref is optional - use deterministic placeholder if not present let stadiumId: String if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference { stadiumId = stadiumRef.recordID.recordName } else { stadiumId = "placeholder_\(record.recordID.recordName)" } return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId) } allGames.append(contentsOf: games) } return allGames.sorted { $0.dateTime < $1.dateTime } } func fetchGame(by id: String) async throws -> Game? { let predicate = NSPredicate(format: "gameId == %@", id) let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) guard let result = results.first, case .success(let record) = result.1 else { return nil } let ckGame = CKGame(record: record) guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference, let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference, let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference else { return nil } let homeId = homeRef.recordID.recordName let awayId = awayRef.recordID.recordName let stadiumId = stadiumRef.recordID.recordName return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId) } // MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit) /// Fetch stadiums for sync operations /// - 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 log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching stadiums modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL stadiums (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) stadium records from CloudKit") var validStadiums: [SyncStadium] = [] var skipped = 0 for record in records { let ckStadium = CKStadium(record: record) guard let stadium = ckStadium.stadium, let canonicalId = ckStadium.canonicalId else { skipped += 1 continue } validStadiums.append(SyncStadium(stadium: stadium, canonicalId: canonicalId)) } if skipped > 0 { log.log("⚠️ [CK] Skipped \(skipped) stadium records due to missing required fields") } try throwIfAllDropped(recordType: CKRecordType.stadium, totalRecords: records.count, parsedRecords: validStadiums.count) return validStadiums } /// Fetch teams for sync operations /// - 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 log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching teams modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL teams (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.team, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) team records from CloudKit") var validTeams: [SyncTeam] = [] var skipped = 0 var missingStadiumRef = 0 for record in records { let ckTeam = CKTeam(record: record) guard let team = ckTeam.team, let canonicalId = ckTeam.canonicalId else { skipped += 1 continue } let stadiumCanonicalId = ckTeam.stadiumCanonicalId if stadiumCanonicalId == nil { missingStadiumRef += 1 } validTeams.append(SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)) } if skipped > 0 { log.log("⚠️ [CK] Skipped \(skipped) team records due to missing required fields") } if missingStadiumRef > 0 { log.log("⚠️ [CK] \(missingStadiumRef) team records are missing stadium refs; merge will preserve local stadiums when possible") } try throwIfAllDropped(recordType: CKRecordType.team, totalRecords: records.count, parsedRecords: validTeams.count) return validTeams } /// Fetch games for sync operations /// - 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 log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching games modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL games (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) game records from CloudKit") var validGames: [SyncGame] = [] var skippedMissingIds = 0 var skippedInvalidGame = 0 var missingStadiumRef = 0 for record in records { let ckGame = CKGame(record: record) guard let canonicalId = ckGame.canonicalId, let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, let awayTeamCanonicalId = ckGame.awayTeamCanonicalId else { skippedMissingIds += 1 continue } let stadiumCanonicalId = ckGame.stadiumCanonicalId if stadiumCanonicalId == nil { missingStadiumRef += 1 } guard let game = ckGame.game( homeTeamId: homeTeamCanonicalId, awayTeamId: awayTeamCanonicalId, stadiumId: stadiumCanonicalId ?? "stadium_placeholder_\(canonicalId)" ) else { skippedInvalidGame += 1 continue } validGames.append(SyncGame( game: game, canonicalId: canonicalId, homeTeamCanonicalId: homeTeamCanonicalId, awayTeamCanonicalId: awayTeamCanonicalId, stadiumCanonicalId: stadiumCanonicalId )) } log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)") if missingStadiumRef > 0 { log.log("⚠️ [CK] \(missingStadiumRef) games are missing stadium refs; merge will derive stadiums from team mappings when possible") } try throwIfAllDropped(recordType: CKRecordType.game, totalRecords: records.count, parsedRecords: validGames.count) // Log sport breakdown var bySport: [String: Int] = [:] for g in validGames { bySport[g.game.sport.rawValue, default: 0] += 1 } for (sport, count) in bySport.sorted(by: { $0.key < $1.key }) { log.log("☁️ [CK] \(sport): \(count) games") } return validGames.sorted { $0.game.dateTime < $1.game.dateTime } } // MARK: - League Structure & Team Aliases func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] { let predicate: NSPredicate if let sport = sport { predicate = NSPredicate(format: "sport == %@", sport.rawValue) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)] let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKLeagueStructure(record: record).toModel() } } func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] { let predicate: NSPredicate if let teamId = teamCanonicalId { predicate = NSPredicate(format: "teamCanonicalId == %@", teamId) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKTeamAlias(record: record).toModel() } } func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] { let predicate: NSPredicate if let stadiumId = stadiumCanonicalId { predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId) } else { predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKStadiumAlias(record: record).toModel() } } // MARK: - Delta Sync (Date-Based for Public Database) /// Fetch league structure records modified after the given date /// - 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 -> [SyncLeagueStructure] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching league structures modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL league structures (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit") var parsed: [SyncLeagueStructure] = [] var skipped = 0 for record in records { if let model = CKLeagueStructure(record: record).toModel() { parsed.append( SyncLeagueStructure( id: model.id, sport: model.sport, structureTypeRaw: model.structureTypeRaw, name: model.name, abbreviation: model.abbreviation, parentId: model.parentId, displayOrder: model.displayOrder, schemaVersion: model.schemaVersion, lastModified: model.lastModified ) ) } else { skipped += 1 } } if skipped > 0 { log.log("⚠️ [CK] Skipped \(skipped) league structure records due to missing required fields") } try throwIfAllDropped(recordType: CKRecordType.leagueStructure, totalRecords: records.count, parsedRecords: parsed.count) return parsed.sorted { lhs, rhs in lhs.lastModified < rhs.lastModified } } /// Fetch team alias records modified after the given date /// - 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 -> [SyncTeamAlias] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching team aliases modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL team aliases (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit") var parsed: [SyncTeamAlias] = [] var skipped = 0 for record in records { if let model = CKTeamAlias(record: record).toModel() { parsed.append( SyncTeamAlias( id: model.id, teamCanonicalId: model.teamCanonicalId, aliasTypeRaw: model.aliasTypeRaw, aliasValue: model.aliasValue, validFrom: model.validFrom, validUntil: model.validUntil, schemaVersion: model.schemaVersion, lastModified: model.lastModified ) ) } else { skipped += 1 } } if skipped > 0 { log.log("⚠️ [CK] Skipped \(skipped) team alias records due to missing required fields") } try throwIfAllDropped(recordType: CKRecordType.teamAlias, totalRecords: records.count, parsedRecords: parsed.count) return parsed.sorted { lhs, rhs in lhs.lastModified < rhs.lastModified } } /// Fetch stadium alias records modified after the given date /// - 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 -> [SyncStadiumAlias] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching stadium aliases modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL stadium aliases (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit") var parsed: [SyncStadiumAlias] = [] var skipped = 0 for record in records { if let model = CKStadiumAlias(record: record).toModel() { parsed.append( SyncStadiumAlias( aliasName: model.aliasName, stadiumCanonicalId: model.stadiumCanonicalId, validFrom: model.validFrom, validUntil: model.validUntil, schemaVersion: model.schemaVersion, lastModified: model.lastModified ) ) } else { skipped += 1 } } if skipped > 0 { log.log("⚠️ [CK] Skipped \(skipped) stadium alias records due to missing required fields") } try throwIfAllDropped(recordType: CKRecordType.stadiumAlias, totalRecords: records.count, parsedRecords: parsed.count) return parsed.sorted { lhs, rhs in lhs.lastModified < rhs.lastModified } } // MARK: - Sport Sync /// Fetch sports for sync operations /// - 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 -> [SyncSport] { let log = SyncLogger.shared let predicate: NSPredicate if let deltaStart = effectiveDeltaStartDate(lastSync) { log.log("☁️ [CK] Fetching sports modified since \(deltaStart.formatted()) (overlap window applied)") predicate = NSPredicate(format: "modificationDate >= %@", deltaStart as NSDate) } else { log.log("☁️ [CK] Fetching ALL sports (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) log.log("☁️ [CK] Received \(records.count) sport records from CloudKit") var parsed: [SyncSport] = [] var skipped = 0 for record in records { if let sport = CKSport(record: record).toCanonical() { parsed.append( SyncSport( id: sport.id, abbreviation: sport.abbreviation, displayName: sport.displayName, iconName: sport.iconName, colorHex: sport.colorHex, seasonStartMonth: sport.seasonStartMonth, seasonEndMonth: sport.seasonEndMonth, isActive: sport.isActive, schemaVersion: sport.schemaVersion, lastModified: sport.lastModified ) ) } else { skipped += 1 } } if skipped > 0 { log.log("⚠️ [CK] Skipped \(skipped) sport records due to missing required fields") } try throwIfAllDropped(recordType: CKRecordType.sport, totalRecords: records.count, parsedRecords: parsed.count) return parsed } // MARK: - Sync Status func checkAccountStatus() async -> CKAccountStatus { do { return try await container.accountStatus() } catch { return .couldNotDetermine } } // MARK: - Subscriptions func subscribeToScheduleUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.game, predicate: NSPredicate(value: true), subscriptionID: Self.gameUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } func subscribeToTeamUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.team, predicate: NSPredicate(value: true), subscriptionID: Self.teamUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } func subscribeToStadiumUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.stadium, predicate: NSPredicate(value: true), subscriptionID: Self.stadiumUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } func subscribeToLeagueStructureUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.leagueStructure, predicate: NSPredicate(value: true), subscriptionID: Self.leagueStructureUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } func subscribeToTeamAliasUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.teamAlias, predicate: NSPredicate(value: true), subscriptionID: Self.teamAliasUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } func subscribeToStadiumAliasUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.stadiumAlias, predicate: NSPredicate(value: true), subscriptionID: Self.stadiumAliasUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } func subscribeToSportUpdates() async throws { let subscription = CKQuerySubscription( recordType: CKRecordType.sport, predicate: NSPredicate(value: true), subscriptionID: Self.sportUpdatesSubscriptionID, options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await saveSubscriptionIfNeeded(subscription) } /// Subscribe to all canonical data updates func subscribeToAllUpdates() async throws { try await subscribeToScheduleUpdates() try await subscribeToTeamUpdates() try await subscribeToStadiumUpdates() try await subscribeToLeagueStructureUpdates() try await subscribeToTeamAliasUpdates() try await subscribeToStadiumAliasUpdates() try await subscribeToSportUpdates() } private func saveSubscriptionIfNeeded(_ subscription: CKQuerySubscription) async throws { do { try await publicDatabase.save(subscription) } catch let error as CKError where error.code == .serverRejectedRequest { // Existing subscriptions can be rejected as duplicates in some environments. // Confirm it exists before treating this as non-fatal. do { _ = try await publicDatabase.subscription(for: subscription.subscriptionID) SyncLogger.shared.log("ℹ️ [CK] Subscription already exists: \(subscription.subscriptionID)") } catch { throw error } } } }