878 lines
34 KiB
Swift
878 lines
34 KiB
Swift
//
|
||
// 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() {
|
||
// Use target entitlements (debug/prod) instead of hardcoding a container ID.
|
||
self.container = CKContainer.default()
|
||
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?
|
||
}
|
||
|
||
// 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 -> [LeagueStructureModel] {
|
||
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: [LeagueStructureModel] = []
|
||
var skipped = 0
|
||
for record in records {
|
||
if let model = CKLeagueStructure(record: record).toModel() {
|
||
parsed.append(model)
|
||
} 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 -> [TeamAlias] {
|
||
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: [TeamAlias] = []
|
||
var skipped = 0
|
||
for record in records {
|
||
if let model = CKTeamAlias(record: record).toModel() {
|
||
parsed.append(model)
|
||
} 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 -> [StadiumAlias] {
|
||
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: [StadiumAlias] = []
|
||
var skipped = 0
|
||
for record in records {
|
||
if let model = CKStadiumAlias(record: record).toModel() {
|
||
parsed.append(model)
|
||
} 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 -> [CanonicalSport] {
|
||
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: [CanonicalSport] = []
|
||
var skipped = 0
|
||
for record in records {
|
||
if let sport = CKSport(record: record).toCanonical() {
|
||
parsed.append(sport)
|
||
} 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
|
||
}
|
||
}
|
||
}
|
||
}
|