Files
Sportstime/SportsTime/Core/Services/CloudKitService.swift

967 lines
38 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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<String> = [
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 {
let status = await checkAccountStatus()
switch status {
case .available, .noAccount:
return
case .restricted:
throw CloudKitError.permissionDenied
case .couldNotDetermine:
throw CloudKitError.networkUnavailable
case .temporarilyUnavailable:
throw CloudKitError.networkUnavailable
@unknown 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<Sport>,
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 placeholder if not present
let stadiumId: String
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference {
stadiumId = stadiumRef.recordID.recordName
} else {
stadiumId = "stadium_placeholder_\(UUID().uuidString)" // Placeholder - will be resolved via team lookup
}
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
}
}
}
}