- Add local canonicalization pipeline (stadiums, teams, games) that generates deterministic canonical IDs before CloudKit upload - Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs instead of random UUIDs from CloudKit records - Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve canonical ID relationships during sync - Add canonical ID field keys to CKModels for reading from CloudKit records - Bundle canonical JSON files (stadiums_canonical, teams_canonical, games_canonical, stadium_aliases) for consistent bootstrap data - Update BootstrapService to prefer canonical format files over legacy format This ensures all entities use consistent deterministic UUIDs derived from their canonical IDs, preventing duplicate records when syncing CloudKit data with bootstrapped local data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
503 lines
18 KiB
Swift
503 lines
18 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()
|
|
|
|
private let container: CKContainer
|
|
private let publicDatabase: CKDatabase
|
|
|
|
private init() {
|
|
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
|
self.publicDatabase = container.publicCloudDatabase
|
|
}
|
|
|
|
// 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()
|
|
return status == .available
|
|
}
|
|
|
|
func checkAvailabilityWithError() async throws {
|
|
let status = await checkAccountStatus()
|
|
switch status {
|
|
case .available:
|
|
return
|
|
case .noAccount:
|
|
throw CloudKitError.notSignedIn
|
|
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,
|
|
let homeId = UUID(uuidString: homeRef.recordID.recordName),
|
|
let awayId = UUID(uuidString: awayRef.recordID.recordName)
|
|
else { return nil }
|
|
|
|
// Stadium ref is optional - use placeholder if not present
|
|
let stadiumId: UUID
|
|
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
|
|
let refId = UUID(uuidString: stadiumRef.recordID.recordName) {
|
|
stadiumId = refId
|
|
} else {
|
|
stadiumId = UUID() // 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: UUID) async throws -> Game? {
|
|
let predicate = NSPredicate(format: "gameId == %@", id.uuidString)
|
|
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,
|
|
let homeId = UUID(uuidString: homeRef.recordID.recordName),
|
|
let awayId = UUID(uuidString: awayRef.recordID.recordName),
|
|
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
|
|
else { return nil }
|
|
|
|
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
|
}
|
|
|
|
// MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit)
|
|
|
|
/// Fetch stadiums with canonical IDs for sync operations
|
|
func fetchStadiumsForSync() async throws -> [SyncStadium] {
|
|
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 -> SyncStadium? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
let ckStadium = CKStadium(record: record)
|
|
guard let stadium = ckStadium.stadium,
|
|
let canonicalId = ckStadium.canonicalId
|
|
else { return nil }
|
|
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
|
|
}
|
|
}
|
|
|
|
/// Fetch teams with canonical IDs for sync operations
|
|
func fetchTeamsForSync(for sport: Sport) async throws -> [SyncTeam] {
|
|
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 -> SyncTeam? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
let ckTeam = CKTeam(record: record)
|
|
guard let team = ckTeam.team,
|
|
let canonicalId = ckTeam.canonicalId,
|
|
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
|
|
else { return nil }
|
|
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
|
|
}
|
|
}
|
|
|
|
/// Fetch games with canonical IDs for sync operations
|
|
func fetchGamesForSync(
|
|
sports: Set<Sport>,
|
|
startDate: Date,
|
|
endDate: Date
|
|
) async throws -> [SyncGame] {
|
|
var allGames: [SyncGame] = []
|
|
|
|
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 -> SyncGame? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
let ckGame = CKGame(record: record)
|
|
|
|
// Extract canonical IDs directly from CloudKit
|
|
guard let canonicalId = ckGame.canonicalId,
|
|
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
|
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
|
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
|
else { return nil }
|
|
|
|
// For the Game domain object, we still need UUIDs - use placeholder
|
|
// The sync service will use canonical IDs for relationships
|
|
let placeholderUUID = UUID()
|
|
guard let game = ckGame.game(
|
|
homeTeamId: placeholderUUID,
|
|
awayTeamId: placeholderUUID,
|
|
stadiumId: placeholderUUID
|
|
) else { return nil }
|
|
|
|
return SyncGame(
|
|
game: game,
|
|
canonicalId: canonicalId,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId
|
|
)
|
|
}
|
|
|
|
allGames.append(contentsOf: games)
|
|
}
|
|
|
|
return allGames.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
|
|
func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, 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()
|
|
}
|
|
}
|
|
|
|
/// Fetch team alias records modified after the given date
|
|
func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, 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 CKTeamAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
/// Fetch stadium alias records modified after the given date
|
|
func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, 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 CKStadiumAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
// 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: "game-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
func subscribeToLeagueStructureUpdates() async throws {
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.leagueStructure,
|
|
predicate: NSPredicate(value: true),
|
|
subscriptionID: "league-structure-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
func subscribeToTeamAliasUpdates() async throws {
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.teamAlias,
|
|
predicate: NSPredicate(value: true),
|
|
subscriptionID: "team-alias-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
func subscribeToStadiumAliasUpdates() async throws {
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.stadiumAlias,
|
|
predicate: NSPredicate(value: true),
|
|
subscriptionID: "stadium-alias-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
/// Subscribe to all canonical data updates
|
|
func subscribeToAllUpdates() async throws {
|
|
try await subscribeToScheduleUpdates()
|
|
try await subscribeToLeagueStructureUpdates()
|
|
try await subscribeToTeamAliasUpdates()
|
|
try await subscribeToStadiumAliasUpdates()
|
|
}
|
|
}
|